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