Improve tasks and backdate insights
This commit is contained in:
424
core/management/commands/reconcile_workspace_metric_history.py
Normal file
424
core/management/commands/reconcile_workspace_metric_history.py
Normal file
@@ -0,0 +1,424 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import statistics
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone as dj_timezone
|
||||
|
||||
from core.models import (
|
||||
Message,
|
||||
Person,
|
||||
PersonIdentifier,
|
||||
WorkspaceConversation,
|
||||
WorkspaceMetricSnapshot,
|
||||
)
|
||||
from core.views.workspace import _conversation_for_person
|
||||
|
||||
|
||||
def _score_from_lag(lag_ms, target_hours=4):
|
||||
if lag_ms is None:
|
||||
return 50.0
|
||||
target_ms = max(1, int(target_hours)) * 60 * 60 * 1000
|
||||
return max(0.0, min(100.0, 100.0 / (1.0 + (float(lag_ms) / target_ms))))
|
||||
|
||||
|
||||
def _median_or_none(values):
|
||||
if not values:
|
||||
return None
|
||||
return float(statistics.median(values))
|
||||
|
||||
|
||||
def _calibrating_payload(last_ts=None):
|
||||
return {
|
||||
"source_event_ts": int(last_ts) if last_ts else None,
|
||||
"stability_state": WorkspaceConversation.StabilityState.CALIBRATING,
|
||||
"stability_score": None,
|
||||
"stability_confidence": 0.0,
|
||||
"stability_sample_messages": 0,
|
||||
"stability_sample_days": 0,
|
||||
"commitment_inbound_score": None,
|
||||
"commitment_outbound_score": None,
|
||||
"commitment_confidence": 0.0,
|
||||
"inbound_messages": 0,
|
||||
"outbound_messages": 0,
|
||||
"reciprocity_score": None,
|
||||
"continuity_score": None,
|
||||
"response_score": None,
|
||||
"volatility_score": None,
|
||||
"inbound_response_score": None,
|
||||
"outbound_response_score": None,
|
||||
"balance_inbound_score": None,
|
||||
"balance_outbound_score": None,
|
||||
}
|
||||
|
||||
|
||||
def _compute_payload(rows, identifier_values):
|
||||
if not rows:
|
||||
return _calibrating_payload(None)
|
||||
|
||||
inbound_count = 0
|
||||
outbound_count = 0
|
||||
daily_counts = {}
|
||||
inbound_response_lags = []
|
||||
outbound_response_lags = []
|
||||
pending_in_ts = None
|
||||
pending_out_ts = None
|
||||
first_ts = int(rows[0]["ts"] or 0)
|
||||
last_ts = int(rows[-1]["ts"] or 0)
|
||||
latest_service = str(rows[-1].get("session__identifier__service") or "").strip().lower()
|
||||
|
||||
for row in rows:
|
||||
ts = int(row.get("ts") or 0)
|
||||
sender = str(row.get("sender_uuid") or "").strip()
|
||||
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
|
||||
|
||||
if direction == "in":
|
||||
inbound_count += 1
|
||||
if pending_out_ts is not None and ts >= pending_out_ts:
|
||||
inbound_response_lags.append(ts - pending_out_ts)
|
||||
pending_out_ts = None
|
||||
pending_in_ts = ts
|
||||
else:
|
||||
outbound_count += 1
|
||||
if pending_in_ts is not None and ts >= pending_in_ts:
|
||||
outbound_response_lags.append(ts - pending_in_ts)
|
||||
pending_in_ts = None
|
||||
pending_out_ts = ts
|
||||
|
||||
message_count = len(rows)
|
||||
span_days = max(1, int(((last_ts - first_ts) / (24 * 60 * 60 * 1000)) + 1))
|
||||
sample_days = len(daily_counts)
|
||||
|
||||
total_messages = max(1, inbound_count + outbound_count)
|
||||
reciprocity_score = 100.0 * (
|
||||
1.0 - abs(inbound_count - outbound_count) / total_messages
|
||||
)
|
||||
continuity_score = 100.0 * min(1.0, sample_days / max(1, span_days))
|
||||
out_resp_score = _score_from_lag(_median_or_none(outbound_response_lags))
|
||||
in_resp_score = _score_from_lag(_median_or_none(inbound_response_lags))
|
||||
response_score = (out_resp_score + in_resp_score) / 2.0
|
||||
|
||||
daily_values = list(daily_counts.values())
|
||||
if len(daily_values) > 1:
|
||||
mean_daily = statistics.mean(daily_values)
|
||||
stdev_daily = statistics.pstdev(daily_values)
|
||||
cv = (stdev_daily / mean_daily) if mean_daily else 1.0
|
||||
volatility_score = max(0.0, 100.0 * (1.0 - min(cv, 1.5) / 1.5))
|
||||
else:
|
||||
volatility_score = 60.0
|
||||
|
||||
stability_score = (
|
||||
(0.35 * reciprocity_score)
|
||||
+ (0.25 * continuity_score)
|
||||
+ (0.20 * response_score)
|
||||
+ (0.20 * volatility_score)
|
||||
)
|
||||
|
||||
balance_out = 100.0 * min(1.0, outbound_count / max(1, inbound_count))
|
||||
balance_in = 100.0 * min(1.0, inbound_count / max(1, outbound_count))
|
||||
commitment_out = (0.60 * out_resp_score) + (0.40 * balance_out)
|
||||
commitment_in = (0.60 * in_resp_score) + (0.40 * balance_in)
|
||||
|
||||
msg_conf = min(1.0, message_count / 200.0)
|
||||
day_conf = min(1.0, sample_days / 30.0)
|
||||
pair_conf = min(
|
||||
1.0, (len(inbound_response_lags) + len(outbound_response_lags)) / 40.0
|
||||
)
|
||||
confidence = (0.50 * msg_conf) + (0.30 * day_conf) + (0.20 * pair_conf)
|
||||
|
||||
if message_count < 20 or sample_days < 3 or confidence < 0.25:
|
||||
stability_state = WorkspaceConversation.StabilityState.CALIBRATING
|
||||
stability_score_value = None
|
||||
commitment_in_value = None
|
||||
commitment_out_value = None
|
||||
else:
|
||||
stability_score_value = round(stability_score, 2)
|
||||
commitment_in_value = round(commitment_in, 2)
|
||||
commitment_out_value = round(commitment_out, 2)
|
||||
if stability_score_value >= 70:
|
||||
stability_state = WorkspaceConversation.StabilityState.STABLE
|
||||
elif stability_score_value >= 50:
|
||||
stability_state = WorkspaceConversation.StabilityState.WATCH
|
||||
else:
|
||||
stability_state = WorkspaceConversation.StabilityState.FRAGILE
|
||||
|
||||
feedback_state = "balanced"
|
||||
if outbound_count > (inbound_count * 1.5):
|
||||
feedback_state = "withdrawing"
|
||||
elif inbound_count > (outbound_count * 1.5):
|
||||
feedback_state = "overextending"
|
||||
|
||||
payload = {
|
||||
"source_event_ts": last_ts,
|
||||
"stability_state": stability_state,
|
||||
"stability_score": float(stability_score_value)
|
||||
if stability_score_value is not None
|
||||
else None,
|
||||
"stability_confidence": round(confidence, 3),
|
||||
"stability_sample_messages": message_count,
|
||||
"stability_sample_days": sample_days,
|
||||
"commitment_inbound_score": float(commitment_in_value)
|
||||
if commitment_in_value is not None
|
||||
else None,
|
||||
"commitment_outbound_score": float(commitment_out_value)
|
||||
if commitment_out_value is not None
|
||||
else None,
|
||||
"commitment_confidence": round(confidence, 3),
|
||||
"inbound_messages": inbound_count,
|
||||
"outbound_messages": outbound_count,
|
||||
"reciprocity_score": round(reciprocity_score, 3),
|
||||
"continuity_score": round(continuity_score, 3),
|
||||
"response_score": round(response_score, 3),
|
||||
"volatility_score": round(volatility_score, 3),
|
||||
"inbound_response_score": round(in_resp_score, 3),
|
||||
"outbound_response_score": round(out_resp_score, 3),
|
||||
"balance_inbound_score": round(balance_in, 3),
|
||||
"balance_outbound_score": round(balance_out, 3),
|
||||
}
|
||||
return payload, latest_service, feedback_state
|
||||
|
||||
|
||||
def _payload_signature(payload: dict) -> tuple:
|
||||
return (
|
||||
int(payload.get("source_event_ts") or 0),
|
||||
str(payload.get("stability_state") or ""),
|
||||
payload.get("stability_score"),
|
||||
float(payload.get("stability_confidence") or 0.0),
|
||||
int(payload.get("stability_sample_messages") or 0),
|
||||
int(payload.get("stability_sample_days") or 0),
|
||||
payload.get("commitment_inbound_score"),
|
||||
payload.get("commitment_outbound_score"),
|
||||
float(payload.get("commitment_confidence") or 0.0),
|
||||
int(payload.get("inbound_messages") or 0),
|
||||
int(payload.get("outbound_messages") or 0),
|
||||
)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = (
|
||||
"Reconcile AI Workspace metric history by deterministically rebuilding "
|
||||
"WorkspaceMetricSnapshot points from message history."
|
||||
)
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--days", type=int, default=365)
|
||||
parser.add_argument("--service", default="")
|
||||
parser.add_argument("--user-id", default="")
|
||||
parser.add_argument("--person-id", default="")
|
||||
parser.add_argument("--step-messages", type=int, default=2)
|
||||
parser.add_argument("--limit", type=int, default=200000)
|
||||
parser.add_argument("--dry-run", action="store_true", default=False)
|
||||
parser.add_argument("--no-reset", action="store_true", default=False)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
days = max(1, int(options.get("days") or 365))
|
||||
service = str(options.get("service") or "").strip().lower()
|
||||
user_id = str(options.get("user_id") or "").strip()
|
||||
person_id = str(options.get("person_id") or "").strip()
|
||||
step_messages = max(1, int(options.get("step_messages") or 2))
|
||||
limit = max(1, int(options.get("limit") or 200000))
|
||||
dry_run = bool(options.get("dry_run"))
|
||||
reset = not bool(options.get("no_reset"))
|
||||
today_start = dj_timezone.now().astimezone(timezone.utc).replace(
|
||||
hour=0,
|
||||
minute=0,
|
||||
second=0,
|
||||
microsecond=0,
|
||||
)
|
||||
cutoff_ts = int(
|
||||
(today_start.timestamp() * 1000) - (days * 24 * 60 * 60 * 1000)
|
||||
)
|
||||
|
||||
people_qs = Person.objects.all()
|
||||
if user_id:
|
||||
people_qs = people_qs.filter(user_id=user_id)
|
||||
if person_id:
|
||||
people_qs = people_qs.filter(id=person_id)
|
||||
people = list(people_qs.order_by("user_id", "name", "id"))
|
||||
|
||||
conversations_scanned = 0
|
||||
deleted = 0
|
||||
snapshots_created = 0
|
||||
checkpoints_total = 0
|
||||
|
||||
for person in people:
|
||||
identifiers_qs = PersonIdentifier.objects.filter(user=person.user, person=person)
|
||||
if service:
|
||||
identifiers_qs = identifiers_qs.filter(service=service)
|
||||
identifiers = list(identifiers_qs)
|
||||
if not identifiers:
|
||||
continue
|
||||
identifier_values = {
|
||||
str(row.identifier or "").strip() for row in identifiers if row.identifier
|
||||
}
|
||||
if not identifier_values:
|
||||
continue
|
||||
|
||||
rows = list(
|
||||
Message.objects.filter(
|
||||
user=person.user,
|
||||
session__identifier__in=identifiers,
|
||||
ts__gte=cutoff_ts,
|
||||
)
|
||||
.order_by("ts", "id")
|
||||
.values(
|
||||
"id",
|
||||
"ts",
|
||||
"sender_uuid",
|
||||
"custom_author",
|
||||
"session__identifier__service",
|
||||
)[:limit]
|
||||
)
|
||||
if not rows:
|
||||
continue
|
||||
|
||||
conversation = _conversation_for_person(person.user, person)
|
||||
conversations_scanned += 1
|
||||
|
||||
if reset and not dry_run:
|
||||
deleted += WorkspaceMetricSnapshot.objects.filter(
|
||||
conversation=conversation
|
||||
).delete()[0]
|
||||
|
||||
existing_signatures = set()
|
||||
if not reset:
|
||||
existing_signatures = set(
|
||||
_payload_signature(
|
||||
{
|
||||
"source_event_ts": row.source_event_ts,
|
||||
"stability_state": row.stability_state,
|
||||
"stability_score": row.stability_score,
|
||||
"stability_confidence": row.stability_confidence,
|
||||
"stability_sample_messages": row.stability_sample_messages,
|
||||
"stability_sample_days": row.stability_sample_days,
|
||||
"commitment_inbound_score": row.commitment_inbound_score,
|
||||
"commitment_outbound_score": row.commitment_outbound_score,
|
||||
"commitment_confidence": row.commitment_confidence,
|
||||
"inbound_messages": row.inbound_messages,
|
||||
"outbound_messages": row.outbound_messages,
|
||||
}
|
||||
)
|
||||
for row in WorkspaceMetricSnapshot.objects.filter(
|
||||
conversation=conversation
|
||||
).only(
|
||||
"source_event_ts",
|
||||
"stability_state",
|
||||
"stability_score",
|
||||
"stability_confidence",
|
||||
"stability_sample_messages",
|
||||
"stability_sample_days",
|
||||
"commitment_inbound_score",
|
||||
"commitment_outbound_score",
|
||||
"commitment_confidence",
|
||||
"inbound_messages",
|
||||
"outbound_messages",
|
||||
)
|
||||
)
|
||||
|
||||
checkpoints = list(range(step_messages, len(rows) + 1, step_messages))
|
||||
if not checkpoints or checkpoints[-1] != len(rows):
|
||||
checkpoints.append(len(rows))
|
||||
checkpoints_total += len(checkpoints)
|
||||
|
||||
latest_payload = None
|
||||
latest_service = ""
|
||||
latest_feedback_state = "balanced"
|
||||
|
||||
for stop in checkpoints:
|
||||
computed = _compute_payload(rows[:stop], identifier_values)
|
||||
payload = computed[0]
|
||||
latest_payload = payload
|
||||
latest_service = computed[1]
|
||||
latest_feedback_state = computed[2]
|
||||
signature = _payload_signature(payload)
|
||||
if not reset and signature in existing_signatures:
|
||||
continue
|
||||
snapshots_created += 1
|
||||
if dry_run:
|
||||
continue
|
||||
WorkspaceMetricSnapshot.objects.create(conversation=conversation, **payload)
|
||||
existing_signatures.add(signature)
|
||||
|
||||
if not latest_payload:
|
||||
continue
|
||||
|
||||
feedback = dict(conversation.participant_feedback or {})
|
||||
feedback[str(person.id)] = {
|
||||
"state": latest_feedback_state,
|
||||
"inbound_messages": int(latest_payload.get("inbound_messages") or 0),
|
||||
"outbound_messages": int(latest_payload.get("outbound_messages") or 0),
|
||||
"sample_messages": int(
|
||||
latest_payload.get("stability_sample_messages") or 0
|
||||
),
|
||||
"sample_days": int(latest_payload.get("stability_sample_days") or 0),
|
||||
"updated_at": dj_timezone.now().isoformat(),
|
||||
}
|
||||
if not dry_run:
|
||||
conversation.platform_type = latest_service or conversation.platform_type
|
||||
conversation.last_event_ts = latest_payload.get("source_event_ts")
|
||||
conversation.stability_state = str(
|
||||
latest_payload.get("stability_state")
|
||||
or WorkspaceConversation.StabilityState.CALIBRATING
|
||||
)
|
||||
conversation.stability_score = latest_payload.get("stability_score")
|
||||
conversation.stability_confidence = float(
|
||||
latest_payload.get("stability_confidence") or 0.0
|
||||
)
|
||||
conversation.stability_sample_messages = int(
|
||||
latest_payload.get("stability_sample_messages") or 0
|
||||
)
|
||||
conversation.stability_sample_days = int(
|
||||
latest_payload.get("stability_sample_days") or 0
|
||||
)
|
||||
conversation.commitment_inbound_score = latest_payload.get(
|
||||
"commitment_inbound_score"
|
||||
)
|
||||
conversation.commitment_outbound_score = latest_payload.get(
|
||||
"commitment_outbound_score"
|
||||
)
|
||||
conversation.commitment_confidence = float(
|
||||
latest_payload.get("commitment_confidence") or 0.0
|
||||
)
|
||||
now_ts = dj_timezone.now()
|
||||
conversation.stability_last_computed_at = now_ts
|
||||
conversation.commitment_last_computed_at = now_ts
|
||||
conversation.participant_feedback = feedback
|
||||
conversation.save(
|
||||
update_fields=[
|
||||
"platform_type",
|
||||
"last_event_ts",
|
||||
"stability_state",
|
||||
"stability_score",
|
||||
"stability_confidence",
|
||||
"stability_sample_messages",
|
||||
"stability_sample_days",
|
||||
"stability_last_computed_at",
|
||||
"commitment_inbound_score",
|
||||
"commitment_outbound_score",
|
||||
"commitment_confidence",
|
||||
"commitment_last_computed_at",
|
||||
"participant_feedback",
|
||||
]
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
"reconcile_workspace_metric_history complete "
|
||||
f"conversations_scanned={conversations_scanned} "
|
||||
f"checkpoints={checkpoints_total} "
|
||||
f"created={snapshots_created} "
|
||||
f"deleted={deleted} "
|
||||
f"reset={reset} dry_run={dry_run} "
|
||||
f"days={days} step_messages={step_messages} limit={limit}"
|
||||
)
|
||||
)
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
<div class="column is-12">
|
||||
<h1 class="title is-4" style="margin-bottom: 0.35rem;">Information: {{ person.name }}</h1>
|
||||
<p class="is-size-7 has-text-grey">Commitment directionality and underlying metric factors.</p>
|
||||
<p class="is-size-7 has-text-grey">Commitment directionality and underlying metric factors from deterministic message-history snapshots.</p>
|
||||
{% include "partials/ai-insight-nav.html" with active_tab="information" %}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<div class="column is-12">
|
||||
<h1 class="title is-4" style="margin-bottom: 0.35rem;">Insight Graphs: {{ person.name }}</h1>
|
||||
<p class="is-size-7 has-text-grey">
|
||||
Historical metrics for workspace {{ workspace_conversation.id }}
|
||||
Historical metrics for workspace {{ workspace_conversation.id }}. Points come from deterministic message-history snapshots (not only mitigation runs).
|
||||
</p>
|
||||
{% include "partials/ai-insight-nav.html" with active_tab="graphs" %}
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
<h1 class="title is-4" style="margin-bottom: 0.35rem;">Scoring Help: {{ person.name }}</h1>
|
||||
<p class="is-size-7 has-text-grey">
|
||||
Combined explanation for each metric collection group and what it can
|
||||
imply in relationship dynamics.
|
||||
imply in relationship dynamics. Scoring is deterministic from message
|
||||
history and can be backfilled via metric history reconciliation.
|
||||
</p>
|
||||
{% include "partials/ai-insight-nav.html" with active_tab="help" %}
|
||||
</div>
|
||||
|
||||
@@ -23,102 +23,45 @@
|
||||
<button class="button is-link is-small" type="submit">Save</button>
|
||||
</form>
|
||||
|
||||
<form method="get" class="box">
|
||||
<h2 class="title is-6">Timeline Filters</h2>
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-3">
|
||||
<label class="label is-size-7">Person</label>
|
||||
<div class="select is-small is-fullwidth">
|
||||
<select name="person">
|
||||
<option value="">All</option>
|
||||
{% for person in people %}
|
||||
<option value="{{ person.id }}" {% if filters.person == person.id|stringformat:"s" %}selected{% endif %}>{{ person.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-2">
|
||||
<label class="label is-size-7">Service</label>
|
||||
<div class="select is-small is-fullwidth">
|
||||
<select name="service">
|
||||
<option value="">All</option>
|
||||
{% for item in service_choices %}
|
||||
<option value="{{ item }}" {% if filters.service == item %}selected{% endif %}>{{ item }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-2">
|
||||
<label class="label is-size-7">State</label>
|
||||
<div class="select is-small is-fullwidth">
|
||||
<select name="state">
|
||||
<option value="">All</option>
|
||||
{% for item in state_choices %}
|
||||
<option value="{{ item }}" {% if filters.state == item %}selected{% endif %}>{{ item }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-2">
|
||||
<label class="label is-size-7">Source</label>
|
||||
<div class="select is-small is-fullwidth">
|
||||
<select name="source_kind">
|
||||
<option value="">All</option>
|
||||
{% for item in source_kind_choices %}
|
||||
<option value="{{ item }}" {% if filters.source_kind == item %}selected{% endif %}>{{ item }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-2">
|
||||
<label class="label is-size-7">Start (ISO)</label>
|
||||
<input class="input is-small" type="datetime-local" name="start" value="{{ filters.start }}">
|
||||
</div>
|
||||
<div class="column is-2">
|
||||
<label class="label is-size-7">End (ISO)</label>
|
||||
<input class="input is-small" type="datetime-local" name="end" value="{{ filters.end }}">
|
||||
</div>
|
||||
</div>
|
||||
<button class="button is-light is-small" type="submit">Apply</button>
|
||||
</form>
|
||||
|
||||
<div class="box">
|
||||
<h2 class="title is-6">Availability Events</h2>
|
||||
<h2 class="title is-6">Availability Event Statistics Per Contact</h2>
|
||||
<table class="table is-fullwidth is-striped is-size-7">
|
||||
<thead><tr><th>ts</th><th>person</th><th>service</th><th>source</th><th>state</th><th>confidence</th></tr></thead>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Contact</th>
|
||||
<th>Service</th>
|
||||
<th>Total</th>
|
||||
<th>Available</th>
|
||||
<th>Fading</th>
|
||||
<th>Unavailable</th>
|
||||
<th>Unknown</th>
|
||||
<th>Native</th>
|
||||
<th>Read</th>
|
||||
<th>Typing</th>
|
||||
<th>Msg Activity</th>
|
||||
<th>Timeout</th>
|
||||
<th>Last Event TS</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in events %}
|
||||
{% for row in contact_stats %}
|
||||
<tr>
|
||||
<td>{{ row.ts }}</td>
|
||||
<td>{{ row.person.name }}</td>
|
||||
<td>{{ row.person__name }}</td>
|
||||
<td>{{ row.service }}</td>
|
||||
<td>{{ row.source_kind }}</td>
|
||||
<td>{{ row.availability_state }}</td>
|
||||
<td>{{ row.confidence|floatformat:2 }}</td>
|
||||
<td>{{ row.total_events }}</td>
|
||||
<td>{{ row.available_events }}</td>
|
||||
<td>{{ row.fading_events }}</td>
|
||||
<td>{{ row.unavailable_events }}</td>
|
||||
<td>{{ row.unknown_events }}</td>
|
||||
<td>{{ row.native_presence_events }}</td>
|
||||
<td>{{ row.read_receipt_events }}</td>
|
||||
<td>{{ row.typing_events }}</td>
|
||||
<td>{{ row.message_activity_events }}</td>
|
||||
<td>{{ row.inferred_timeout_events }}</td>
|
||||
<td>{{ row.last_event_ts }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="6">No events in range.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<h2 class="title is-6">Availability Spans</h2>
|
||||
<table class="table is-fullwidth is-striped is-size-7">
|
||||
<thead><tr><th>person</th><th>service</th><th>state</th><th>start</th><th>end</th><th>confidence</th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in spans %}
|
||||
<tr>
|
||||
<td>{{ row.person.name }}</td>
|
||||
<td>{{ row.service }}</td>
|
||||
<td>{{ row.state }}</td>
|
||||
<td>{{ row.start_ts }}</td>
|
||||
<td>{{ row.end_ts }}</td>
|
||||
<td>{{ row.confidence_start|floatformat:2 }} -> {{ row.confidence_end|floatformat:2 }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="6">No spans in range.</td></tr>
|
||||
<tr><td colspan="13">No availability events found.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -448,6 +448,13 @@
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.command-variant-warning strong {
|
||||
color: #3f2a09;
|
||||
}
|
||||
.command-variant-warning code {
|
||||
color: #5b3a0c;
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
.command-destination-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
|
||||
@@ -3,6 +3,47 @@
|
||||
<section class="section"><div class="container">
|
||||
<h1 class="title is-4">Group Tasks: {{ channel_display_name }}</h1>
|
||||
<p class="subtitle is-6">{{ service_label }} · {{ identifier }}</p>
|
||||
<article class="box">
|
||||
<h2 class="title is-6">Create Or Map Project</h2>
|
||||
<form method="post" style="margin-bottom: 0.7rem;">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="group_project_create">
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-5">
|
||||
<label class="label is-size-7">Project Name</label>
|
||||
<input class="input is-small" name="project_name" placeholder="Project name">
|
||||
</div>
|
||||
<div class="column is-5">
|
||||
<label class="label is-size-7">Initial Epic (optional)</label>
|
||||
<input class="input is-small" name="epic_name" placeholder="Epic name">
|
||||
</div>
|
||||
<div class="column is-2" style="display:flex; align-items:flex-end;">
|
||||
<button class="button is-small is-link is-light" type="submit">Create + Map</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="group_map_existing_project">
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-9">
|
||||
<label class="label is-size-7">Existing Project</label>
|
||||
<div class="select is-small is-fullwidth">
|
||||
<select name="project_id">
|
||||
{% for project in projects %}
|
||||
<option value="{{ project.id }}">{{ project.name }}</option>
|
||||
{% empty %}
|
||||
<option value="">No projects available</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-3" style="display:flex; align-items:flex-end;">
|
||||
<button class="button is-small is-light" type="submit">Map Existing</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
{% if not tasks %}
|
||||
<article class="box">
|
||||
<h2 class="title is-6">No Tasks Yet</h2>
|
||||
@@ -19,23 +60,46 @@
|
||||
{% endif %}
|
||||
<article class="box">
|
||||
<h2 class="title is-6">Mappings</h2>
|
||||
<ul>
|
||||
{% for row in mappings %}
|
||||
<li>{{ row.project.name }}{% if row.epic %} / {{ row.epic.name }}{% endif %}</li>
|
||||
{% empty %}
|
||||
<li>No mappings for this group.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<table class="table is-fullwidth is-striped is-size-7">
|
||||
<thead><tr><th>Project</th><th>Epic</th><th>Channel</th><th>Enabled</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in mappings %}
|
||||
<tr>
|
||||
<td>{{ row.project.name }}</td>
|
||||
<td>{% if row.epic %}{{ row.epic.name }}{% else %}-{% endif %}</td>
|
||||
<td>
|
||||
<div><code>{{ row.service }} · {{ row.channel_identifier }}</code></div>
|
||||
{% if channel_display_name %}
|
||||
<p class="is-size-7 has-text-dark" style="margin-top:0.2rem;">{{ channel_display_name }}</p>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ row.enabled }}</td>
|
||||
<td><a class="button is-small is-light" href="{% url 'tasks_project' project_id=row.project_id %}">Open Project</a></td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="5">No mappings for this group.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</article>
|
||||
<article class="box">
|
||||
<h2 class="title is-6">Derived Tasks</h2>
|
||||
<ul>
|
||||
{% for row in tasks %}
|
||||
<li><a href="{% url 'tasks_task' task_id=row.id %}">#{{ row.reference_code }} {{ row.title }}</a></li>
|
||||
{% empty %}
|
||||
<li>No tasks yet.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<table class="table is-fullwidth is-striped is-size-7">
|
||||
<thead><tr><th>Ref</th><th>Title</th><th>Project</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in tasks %}
|
||||
<tr>
|
||||
<td>#{{ row.reference_code }}</td>
|
||||
<td>{{ row.title }}</td>
|
||||
<td>{{ row.project.name }}{% if row.epic %} / {{ row.epic.name }}{% endif %}</td>
|
||||
<td>{{ row.status_snapshot }}</td>
|
||||
<td><a class="button is-small is-light" href="{% url 'tasks_task' task_id=row.id %}">Open</a></td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="5">No tasks yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</article>
|
||||
</div></section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -4,20 +4,130 @@
|
||||
<div class="container">
|
||||
<h1 class="title is-4">Tasks</h1>
|
||||
<p class="subtitle is-6">Immutable tasks derived from chat activity.</p>
|
||||
<div class="buttons">
|
||||
<div class="buttons" style="margin-bottom: 0.75rem;">
|
||||
<a class="button is-small is-link is-light" href="{% url 'tasks_settings' %}">Task Settings</a>
|
||||
</div>
|
||||
<div class="columns">
|
||||
<div class="columns is-variable is-5">
|
||||
<div class="column is-4">
|
||||
<article class="box">
|
||||
<h2 class="title is-6">Projects</h2>
|
||||
<ul>
|
||||
<div class="is-flex is-justify-content-space-between is-align-items-center" style="gap: 0.5rem; margin-bottom: 0.6rem;">
|
||||
<h2 class="title is-6" style="margin: 0;">Projects</h2>
|
||||
<span class="tag task-stat-tag">{{ projects|length }}</span>
|
||||
</div>
|
||||
<p class="help" style="margin-bottom: 0.45rem;">Create the project first, then map linked identifiers below in one click.</p>
|
||||
<form method="post" style="margin-bottom: 0.75rem;">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="project_create">
|
||||
<input type="hidden" name="person" value="{{ scope.person_id }}">
|
||||
<input type="hidden" name="service" value="{{ scope.service }}">
|
||||
<input type="hidden" name="identifier" value="{{ scope.identifier }}">
|
||||
<div class="field has-addons">
|
||||
<div class="control is-expanded">
|
||||
<input class="input is-small" name="name" placeholder="New project name">
|
||||
</div>
|
||||
<div class="control">
|
||||
<button class="button is-small is-link is-light" type="submit">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if scope.person %}
|
||||
<article class="message is-light" style="margin-bottom: 0.75rem;">
|
||||
<div class="message-body is-size-7">
|
||||
Setup scope: <strong>{{ scope.person.name }}</strong>
|
||||
{% if scope.service and scope.identifier %}
|
||||
· {{ scope.service }} · {{ scope.identifier }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
<div style="margin-bottom: 0.75rem;">
|
||||
<label class="label is-size-7">Map Linked Identifiers To Project</label>
|
||||
<form method="get">
|
||||
<input type="hidden" name="person" value="{{ scope.person_id }}">
|
||||
<input type="hidden" name="service" value="{{ scope.service }}">
|
||||
<input type="hidden" name="identifier" value="{{ scope.identifier }}">
|
||||
<div class="field has-addons">
|
||||
<div class="control is-expanded">
|
||||
<div class="select is-small is-fullwidth">
|
||||
<select name="project">
|
||||
<option value="">Select project</option>
|
||||
{% for project in projects %}
|
||||
<option value="{{ project.id }}" {% if selected_project and selected_project.id == project.id %}selected{% endif %}>{{ project.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button class="button is-small is-light" type="submit">Select</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<table class="table is-fullwidth is-striped is-size-7" style="margin-bottom:0.9rem;">
|
||||
<thead><tr><th>Identifier</th><th>Service</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in person_identifiers %}
|
||||
<tr>
|
||||
<td><code>{{ row.identifier }}</code></td>
|
||||
<td>{{ row.service }}</td>
|
||||
<td class="has-text-right">
|
||||
{% if selected_project %}
|
||||
{% if tuple(selected_project.id, row.service, row.identifier) in mapping_pairs %}
|
||||
<span class="tag task-stat-tag">Linked</span>
|
||||
{% else %}
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="project_map_identifier">
|
||||
<input type="hidden" name="project_id" value="{{ selected_project.id }}">
|
||||
<input type="hidden" name="person_identifier_id" value="{{ row.id }}">
|
||||
<input type="hidden" name="person" value="{{ scope.person_id }}">
|
||||
<input type="hidden" name="service" value="{{ scope.service }}">
|
||||
<input type="hidden" name="identifier" value="{{ scope.identifier }}">
|
||||
<button class="button is-small is-light" type="submit">Link</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="has-text-grey">Select project</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="3">No linked identifiers for this person yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="help" style="margin-bottom: 0.75rem;">
|
||||
Open this page from Compose to map a person’s linked identifiers in one click.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<table class="table is-fullwidth is-striped is-size-7">
|
||||
<thead><tr><th>Project</th><th>Stats</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for project in projects %}
|
||||
<li><a href="{% url 'tasks_project' project_id=project.id %}">{{ project.name }}</a> <span class="has-text-grey">({{ project.task_count }})</span></li>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{% url 'tasks_project' project_id=project.id %}">{{ project.name }}</a>
|
||||
</td>
|
||||
<td>
|
||||
<span class="tag task-stat-tag">{{ project.task_count }} task{{ project.task_count|pluralize }}</span>
|
||||
<span class="tag task-stat-tag">{{ project.epic_count }} epic{{ project.epic_count|pluralize }}</span>
|
||||
</td>
|
||||
<td class="has-text-right">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="project_delete">
|
||||
<input type="hidden" name="project_id" value="{{ project.id }}">
|
||||
<button class="button is-small is-danger is-light" type="submit">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<li>No projects yet.</li>
|
||||
<tr><td colspan="3">No projects yet.</td></tr>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</tbody>
|
||||
</table>
|
||||
</article>
|
||||
</div>
|
||||
<div class="column">
|
||||
@@ -44,4 +154,15 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<style>
|
||||
.task-stat-tag {
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #dbdbdb;
|
||||
color: #1f1f1f !important;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
padding: 0.25em 0.75em;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,27 +1,75 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<section class="section"><div class="container">
|
||||
<h1 class="title is-4">Project: {{ project.name }}</h1>
|
||||
<div class="buttons"><a class="button is-small is-light" href="{% url 'tasks_hub' %}">Back</a></div>
|
||||
<article class="box">
|
||||
<h2 class="title is-6">Epics</h2>
|
||||
<ul>
|
||||
{% for epic in epics %}
|
||||
<li><a href="{% url 'tasks_epic' epic_id=epic.id %}">{{ epic.name }}</a></li>
|
||||
{% empty %}
|
||||
<li>No epics.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</article>
|
||||
<article class="box">
|
||||
<h2 class="title is-6">Tasks</h2>
|
||||
<ul>
|
||||
{% for row in tasks %}
|
||||
<li><a href="{% url 'tasks_task' task_id=row.id %}">#{{ row.reference_code }} {{ row.title }}</a></li>
|
||||
{% empty %}
|
||||
<li>No tasks.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</article>
|
||||
</div></section>
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<h1 class="title is-4">Project: {{ project.name }}</h1>
|
||||
<div class="buttons" style="margin-bottom: 0.75rem;">
|
||||
<a class="button is-small is-light" href="{% url 'tasks_hub' %}">Back</a>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="project_delete">
|
||||
<button class="button is-small is-danger is-light" type="submit">Delete Project</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<article class="box">
|
||||
<div class="is-flex is-justify-content-space-between is-align-items-center" style="gap: 0.5rem; margin-bottom: 0.6rem;">
|
||||
<h2 class="title is-6" style="margin: 0;">Epics</h2>
|
||||
<span class="tag is-light">{{ epics|length }}</span>
|
||||
</div>
|
||||
<form method="post" style="margin-bottom: 0.75rem;">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="epic_create">
|
||||
<div class="field has-addons">
|
||||
<div class="control is-expanded">
|
||||
<input class="input is-small" name="name" placeholder="New epic name">
|
||||
</div>
|
||||
<div class="control">
|
||||
<button class="button is-small is-link is-light" type="submit">Add Epic</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<table class="table is-fullwidth is-striped is-size-7">
|
||||
<thead><tr><th>Epic</th><th>Tasks</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for epic in epics %}
|
||||
<tr>
|
||||
<td><a href="{% url 'tasks_epic' epic_id=epic.id %}">{{ epic.name }}</a></td>
|
||||
<td><span class="tag is-light">{{ epic.task_count }}</span></td>
|
||||
<td class="has-text-right">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="epic_delete">
|
||||
<input type="hidden" name="epic_id" value="{{ epic.id }}">
|
||||
<button class="button is-small is-danger is-light" type="submit">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="3">No epics.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</article>
|
||||
|
||||
<article class="box">
|
||||
<h2 class="title is-6">Tasks</h2>
|
||||
<table class="table is-fullwidth is-striped is-size-7">
|
||||
<thead><tr><th>Ref</th><th>Title</th><th>Epic</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in tasks %}
|
||||
<tr>
|
||||
<td>#{{ row.reference_code }}</td>
|
||||
<td>{{ row.title }}</td>
|
||||
<td>{% if row.epic %}{{ row.epic.name }}{% else %}-{% endif %}</td>
|
||||
<td><a class="button is-small is-light" href="{% url 'tasks_task' task_id=row.id %}">Open</a></td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="4">No tasks.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -335,6 +335,7 @@
|
||||
<input type="hidden" name="provider" value="codex_cli">
|
||||
<label class="checkbox"><input type="checkbox" name="enabled" value="1" {% if codex_provider_config and codex_provider_config.enabled %}checked{% endif %}> Enable Codex CLI provider</label>
|
||||
<p class="help">Codex task-sync runs in a dedicated worker (<code>python manage.py codex_worker</code>).</p>
|
||||
<p class="help">This provider syncs task updates to Codex; it does not mirror whole chat threads in this phase.</p>
|
||||
<div class="field" style="margin-top:0.5rem;">
|
||||
<label class="label is-size-7">Command</label>
|
||||
<input class="input is-small" name="command" value="{{ codex_provider_settings.command }}" placeholder="codex">
|
||||
@@ -361,40 +362,69 @@
|
||||
<div class="column is-12">
|
||||
<section class="tasks-panel">
|
||||
<h3 class="title is-7">External Chat Links</h3>
|
||||
<p class="help">Map a contact to an external Codex chat/session ID for task-sync metadata.</p>
|
||||
<p class="help">Map a GIA contact identifier to one Codex conversation/session so task-sync updates are routed to the correct Codex thread.</p>
|
||||
{% if external_link_scoped %}
|
||||
<article class="message is-info is-light tasks-link-scope-note">
|
||||
<div class="message-body">
|
||||
Scoped to <strong>{{ external_link_scope_label }}</strong>. Only matching identifiers are available below.
|
||||
</div>
|
||||
</article>
|
||||
{% endif %}
|
||||
<form method="post" class="block">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="external_chat_link_upsert">
|
||||
<div class="columns tasks-settings-inline-columns">
|
||||
<div class="column is-2">
|
||||
<label class="label is-size-7">Provider</label>
|
||||
<div class="select is-small is-fullwidth">
|
||||
<select name="provider">
|
||||
<option value="codex_cli" selected>codex_cli</option>
|
||||
</select>
|
||||
<input type="hidden" name="prefill_service" value="{{ prefill_service }}">
|
||||
<input type="hidden" name="prefill_identifier" value="{{ prefill_identifier }}">
|
||||
<div class="columns is-multiline is-variable is-2 tasks-external-link-columns">
|
||||
<div class="column is-12-mobile is-4-tablet is-2-desktop">
|
||||
<div class="field">
|
||||
<label class="label is-size-7">Provider</label>
|
||||
<div class="control">
|
||||
<div class="select is-small is-fullwidth">
|
||||
<select name="provider">
|
||||
<option value="codex_cli" selected>codex_cli</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-5">
|
||||
<label class="label is-size-7">Contact Identifier</label>
|
||||
<div class="select is-small is-fullwidth">
|
||||
<select name="person_identifier_id">
|
||||
<option value="">Unlinked</option>
|
||||
{% for row in person_identifiers %}
|
||||
<option value="{{ row.id }}">{{ row.person.name }} · {{ row.service }} · {{ row.identifier }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="column is-12-mobile is-8-tablet is-5-desktop">
|
||||
<div class="field">
|
||||
<label class="label is-size-7">Contact Identifier</label>
|
||||
<div class="control">
|
||||
<div class="select is-small is-fullwidth">
|
||||
<select name="person_identifier_id">
|
||||
<option value="">Unlinked</option>
|
||||
{% for row in external_link_person_identifiers %}
|
||||
<option value="{{ row.id }}">{{ row.person.name }} · {{ row.service }} · {{ row.identifier }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help">Choose which contact/group in GIA this Codex chat mapping belongs to.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<label class="label is-size-7">External Chat ID</label>
|
||||
<input class="input is-small" name="external_chat_id" placeholder="codex-chat-...">
|
||||
<div class="column is-12-mobile is-8-tablet is-3-desktop">
|
||||
<div class="field">
|
||||
<label class="label is-size-7">External Chat ID</label>
|
||||
<div class="control">
|
||||
<input class="input is-small" name="external_chat_id" placeholder="codex-chat-...">
|
||||
</div>
|
||||
<p class="help">Use the Codex conversation/session ID (the stable ID Codex worker should target for task updates).</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-2">
|
||||
<label class="label is-size-7">Enabled</label>
|
||||
<label class="checkbox"><input type="checkbox" name="enabled" value="1" checked> Active</label>
|
||||
<div class="column is-6-mobile is-4-tablet is-2-desktop">
|
||||
<div class="field">
|
||||
<label class="label is-size-7">Enabled</label>
|
||||
<label class="checkbox"><input type="checkbox" name="enabled" value="1" checked> Active</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<button class="button is-small is-link is-light" type="submit">Save Link</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="button is-small is-link is-light" type="submit">Save Link</button>
|
||||
</form>
|
||||
<table class="table is-fullwidth is-striped is-size-7">
|
||||
<thead><tr><th>Provider</th><th>Person</th><th>Identifier</th><th>External Chat</th><th>Enabled</th><th></th></tr></thead>
|
||||
@@ -452,6 +482,12 @@
|
||||
.tasks-settings-page .tasks-settings-list {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.tasks-settings-page .tasks-link-scope-note {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.tasks-settings-page .tasks-external-link-columns .field {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.tasks-settings-page .prefix-chip {
|
||||
margin-right: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
|
||||
@@ -3,7 +3,12 @@ from __future__ import annotations
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from core.models import ContactAvailabilitySettings, User
|
||||
from core.models import (
|
||||
ContactAvailabilityEvent,
|
||||
ContactAvailabilitySettings,
|
||||
Person,
|
||||
User,
|
||||
)
|
||||
|
||||
|
||||
class AvailabilitySettingsPageTests(TestCase):
|
||||
@@ -36,3 +41,36 @@ class AvailabilitySettingsPageTests(TestCase):
|
||||
self.assertTrue(row.inference_enabled)
|
||||
self.assertEqual(120, row.retention_days)
|
||||
self.assertEqual(300, row.fade_threshold_seconds)
|
||||
|
||||
def test_contact_event_stats_are_aggregated(self):
|
||||
person = Person.objects.create(user=self.user, name="Alice")
|
||||
ContactAvailabilityEvent.objects.create(
|
||||
user=self.user,
|
||||
person=person,
|
||||
service="whatsapp",
|
||||
source_kind="message_in",
|
||||
availability_state="available",
|
||||
confidence=0.9,
|
||||
ts=1000,
|
||||
payload={},
|
||||
)
|
||||
ContactAvailabilityEvent.objects.create(
|
||||
user=self.user,
|
||||
person=person,
|
||||
service="whatsapp",
|
||||
source_kind="inferred_timeout",
|
||||
availability_state="fading",
|
||||
confidence=0.5,
|
||||
ts=2000,
|
||||
payload={},
|
||||
)
|
||||
response = self.client.get(reverse("availability_settings"))
|
||||
self.assertEqual(200, response.status_code)
|
||||
stats = list(response.context["contact_stats"])
|
||||
self.assertEqual(1, len(stats))
|
||||
self.assertEqual("Alice", stats[0]["person__name"])
|
||||
self.assertEqual(2, stats[0]["total_events"])
|
||||
self.assertEqual(1, stats[0]["available_events"])
|
||||
self.assertEqual(1, stats[0]["fading_events"])
|
||||
self.assertEqual(1, stats[0]["message_activity_events"])
|
||||
self.assertEqual(1, stats[0]["inferred_timeout_events"])
|
||||
|
||||
131
core/tests/test_reconcile_workspace_metric_history.py
Normal file
131
core/tests/test_reconcile_workspace_metric_history.py
Normal file
@@ -0,0 +1,131 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.core.management import call_command
|
||||
from django.test import TestCase
|
||||
|
||||
from core.models import (
|
||||
ChatSession,
|
||||
Message,
|
||||
Person,
|
||||
PersonIdentifier,
|
||||
User,
|
||||
WorkspaceMetricSnapshot,
|
||||
)
|
||||
from core.views.workspace import _conversation_for_person
|
||||
|
||||
|
||||
class ReconcileWorkspaceMetricHistoryCommandTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(
|
||||
"reconcile-user",
|
||||
"reconcile@example.com",
|
||||
"x",
|
||||
)
|
||||
self.person = Person.objects.create(user=self.user, name="Reconcile Person")
|
||||
self.identifier = PersonIdentifier.objects.create(
|
||||
user=self.user,
|
||||
person=self.person,
|
||||
service="whatsapp",
|
||||
identifier="15551230000@s.whatsapp.net",
|
||||
)
|
||||
self.session = ChatSession.objects.create(user=self.user, identifier=self.identifier)
|
||||
base_ts = 1_700_000_000_000
|
||||
for idx in range(10):
|
||||
inbound = idx % 2 == 0
|
||||
Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
ts=base_ts + (idx * 60_000),
|
||||
text=f"m{idx}",
|
||||
source_service="whatsapp",
|
||||
source_chat_id="120363402761690215@g.us",
|
||||
sender_uuid=f"actor-{idx}@s.whatsapp.net",
|
||||
custom_author="OTHER" if inbound else "USER",
|
||||
)
|
||||
|
||||
def test_reconcile_builds_checkpoints_and_no_reset_is_idempotent(self):
|
||||
call_command(
|
||||
"reconcile_workspace_metric_history",
|
||||
"--person-id",
|
||||
str(self.person.id),
|
||||
"--service",
|
||||
"whatsapp",
|
||||
"--days",
|
||||
"36500",
|
||||
"--step-messages",
|
||||
"2",
|
||||
)
|
||||
conversation = _conversation_for_person(self.user, self.person)
|
||||
first_count = WorkspaceMetricSnapshot.objects.filter(
|
||||
conversation=conversation
|
||||
).count()
|
||||
self.assertGreaterEqual(first_count, 5)
|
||||
|
||||
call_command(
|
||||
"reconcile_workspace_metric_history",
|
||||
"--person-id",
|
||||
str(self.person.id),
|
||||
"--service",
|
||||
"whatsapp",
|
||||
"--days",
|
||||
"36500",
|
||||
"--step-messages",
|
||||
"2",
|
||||
"--no-reset",
|
||||
)
|
||||
second_count = WorkspaceMetricSnapshot.objects.filter(
|
||||
conversation=conversation
|
||||
).count()
|
||||
self.assertEqual(first_count, second_count)
|
||||
latest = conversation.metric_snapshots.first()
|
||||
self.assertEqual(5, int(latest.inbound_messages or 0))
|
||||
self.assertEqual(5, int(latest.outbound_messages or 0))
|
||||
|
||||
def test_no_reset_does_not_duplicate_when_messages_share_timestamp(self):
|
||||
Message.objects.all().delete()
|
||||
base_ts = 1_700_100_000_000
|
||||
for idx in range(8):
|
||||
inbound = idx % 2 == 0
|
||||
Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
ts=base_ts + ((idx // 2) * 60_000),
|
||||
text=f"d{idx}",
|
||||
source_service="whatsapp",
|
||||
source_chat_id="120363402761690215@g.us",
|
||||
sender_uuid=f"dup-{idx}@s.whatsapp.net",
|
||||
custom_author="OTHER" if inbound else "USER",
|
||||
)
|
||||
|
||||
call_command(
|
||||
"reconcile_workspace_metric_history",
|
||||
"--person-id",
|
||||
str(self.person.id),
|
||||
"--service",
|
||||
"whatsapp",
|
||||
"--days",
|
||||
"36500",
|
||||
"--step-messages",
|
||||
"2",
|
||||
)
|
||||
conversation = _conversation_for_person(self.user, self.person)
|
||||
first_count = WorkspaceMetricSnapshot.objects.filter(
|
||||
conversation=conversation
|
||||
).count()
|
||||
|
||||
call_command(
|
||||
"reconcile_workspace_metric_history",
|
||||
"--person-id",
|
||||
str(self.person.id),
|
||||
"--service",
|
||||
"whatsapp",
|
||||
"--days",
|
||||
"36500",
|
||||
"--step-messages",
|
||||
"2",
|
||||
"--no-reset",
|
||||
)
|
||||
second_count = WorkspaceMetricSnapshot.objects.filter(
|
||||
conversation=conversation
|
||||
).count()
|
||||
self.assertEqual(first_count, second_count)
|
||||
113
core/tests/test_tasks_pages_management.py
Normal file
113
core/tests/test_tasks_pages_management.py
Normal file
@@ -0,0 +1,113 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from core.models import ChatTaskSource, TaskEpic, TaskProject, User
|
||||
|
||||
|
||||
class TasksPagesManagementTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user("tasks-pages-user", "tasks-pages@example.com", "x")
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_tasks_hub_requires_group_scope_for_project_create(self):
|
||||
create_response = self.client.post(
|
||||
reverse("tasks_hub"),
|
||||
{
|
||||
"action": "project_create",
|
||||
"name": "Ops",
|
||||
},
|
||||
follow=True,
|
||||
)
|
||||
self.assertEqual(200, create_response.status_code)
|
||||
self.assertFalse(TaskProject.objects.filter(user=self.user, name="Ops").exists())
|
||||
|
||||
def test_tasks_hub_can_create_scoped_project_and_delete(self):
|
||||
create_response = self.client.post(
|
||||
reverse("tasks_hub"),
|
||||
{
|
||||
"action": "project_create",
|
||||
"name": "Ops",
|
||||
"service": "whatsapp",
|
||||
"channel_identifier": "120363402761690215@g.us",
|
||||
},
|
||||
follow=True,
|
||||
)
|
||||
self.assertEqual(200, create_response.status_code)
|
||||
project = TaskProject.objects.get(user=self.user, name="Ops")
|
||||
self.assertIsNotNone(project)
|
||||
self.assertTrue(
|
||||
ChatTaskSource.objects.filter(
|
||||
user=self.user,
|
||||
service="whatsapp",
|
||||
channel_identifier="120363402761690215@g.us",
|
||||
project=project,
|
||||
).exists()
|
||||
)
|
||||
|
||||
delete_response = self.client.post(
|
||||
reverse("tasks_hub"),
|
||||
{
|
||||
"action": "project_delete",
|
||||
"project_id": str(project.id),
|
||||
},
|
||||
follow=True,
|
||||
)
|
||||
self.assertEqual(200, delete_response.status_code)
|
||||
self.assertFalse(TaskProject.objects.filter(user=self.user, name="Ops").exists())
|
||||
|
||||
def test_project_page_can_create_and_delete_epic(self):
|
||||
project = TaskProject.objects.create(user=self.user, name="Roadmap")
|
||||
create_response = self.client.post(
|
||||
reverse("tasks_project", kwargs={"project_id": str(project.id)}),
|
||||
{
|
||||
"action": "epic_create",
|
||||
"name": "Phase 1",
|
||||
},
|
||||
follow=True,
|
||||
)
|
||||
self.assertEqual(200, create_response.status_code)
|
||||
epic = TaskEpic.objects.get(project=project, name="Phase 1")
|
||||
self.assertIsNotNone(epic)
|
||||
|
||||
delete_response = self.client.post(
|
||||
reverse("tasks_project", kwargs={"project_id": str(project.id)}),
|
||||
{
|
||||
"action": "epic_delete",
|
||||
"epic_id": str(epic.id),
|
||||
},
|
||||
follow=True,
|
||||
)
|
||||
self.assertEqual(200, delete_response.status_code)
|
||||
self.assertFalse(TaskEpic.objects.filter(project=project, name="Phase 1").exists())
|
||||
|
||||
def test_group_page_create_and_map_project(self):
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"tasks_group",
|
||||
kwargs={
|
||||
"service": "whatsapp",
|
||||
"identifier": "120363402761690215@g.us",
|
||||
},
|
||||
),
|
||||
{
|
||||
"action": "group_project_create",
|
||||
"project_name": "Group Project",
|
||||
"epic_name": "Sprint A",
|
||||
},
|
||||
follow=True,
|
||||
)
|
||||
self.assertEqual(200, response.status_code)
|
||||
project = TaskProject.objects.get(user=self.user, name="Group Project")
|
||||
epic = TaskEpic.objects.get(project=project, name="Sprint A")
|
||||
self.assertTrue(
|
||||
ChatTaskSource.objects.filter(
|
||||
user=self.user,
|
||||
service="whatsapp",
|
||||
channel_identifier="120363402761690215@g.us",
|
||||
project=project,
|
||||
epic=epic,
|
||||
enabled=True,
|
||||
).exists()
|
||||
)
|
||||
@@ -10,6 +10,7 @@ from core.models import (
|
||||
ChatSession,
|
||||
ChatTaskSource,
|
||||
DerivedTask,
|
||||
ExternalChatLink,
|
||||
Message,
|
||||
Person,
|
||||
PersonIdentifier,
|
||||
@@ -203,3 +204,67 @@ class TaskSettingsViewActionsTests(TestCase):
|
||||
self.assertFalse(
|
||||
ChatTaskSource.objects.filter(id=self.source.id, user=self.user).exists()
|
||||
)
|
||||
|
||||
|
||||
class TaskSettingsExternalChatLinkScopeTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user("task-link-user", "task-link@example.com", "x")
|
||||
self.client.force_login(self.user)
|
||||
self.group_person = Person.objects.create(user=self.user, name="Scoped Group")
|
||||
self.group_identifier = PersonIdentifier.objects.create(
|
||||
user=self.user,
|
||||
person=self.group_person,
|
||||
service="whatsapp",
|
||||
identifier="120363402761690215@g.us",
|
||||
)
|
||||
self.group_identifier_bare = PersonIdentifier.objects.create(
|
||||
user=self.user,
|
||||
person=self.group_person,
|
||||
service="whatsapp",
|
||||
identifier="120363402761690215",
|
||||
)
|
||||
self.other_person = Person.objects.create(user=self.user, name="Other Group")
|
||||
self.other_identifier = PersonIdentifier.objects.create(
|
||||
user=self.user,
|
||||
person=self.other_person,
|
||||
service="whatsapp",
|
||||
identifier="120399999999999999@g.us",
|
||||
)
|
||||
|
||||
def test_scoped_settings_limits_contact_identifier_options(self):
|
||||
response = self.client.get(
|
||||
reverse("tasks_settings"),
|
||||
{
|
||||
"service": "whatsapp",
|
||||
"identifier": "120363402761690215@g.us",
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, response.status_code)
|
||||
options = list(response.context["external_link_person_identifiers"])
|
||||
self.assertTrue(any(row.id == self.group_identifier.id for row in options))
|
||||
self.assertTrue(any(row.id == self.group_identifier_bare.id for row in options))
|
||||
self.assertFalse(any(row.id == self.other_identifier.id for row in options))
|
||||
self.assertTrue(bool(response.context["external_link_scoped"]))
|
||||
|
||||
def test_scoped_upsert_rejects_out_of_scope_identifier(self):
|
||||
response = self.client.post(
|
||||
reverse("tasks_settings"),
|
||||
{
|
||||
"action": "external_chat_link_upsert",
|
||||
"provider": "codex_cli",
|
||||
"person_identifier_id": str(self.other_identifier.id),
|
||||
"external_chat_id": "codex-chat-abc",
|
||||
"enabled": "1",
|
||||
"prefill_service": "whatsapp",
|
||||
"prefill_identifier": "120363402761690215@g.us",
|
||||
},
|
||||
follow=True,
|
||||
)
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertFalse(
|
||||
ExternalChatLink.objects.filter(
|
||||
user=self.user,
|
||||
provider="codex_cli",
|
||||
external_chat_id="codex-chat-abc",
|
||||
).exists()
|
||||
)
|
||||
|
||||
@@ -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