286 lines
9.5 KiB
Python
286 lines
9.5 KiB
Python
import ipaddress
|
|
import json
|
|
import logging
|
|
|
|
from django.contrib import messages
|
|
from django.db.models import Count
|
|
from django.http import JsonResponse
|
|
from django.shortcuts import get_object_or_404, redirect, render
|
|
from django.urls import reverse
|
|
from django.views.decorators.csrf import csrf_exempt
|
|
from django.views.decorators.http import require_http_methods
|
|
|
|
from .forms import PilotCallForm
|
|
from .models import PilotCall, WebhookEvent
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
MAX_WEBHOOK_BODY_BYTES = 512 * 1024
|
|
|
|
|
|
def home(request):
|
|
"""Render the branded landing dashboard for the real-time intake pilot."""
|
|
recent_calls = PilotCall.objects.all()[:3]
|
|
total_calls = PilotCall.objects.count()
|
|
urgent_count = sum(1 for call in PilotCall.objects.all() if call.urgent_flags)
|
|
avg_completion = 0
|
|
if total_calls:
|
|
avg_completion = round(sum(call.completion_score for call in PilotCall.objects.all()) / total_calls)
|
|
|
|
context = {
|
|
'project_name': 'ReliefSignal',
|
|
'meta_description': 'Real-time tax relief call intake pilot for live transcription, structured extraction, completion scoring, and CRM readiness.',
|
|
'recent_calls': recent_calls,
|
|
'total_calls': total_calls,
|
|
'urgent_count': urgent_count,
|
|
'avg_completion': avg_completion,
|
|
}
|
|
return render(request, 'core/index.html', context)
|
|
|
|
|
|
def call_list(request):
|
|
calls = PilotCall.objects.all()
|
|
summary = calls.aggregate(total=Count('id'))
|
|
context = {
|
|
'project_name': 'ReliefSignal',
|
|
'meta_description': 'Review tax relief pilot calls, completion scores, and urgency flags.',
|
|
'calls': calls,
|
|
'total_calls': summary['total'] or 0,
|
|
}
|
|
return render(request, 'core/call_list.html', context)
|
|
|
|
|
|
def call_create(request):
|
|
if request.method == 'POST':
|
|
form = PilotCallForm(request.POST)
|
|
if form.is_valid():
|
|
call = form.save()
|
|
messages.success(request, 'Pilot call saved. Review the completion score and missing intake fields below.')
|
|
return redirect('call_detail', pk=call.pk)
|
|
else:
|
|
form = PilotCallForm()
|
|
|
|
context = {
|
|
'project_name': 'ReliefSignal',
|
|
'meta_description': 'Create a tax relief pilot call intake from a live transcript segment.',
|
|
'form': form,
|
|
}
|
|
return render(request, 'core/call_form.html', context)
|
|
|
|
|
|
def call_detail(request, pk):
|
|
call = get_object_or_404(PilotCall, pk=pk)
|
|
intake_fields = [
|
|
('IRS Debt', call.irs_debt),
|
|
('State Debt', call.state_debt),
|
|
('Tax Years', call.tax_years),
|
|
('Filing Status', call.filing_status),
|
|
('Employment', call.employment_type),
|
|
('Occupation', call.occupation),
|
|
('Monthly Gross Income', call.monthly_gross_income),
|
|
('Spouse Income', call.spouse_income),
|
|
('Mortgage', call.mortgage),
|
|
('Rent', call.rent),
|
|
('Utilities', call.utilities),
|
|
('Car Payment', call.car_payment),
|
|
('Insurance', call.insurance),
|
|
('Student Loans', call.student_loans),
|
|
('Credit Cards', call.credit_cards),
|
|
('Business Expenses', call.business_expenses),
|
|
('Best Callback Time', call.best_callback_time),
|
|
]
|
|
context = {
|
|
'project_name': 'ReliefSignal',
|
|
'meta_description': f'Pilot call detail for {call.client_name}: transcript, extracted tax relief fields, urgency flags, and completion score.',
|
|
'call': call,
|
|
'intake_fields': intake_fields,
|
|
}
|
|
return render(request, 'core/call_detail.html', context)
|
|
|
|
|
|
|
|
def audio_capture(request):
|
|
context = {
|
|
'project_name': 'ReliefSignal',
|
|
'meta_description': 'Browser-based computer audio capture prototype for microphone input, shared tab/system output audio, local level monitoring, and local recording tests.',
|
|
}
|
|
return render(request, 'core/audio_capture.html', context)
|
|
|
|
|
|
def webhook_list(request):
|
|
events = WebhookEvent.objects.all()[:25]
|
|
context = {
|
|
'project_name': 'ReliefSignal',
|
|
'meta_description': 'Provider-neutral webhook inbox for testing Ytel-style call events before a dedicated integration is finalized.',
|
|
'events': events,
|
|
'total_events': WebhookEvent.objects.count(),
|
|
'endpoint_url': _public_absolute_uri(request, reverse('webhook_generic')),
|
|
}
|
|
return render(request, 'core/webhook_list.html', context)
|
|
|
|
|
|
@csrf_exempt
|
|
@require_http_methods(['GET', 'POST'])
|
|
def webhook_generic(request):
|
|
if request.method == 'GET':
|
|
return JsonResponse({
|
|
'ok': True,
|
|
'name': 'ReliefSignal generic webhook receiver',
|
|
'usage': 'POST JSON or form payloads to this endpoint. Add ?provider=ytel later when testing Ytel deliveries.',
|
|
'accepted_methods': ['POST'],
|
|
})
|
|
|
|
body = request.body or b''
|
|
if len(body) > MAX_WEBHOOK_BODY_BYTES:
|
|
logger.warning('Rejected oversized webhook body: %s bytes', len(body))
|
|
return JsonResponse({'ok': False, 'error': 'Payload is too large.'}, status=413)
|
|
|
|
content_type = request.META.get('CONTENT_TYPE', '')[:180]
|
|
raw_body = body.decode('utf-8', errors='replace')
|
|
payload, parsed = _parse_webhook_payload(request, raw_body, content_type)
|
|
|
|
provider = _normalise_label(
|
|
request.GET.get('provider')
|
|
or request.headers.get('X-Webhook-Provider')
|
|
or _payload_lookup(payload, 'provider', 'source'),
|
|
fallback='generic',
|
|
max_length=60,
|
|
)
|
|
event_type = _normalise_label(
|
|
request.headers.get('X-Event-Type')
|
|
or request.headers.get('X-Webhook-Event')
|
|
or request.headers.get('X-Hook-Event')
|
|
or request.headers.get('X-Ytel-Event-Type')
|
|
or _payload_lookup(payload, 'event_type', 'eventType', 'event', 'type', 'action', 'status'),
|
|
fallback='',
|
|
max_length=120,
|
|
)
|
|
delivery_id = _normalise_label(
|
|
request.headers.get('X-Delivery-Id')
|
|
or request.headers.get('X-Webhook-Id')
|
|
or request.headers.get('X-Request-Id')
|
|
or request.headers.get('X-Event-Id')
|
|
or request.headers.get('X-Ytel-Delivery-Id')
|
|
or request.headers.get('X-Ytel-Event-Id')
|
|
or request.headers.get('Cf-Ray'),
|
|
fallback='',
|
|
max_length=160,
|
|
)
|
|
external_id = _normalise_label(
|
|
_payload_lookup(payload, 'external_id', 'externalId', 'event_id', 'eventId', 'id', 'call_id', 'callId', 'uuid', 'sid'),
|
|
fallback='',
|
|
max_length=160,
|
|
)
|
|
|
|
event = WebhookEvent.objects.create(
|
|
provider=provider,
|
|
event_type=event_type,
|
|
external_id=external_id,
|
|
delivery_id=delivery_id,
|
|
source_ip=_source_ip(request),
|
|
content_type=content_type,
|
|
headers=_safe_request_headers(request),
|
|
query_params=_query_params(request),
|
|
payload=payload,
|
|
raw_body=raw_body,
|
|
)
|
|
|
|
return JsonResponse({
|
|
'ok': True,
|
|
'accepted': True,
|
|
'parsed': parsed,
|
|
'id': event.pk,
|
|
'provider': event.provider,
|
|
'event_type': event.display_event_type,
|
|
'received_at': event.received_at.isoformat(),
|
|
}, status=202)
|
|
|
|
|
|
def _public_absolute_uri(request, path):
|
|
url = request.build_absolute_uri(path)
|
|
host = request.get_host().split(':')[0]
|
|
if host not in ('127.0.0.1', 'localhost') and url.startswith('http://'):
|
|
return 'https://' + url[len('http://'):]
|
|
return url
|
|
|
|
|
|
def _parse_webhook_payload(request, raw_body, content_type):
|
|
if raw_body and ('application/json' in content_type or raw_body.lstrip().startswith(('{', '['))):
|
|
try:
|
|
return json.loads(raw_body), True
|
|
except json.JSONDecodeError:
|
|
return {'_unparsed_preview': raw_body[:2000]}, False
|
|
|
|
if request.POST:
|
|
return _multi_value_dict(request.POST), True
|
|
|
|
if raw_body:
|
|
return {'_raw_preview': raw_body[:2000]}, False
|
|
|
|
return {}, True
|
|
|
|
|
|
def _safe_request_headers(request):
|
|
sensitive_markers = ('authorization', 'cookie', 'token', 'secret', 'key', 'signature')
|
|
safe_headers = {}
|
|
for name, value in request.headers.items():
|
|
if any(marker in name.lower() for marker in sensitive_markers):
|
|
safe_headers[name] = '[redacted]'
|
|
else:
|
|
safe_headers[name] = str(value)[:500]
|
|
return safe_headers
|
|
|
|
|
|
def _query_params(request):
|
|
return _multi_value_dict(request.GET)
|
|
|
|
|
|
def _multi_value_dict(query_dict):
|
|
return {
|
|
key: values if len(values := query_dict.getlist(key)) > 1 else query_dict.get(key)
|
|
for key in query_dict
|
|
}
|
|
|
|
|
|
def _payload_lookup(payload, *keys):
|
|
if not isinstance(payload, dict):
|
|
return None
|
|
|
|
for key in keys:
|
|
value = payload.get(key)
|
|
if value not in (None, ''):
|
|
return value
|
|
|
|
for nested_key in ('data', 'event', 'call', 'payload'):
|
|
nested = payload.get(nested_key)
|
|
if isinstance(nested, dict):
|
|
value = _payload_lookup(nested, *keys)
|
|
if value not in (None, ''):
|
|
return value
|
|
|
|
return None
|
|
|
|
|
|
def _normalise_label(value, fallback='', max_length=120):
|
|
if value in (None, ''):
|
|
return fallback
|
|
if isinstance(value, (dict, list)):
|
|
value = json.dumps(value, sort_keys=True)
|
|
return str(value).strip()[:max_length] or fallback
|
|
|
|
|
|
def _source_ip(request):
|
|
raw_ip = (
|
|
request.headers.get('Cf-Connecting-Ip')
|
|
or request.headers.get('X-Real-Ip')
|
|
or request.headers.get('X-Forwarded-For')
|
|
or request.META.get('REMOTE_ADDR')
|
|
or ''
|
|
)
|
|
candidate = raw_ip.split(',')[0].strip()
|
|
try:
|
|
ipaddress.ip_address(candidate)
|
|
except ValueError:
|
|
return None
|
|
return candidate
|