Improve tasks and backdate insights
This commit is contained in:
@@ -1,16 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.db.models import Count, Max, Q
|
||||
from django.shortcuts import render
|
||||
from django.views import View
|
||||
|
||||
from core.models import (
|
||||
ContactAvailabilityEvent,
|
||||
ContactAvailabilitySettings,
|
||||
ContactAvailabilitySpan,
|
||||
Person,
|
||||
)
|
||||
|
||||
|
||||
@@ -32,19 +29,6 @@ def _to_bool(value, default=False):
|
||||
return bool(default)
|
||||
|
||||
|
||||
def _iso_to_ms(value: str) -> int:
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
return 0
|
||||
try:
|
||||
dt = datetime.fromisoformat(raw)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return int(dt.timestamp() * 1000)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
class AvailabilitySettingsPage(LoginRequiredMixin, View):
|
||||
template_name = "pages/availability-settings.html"
|
||||
|
||||
@@ -81,67 +65,45 @@ class AvailabilitySettingsPage(LoginRequiredMixin, View):
|
||||
|
||||
def get(self, request):
|
||||
settings_row = self._settings(request)
|
||||
person_id = str(request.GET.get("person") or "").strip()
|
||||
service = str(request.GET.get("service") or "").strip().lower()
|
||||
state = str(request.GET.get("state") or "").strip().lower()
|
||||
source_kind = str(request.GET.get("source_kind") or "").strip().lower()
|
||||
start_ts = _iso_to_ms(request.GET.get("start"))
|
||||
end_ts = _iso_to_ms(request.GET.get("end"))
|
||||
if end_ts <= 0:
|
||||
end_ts = int(datetime.now(tz=timezone.utc).timestamp() * 1000)
|
||||
if start_ts <= 0:
|
||||
start_ts = max(0, end_ts - (14 * 24 * 60 * 60 * 1000))
|
||||
|
||||
events_qs = ContactAvailabilityEvent.objects.filter(user=request.user)
|
||||
spans_qs = ContactAvailabilitySpan.objects.filter(user=request.user)
|
||||
|
||||
if person_id:
|
||||
events_qs = events_qs.filter(person_id=person_id)
|
||||
spans_qs = spans_qs.filter(person_id=person_id)
|
||||
if service:
|
||||
events_qs = events_qs.filter(service=service)
|
||||
spans_qs = spans_qs.filter(service=service)
|
||||
if state:
|
||||
events_qs = events_qs.filter(availability_state=state)
|
||||
spans_qs = spans_qs.filter(state=state)
|
||||
if source_kind:
|
||||
events_qs = events_qs.filter(source_kind=source_kind)
|
||||
|
||||
events_qs = events_qs.filter(ts__gte=start_ts, ts__lte=end_ts)
|
||||
spans_qs = spans_qs.filter(start_ts__lte=end_ts, end_ts__gte=start_ts)
|
||||
|
||||
events = list(
|
||||
events_qs.select_related("person", "person_identifier").order_by("-ts")[:500]
|
||||
contact_stats = list(
|
||||
ContactAvailabilityEvent.objects.filter(
|
||||
user=request.user, person__isnull=False
|
||||
)
|
||||
.values("person_id", "person__name", "service")
|
||||
.annotate(
|
||||
total_events=Count("id"),
|
||||
available_events=Count(
|
||||
"id", filter=Q(availability_state="available")
|
||||
),
|
||||
fading_events=Count("id", filter=Q(availability_state="fading")),
|
||||
unavailable_events=Count(
|
||||
"id", filter=Q(availability_state="unavailable")
|
||||
),
|
||||
unknown_events=Count("id", filter=Q(availability_state="unknown")),
|
||||
native_presence_events=Count(
|
||||
"id", filter=Q(source_kind="native_presence")
|
||||
),
|
||||
read_receipt_events=Count("id", filter=Q(source_kind="read_receipt")),
|
||||
typing_events=Count(
|
||||
"id",
|
||||
filter=Q(source_kind="typing_start")
|
||||
| Q(source_kind="typing_stop"),
|
||||
),
|
||||
message_activity_events=Count(
|
||||
"id",
|
||||
filter=Q(source_kind="message_in")
|
||||
| Q(source_kind="message_out"),
|
||||
),
|
||||
inferred_timeout_events=Count(
|
||||
"id", filter=Q(source_kind="inferred_timeout")
|
||||
),
|
||||
last_event_ts=Max("ts"),
|
||||
)
|
||||
.order_by("-total_events", "person__name", "service")
|
||||
)
|
||||
spans = list(
|
||||
spans_qs.select_related("person", "person_identifier").order_by("-end_ts")[:500]
|
||||
)
|
||||
|
||||
people = list(Person.objects.filter(user=request.user).order_by("name"))
|
||||
|
||||
context = {
|
||||
"settings_row": settings_row,
|
||||
"people": people,
|
||||
"events": events,
|
||||
"spans": spans,
|
||||
"filters": {
|
||||
"person": person_id,
|
||||
"service": service,
|
||||
"state": state,
|
||||
"source_kind": source_kind,
|
||||
"start": request.GET.get("start") or "",
|
||||
"end": request.GET.get("end") or "",
|
||||
},
|
||||
"service_choices": ["signal", "whatsapp", "xmpp", "instagram", "web"],
|
||||
"state_choices": ["available", "fading", "unavailable", "unknown"],
|
||||
"source_kind_choices": [
|
||||
"native_presence",
|
||||
"read_receipt",
|
||||
"typing_start",
|
||||
"typing_stop",
|
||||
"message_in",
|
||||
"message_out",
|
||||
"inferred_timeout",
|
||||
],
|
||||
"contact_stats": contact_stats,
|
||||
}
|
||||
return render(request, self.template_name, context)
|
||||
|
||||
@@ -1228,7 +1228,7 @@ def _trend_meta(current, previous, higher_is_better=True):
|
||||
}
|
||||
|
||||
|
||||
def _emotion_meta(metric_kind, value):
|
||||
def _emotion_meta(metric_kind, value, metric_key=None):
|
||||
score = _to_float(value)
|
||||
if score is None:
|
||||
return {
|
||||
@@ -1239,6 +1239,25 @@ def _emotion_meta(metric_kind, value):
|
||||
if metric_kind == "confidence":
|
||||
score = score * 100.0
|
||||
if metric_kind == "count":
|
||||
key = str(metric_key or "").strip().lower()
|
||||
if key == "sample_days":
|
||||
if score >= 14:
|
||||
return {
|
||||
"icon": "fa-solid fa-calendar-check",
|
||||
"class_name": "has-text-success",
|
||||
"label": "Broad Coverage",
|
||||
}
|
||||
if score >= 5:
|
||||
return {
|
||||
"icon": "fa-solid fa-calendar-days",
|
||||
"class_name": "has-text-warning",
|
||||
"label": "Adequate Coverage",
|
||||
}
|
||||
return {
|
||||
"icon": "fa-solid fa-calendar-xmark",
|
||||
"class_name": "has-text-danger",
|
||||
"label": "Narrow Coverage",
|
||||
}
|
||||
if score >= 80:
|
||||
return {
|
||||
"icon": "fa-solid fa-chart-column",
|
||||
@@ -1433,7 +1452,7 @@ def _quick_insights_rows(conversation):
|
||||
point_count = conversation.metric_snapshots.exclude(
|
||||
**{f"{field_name}__isnull": True}
|
||||
).count()
|
||||
emotion = _emotion_meta(spec["kind"], current)
|
||||
emotion = _emotion_meta(spec["kind"], current, spec["key"])
|
||||
rows.append(
|
||||
{
|
||||
"key": spec["key"],
|
||||
@@ -4035,6 +4054,7 @@ class ComposeQuickInsights(LoginRequiredMixin, View):
|
||||
"Arrow color indicates improving or risk direction for that metric.",
|
||||
"State uses participant feedback (Withdrawing/Overextending/Balanced) when available.",
|
||||
"Values are computed from all linked platform messages for this person.",
|
||||
"Data labels are metric-specific (for example, day coverage is rated separately from message volume).",
|
||||
"Face indicator maps value range to positive, mixed, or strained climate.",
|
||||
"Use this card for fast triage; open AI Workspace for full graphs and details.",
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2375,7 +2375,7 @@ def _refresh_conversation_stability(conversation, user, person):
|
||||
session__identifier__in=identifiers,
|
||||
)
|
||||
.order_by("ts")
|
||||
.values("ts", "sender_uuid", "session__identifier__service")
|
||||
.values("ts", "sender_uuid", "custom_author", "session__identifier__service")
|
||||
)
|
||||
if not rows:
|
||||
conversation.stability_state = WorkspaceConversation.StabilityState.CALIBRATING
|
||||
@@ -2446,7 +2446,13 @@ def _refresh_conversation_stability(conversation, user, person):
|
||||
for row in rows:
|
||||
ts = int(row["ts"] or 0)
|
||||
sender = str(row.get("sender_uuid") or "").strip()
|
||||
is_inbound = sender in identifier_values
|
||||
author = str(row.get("custom_author") or "").strip().upper()
|
||||
if author in {"USER", "BOT"}:
|
||||
is_inbound = False
|
||||
elif author == "OTHER":
|
||||
is_inbound = True
|
||||
else:
|
||||
is_inbound = sender in identifier_values
|
||||
direction = "in" if is_inbound else "out"
|
||||
day_key = datetime.fromtimestamp(ts / 1000, tz=timezone.utc).date().isoformat()
|
||||
daily_counts[day_key] = daily_counts.get(day_key, 0) + 1
|
||||
|
||||
Reference in New Issue
Block a user