diff --git a/backend/backend/urls.py b/backend/backend/urls.py index eafcbb2..26f03ad 100644 --- a/backend/backend/urls.py +++ b/backend/backend/urls.py @@ -14,6 +14,7 @@ class CustomTokenObtainPairView(BaseTokenObtainPairView): from campaigns.views import ( + AuditLogViewSet, CampaignViewSet, SequenceStepViewSet, EmailTemplateViewSet, @@ -43,6 +44,7 @@ def api_root(_request): router.register(r'blocked-domains', BlockedDomainViewSet, basename='blocked-domains') router.register(r'campaigns', CampaignViewSet, basename='campaigns') router.register(r'email-templates', EmailTemplateViewSet, basename='email-templates') +router.register(r'audit-logs', AuditLogViewSet, basename='audit-logs') urlpatterns = [ path('', api_root, name='api_root'), @@ -65,4 +67,4 @@ def api_root(_request): path('api/v1/connected-accounts/', ConnectedAccountsListView.as_view(), name='connected_accounts'), path('api/v1/unsubscribe///', unsubscribe_view, name='unsubscribe'), path('api/v1/', include(router.urls)), -] \ No newline at end of file +] diff --git a/backend/campaigns/migrations/0010_merge_20260622_0655.py b/backend/campaigns/migrations/0010_merge_20260622_0655.py new file mode 100644 index 0000000..517f05b --- /dev/null +++ b/backend/campaigns/migrations/0010_merge_20260622_0655.py @@ -0,0 +1,14 @@ +# Generated by Django 4.2.30 on 2026-06-22 06:55 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('campaigns', '0009_campaign_cached_counters'), + ('campaigns', '0009_campaignlead_bounce_metadata'), + ] + + operations = [ + ] diff --git a/backend/campaigns/migrations/0011_auditlog.py b/backend/campaigns/migrations/0011_auditlog.py new file mode 100644 index 0000000..998d76e --- /dev/null +++ b/backend/campaigns/migrations/0011_auditlog.py @@ -0,0 +1,36 @@ +# Generated by Django 4.2.30 on 2026-06-22 06:55 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenants', '0002_organization_enable_ai_personalization_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('campaigns', '0010_merge_20260622_0655'), + ] + + operations = [ + migrations.CreateModel( + name='AuditLog', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('action', models.CharField(max_length=255)), + ('target_type', models.CharField(blank=True, default='', max_length=64)), + ('target_id', models.CharField(blank=True, default='', max_length=64)), + ('ip_address', models.GenericIPAddressField(blank=True, null=True)), + ('metadata', models.JSONField(blank=True, default=dict)), + ('actor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='audit_logs', to=settings.AUTH_USER_MODEL)), + ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tenants.organization')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/backend/campaigns/models.py b/backend/campaigns/models.py index 161b5df..702b5b7 100644 --- a/backend/campaigns/models.py +++ b/backend/campaigns/models.py @@ -50,6 +50,29 @@ class Campaign(TenantModel): def __str__(self): return self.name + +class AuditLog(TenantModel): + action = models.CharField(max_length=255) + target_type = models.CharField(max_length=64, blank=True, default='') + target_id = models.CharField(max_length=64, blank=True, default='') + actor = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='audit_logs', + ) + ip_address = models.GenericIPAddressField(null=True, blank=True) + metadata = models.JSONField(default=dict, blank=True) + + class Meta: + ordering = ['-created_at'] + + def __str__(self): + actor_label = self.actor.email if self.actor else 'system' + target_label = self.target_type or 'unknown target' + return f"{self.action} by {actor_label} on {target_label}" + class SequenceStep(TenantModel): CHANNEL_CHOICES = ( ('EMAIL', 'Email'), diff --git a/backend/campaigns/serializers.py b/backend/campaigns/serializers.py index 3213947..d053705 100644 --- a/backend/campaigns/serializers.py +++ b/backend/campaigns/serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers from django.db.models import Q -from .models import Campaign, CampaignLead, ConnectedEmailAccount, SequenceStep, EmailTemplate +from .models import AuditLog, Campaign, CampaignLead, ConnectedEmailAccount, SequenceStep, EmailTemplate DELAY_UNIT_TO_MINUTES = { 'minutes': 1, @@ -232,6 +232,39 @@ class Meta: model = EmailTemplate fields = ['id', 'name', 'subject', 'body', 'category', 'usage_count', 'created_at'] + +class AuditLogSerializer(serializers.ModelSerializer): + actor = serializers.SerializerMethodField() + + class Meta: + model = AuditLog + fields = [ + 'id', + 'action', + 'target_type', + 'target_id', + 'actor', + 'ip_address', + 'metadata', + 'created_at', + ] + + def get_actor(self, obj): + if not obj.actor: + return None + full_name = ' '.join( + part for part in [ + getattr(obj.actor, 'first_name', ''), + getattr(obj.actor, 'last_name', ''), + ] + if part + ).strip() + return { + 'id': str(obj.actor_id), + 'email': obj.actor.email, + 'name': full_name or obj.actor.email, + } + class CampaignLeadSerializer(serializers.ModelSerializer): class Meta: model = CampaignLead diff --git a/backend/campaigns/tasks.py b/backend/campaigns/tasks.py index de16c24..158e286 100644 --- a/backend/campaigns/tasks.py +++ b/backend/campaigns/tasks.py @@ -1,13 +1,15 @@ -import logging from datetime -import timedelta from celery -import shared_task from django.conf -import settings as django_settings from django.utils -import timezone from .ai -import _apply_merge_tags, personalize_email from .gmail_service -import build_unsubscribe_url, check_for_replies, send_gmail from .sms_service -import send_sms, initiate_call from .models -import CampaignLead, SequenceStep from leads.models -import BlockedDomain, normalize_domain +import logging +from datetime import timedelta + +from celery import shared_task +from django.conf import settings as django_settings +from django.utils import timezone + +from .ai import _apply_merge_tags, personalize_email +from .gmail_service import build_unsubscribe_url, check_for_replies, send_gmail +from .sms_service import initiate_call, send_sms +from leads.models import BlockedDomain, normalize_domain +from .models import CampaignLead, SequenceStep import urllib.parse @@ -692,4 +694,4 @@ def poll_gmail_for_replies(): logger.info(f"Reply detected for {clead.lead.email} in campaign {clead.campaign.name}") _maybe_mark_campaign_completed(clead.campaign) - return f"Detected {total_replies} new replies." \ No newline at end of file + return f"Detected {total_replies} new replies." diff --git a/backend/campaigns/tests.py b/backend/campaigns/tests.py index 1d96075..3d69bdc 100644 --- a/backend/campaigns/tests.py +++ b/backend/campaigns/tests.py @@ -18,6 +18,7 @@ process_active_leads_once, send_email_step, ) +from campaigns.models import AuditLog from campaigns.utils import generate_unsubscribe_token from leads.models import BlockedDomain, Lead from tenants.models import Organization @@ -1516,3 +1517,72 @@ def test_callback_rejects_tampered_state_and_accepts_signed_state(self): self.assertEqual(response.status_code, status.HTTP_302_FOUND) self.assertIn('google_auth=error', response['Location']) self.assertIn('reason=no_user', response['Location']) + + +class CampaignAuditLogTests(APITestCase): + def setUp(self): + self.organization = Organization.objects.create(name='Audit Org') + self.user = User.objects.create_user( + email='manager@audit.test', + password='StrongPass123!', + organization=self.organization, + role='ADMIN', + ) + self.client.force_authenticate(self.user) + + def test_campaign_creation_writes_and_lists_audit_log(self): + response = self.client.post( + '/api/v1/campaigns/', + { + 'name': 'Audit Trail', + 'status': 'DRAFT', + 'settings': {}, + }, + format='json', + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + log = AuditLog.objects.get( + organization=self.organization, + action='campaign_created', + ) + self.assertEqual(log.target_type, 'Campaign') + self.assertEqual(log.metadata.get('name'), 'Audit Trail') + + response = self.client.get('/api/v1/audit-logs/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(response.data) + self.assertEqual(response.data[0]['action'], 'campaign_created') + + +class CampaignAnalyticsBenchmarkTests(APITestCase): + def setUp(self): + self.organization = Organization.objects.create(name='Analytics Org') + self.user = User.objects.create_user( + email='analytics@audit.test', + password='StrongPass123!', + organization=self.organization, + role='ADMIN', + ) + self.client.force_authenticate(self.user) + + def test_dashboard_includes_benchmark_comparison_data(self): + response = self.client.get('/api/v1/analytics/dashboard/?days=30') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('benchmark_comparison', response.data) + self.assertIn('benchmark_recommendations', response.data) + self.assertEqual(len(response.data['benchmark_comparison']), 4) + self.assertEqual(len(response.data['benchmark_recommendations']), 4) + open_rate = next( + item for item in response.data['benchmark_comparison'] + if item['metric'] == 'open_rate' + ) + self.assertEqual(open_rate['benchmark'], 20.0) + self.assertEqual(open_rate['is_favorable'], open_rate['delta'] >= 0) + bounce_rate = next( + item for item in response.data['benchmark_comparison'] + if item['metric'] == 'bounce_rate' + ) + self.assertEqual(bounce_rate['is_favorable'], bounce_rate['delta'] <= 0) diff --git a/backend/campaigns/views.py b/backend/campaigns/views.py index 2a11bf0..b5f19b8 100644 --- a/backend/campaigns/views.py +++ b/backend/campaigns/views.py @@ -14,11 +14,43 @@ from leads.models import Lead from users.permissions import IsOrgManager -from .models import Campaign, CampaignLead, SequenceStep, EmailTemplate -from .serializers import CampaignSerializer, SequenceStepSerializer, EmailTemplateSerializer +from .models import AuditLog, Campaign, CampaignLead, SequenceStep, EmailTemplate +from .serializers import AuditLogSerializer, CampaignSerializer, SequenceStepSerializer, EmailTemplateSerializer logger = logging.getLogger(__name__) + +def record_audit_log(request, action, *, target=None, target_type='', target_id='', metadata=None): + user = getattr(request, 'user', None) + organization = getattr(user, 'organization', None) + if not getattr(user, 'is_authenticated', False) or not organization: + return + + if target is not None: + target_type = target_type or target.__class__.__name__ + target_id = target_id or str(getattr(target, 'id', '') or '') + + AuditLog.objects.create( + organization=organization, + actor=user, + action=action, + target_type=target_type, + target_id=target_id, + ip_address=request.META.get('REMOTE_ADDR') or None, + metadata=metadata or {}, + ) + + +class AuditLogViewSet(viewsets.ReadOnlyModelViewSet): + serializer_class = AuditLogSerializer + permission_classes = [IsAuthenticated, IsOrgManager] + queryset = AuditLog.objects.all() + + def get_queryset(self): + return AuditLog.objects.filter( + organization=self.request.user.organization, + ).select_related('actor').order_by('-created_at') + class CampaignViewSet(viewsets.ModelViewSet): serializer_class = CampaignSerializer queryset = Campaign.objects.all() @@ -47,7 +79,34 @@ def get_queryset(self): ) def perform_create(self, serializer): - serializer.save(organization=self.request.user.organization) + campaign = serializer.save(organization=self.request.user.organization) + record_audit_log( + self.request, + 'campaign_created', + target=campaign, + metadata={'name': campaign.name, 'status': campaign.status}, + ) + + def perform_update(self, serializer): + campaign = serializer.save() + record_audit_log( + self.request, + 'campaign_updated', + target=campaign, + metadata={'name': campaign.name, 'status': campaign.status}, + ) + + def perform_destroy(self, instance): + campaign_id = str(instance.id) + campaign_name = instance.name + instance.delete() + record_audit_log( + self.request, + 'campaign_deleted', + target_type='Campaign', + target_id=campaign_id, + metadata={'name': campaign_name}, + ) @action(detail=True, methods=['post']) def enroll(self, request, pk=None): @@ -69,6 +128,13 @@ def enroll(self, request, pk=None): # Refresh campaign to get updated cached counters from signals campaign.refresh_from_db() + + record_audit_log( + request, + 'campaign_enrolled_leads', + target=campaign, + metadata={'enrolled_count': enrolled_count, 'lead_ids': [str(lead_id) for lead_id in lead_ids]}, + ) return Response( { @@ -159,6 +225,13 @@ def launch(self, request, pk=None): immediate_processed = process_active_leads_once() process_active_leads.delay() + record_audit_log( + request, + 'campaign_launched', + target=campaign, + metadata={'enrolled_leads': enrolled_count, 'immediate_processed': immediate_processed}, + ) + return Response( { "message": "Campaign launched. Processing queue triggered.", @@ -186,6 +259,13 @@ def pause(self, request, pk=None): campaign.status = 'PAUSED' campaign.save(update_fields=['status']) + record_audit_log( + request, + 'campaign_paused', + target=campaign, + metadata={'status': campaign.status}, + ) + return Response( { "message": "Campaign paused.", @@ -215,6 +295,13 @@ def resume(self, request, pk=None): campaign.save(update_fields=['status']) process_active_leads.delay() + record_audit_log( + request, + 'campaign_resumed', + target=campaign, + metadata={'status': campaign.status}, + ) + return Response( { "message": "Campaign resumed. Processing queue triggered.", @@ -584,6 +671,65 @@ def get(self, request, *args, **kwargs): opened_series.append(opened_by_day.get(d, 0)) replied_series.append(replied_by_day.get(d, 0)) + benchmark_targets = [ + { + 'metric': 'open_rate', + 'label': 'Open rate', + 'benchmark': 20.0, + 'current': open_rate, + 'recommendation': 'Try improving subject lines and sender trust.', + }, + { + 'metric': 'reply_rate', + 'label': 'Reply rate', + 'benchmark': 5.0, + 'current': reply_rate, + 'recommendation': 'Tighten the call to action and lead follow-up timing.', + }, + { + 'metric': 'click_rate', + 'label': 'Click rate', + 'benchmark': 3.0, + 'current': click_rate, + 'recommendation': 'Make the CTA clearer and keep the content shorter.', + }, + { + 'metric': 'bounce_rate', + 'label': 'Bounce rate', + 'benchmark': 2.0, + 'current': bounce_rate, + 'recommendation': 'Clean the list and verify sender/domain configuration.', + }, + ] + + benchmark_comparison = [] + benchmark_recommendations = [] + for item in benchmark_targets: + delta = round(item['current'] - item['benchmark'], 1) + status_label = 'above' if delta >= 0 else 'below' + is_favorable = delta >= 0 if item['metric'] != 'bounce_rate' else delta <= 0 + benchmark_comparison.append({ + 'metric': item['metric'], + 'label': item['label'], + 'current': item['current'], + 'benchmark': item['benchmark'], + 'delta': delta, + 'status': status_label, + 'is_favorable': is_favorable, + }) + benchmark_recommendations.append({ + 'metric': item['metric'], + 'label': item['label'], + 'message': ( + f"{item['label']} is {abs(delta):.1f}% {status_label} the benchmark. " + f"{item['recommendation']}" + ), + 'status': status_label, + 'is_favorable': is_favorable, + 'current': item['current'], + 'benchmark': item['benchmark'], + }) + # ── Per-campaign breakdown: use cached counters directly ── campaign_stats = [] for c in Campaign.objects.filter(organization=org).order_by('-created_at')[:20]: @@ -627,6 +773,8 @@ def get(self, request, *args, **kwargs): 'reply_rate': reply_rate, 'click_rate': click_rate, 'bounce_rate': bounce_rate, + 'benchmark_comparison': benchmark_comparison, + 'benchmark_recommendations': benchmark_recommendations, 'time_series': { 'labels': labels, 'sent': sent_series, @@ -820,4 +968,4 @@ def get(self, request, *args, **kwargs): # Original Destination par redirect karna decoded_dest = urllib.parse.unquote(dest_url) return HttpResponseRedirect(decoded_dest) -# ------------------------------------------ \ No newline at end of file +# ------------------------------------------ diff --git a/frontend/analytics.html b/frontend/analytics.html index 371b478..470f244 100644 --- a/frontend/analytics.html +++ b/frontend/analytics.html @@ -32,6 +32,24 @@ .campaign-inspector .form-select { min-width: 220px; } + + .benchmark-item { + border: 1px solid #e2e8f0; + border-radius: 12px; + background: #f8fafc; + padding: 12px 14px; + } + + .benchmark-item .benchmark-label { + font-size: 0.8rem; + font-weight: 700; + color: #0f172a; + } + + .benchmark-item .benchmark-meta { + font-size: 0.75rem; + color: #64748b; + } @@ -210,6 +228,21 @@
+ +
+
+
+
Benchmark Comparison
+ +
+
+
+
+
Recommendations
+
+
+
+
@@ -229,6 +262,7 @@
let campaignChart; let channelChart; let campaignStepChart; + let benchmarkChart; let campaigns = []; // ── Load analytics data from backend ── @@ -237,6 +271,7 @@
total_leads: 0, active_campaigns: 0, emails_sent: 0, opened: 0, replied: 0, clicked: 0, bounced: 0, open_rate: 0, reply_rate: 0, click_rate: 0, bounce_rate: 0, + benchmark_comparison: [], benchmark_recommendations: [], time_series: { labels: [], sent: [], opened: [], replied: [] }, campaign_stats: [], recent_activity: [], }; @@ -353,6 +388,70 @@
}); }; + const renderBenchmarkSection = () => { + const comparison = data.benchmark_comparison || []; + const recommendations = data.benchmark_recommendations || []; + const chartLabels = comparison.length ? comparison.map(item => item.label) : ['Open rate', 'Reply rate', 'Click rate', 'Bounce rate']; + const currentVals = comparison.length ? comparison.map(item => item.current || 0) : [0, 0, 0, 0]; + const benchmarkVals = comparison.length ? comparison.map(item => item.benchmark || 0) : [20, 5, 3, 2]; + + if (benchmarkChart) benchmarkChart.destroy(); + benchmarkChart = new Chart(document.getElementById('benchmarkChart'), { + type: 'bar', + data: { + labels: chartLabels, + datasets: [ + { + label: 'Current', + data: currentVals, + backgroundColor: '#0d6efd', + borderRadius: 6, + }, + { + label: 'Benchmark', + data: benchmarkVals, + backgroundColor: '#94a3b8', + borderRadius: 6, + }, + ], + }, + options: { + responsive: true, + indexAxis: 'y', + plugins: { legend: { position: 'bottom' } }, + scales: { x: { beginAtZero: true } }, + }, + }); + + const recContainer = document.getElementById('benchmarkRecommendations'); + recContainer.replaceChildren(); + const items = recommendations.length ? recommendations : [{ + label: 'No recommendations yet', + message: 'Launch a campaign to compare its performance against benchmark targets.', + current: null, + benchmark: null, + is_favorable: null, + }]; + items.forEach(item => { + const wrapper = document.createElement('div'); + wrapper.className = 'benchmark-item'; + wrapper.dataset.favorable = item.is_favorable === null ? 'unknown' : String(Boolean(item.is_favorable)); + + const labelEl = document.createElement('div'); + labelEl.className = 'benchmark-label'; + labelEl.textContent = item.label; + + const metaEl = document.createElement('div'); + metaEl.className = 'benchmark-meta mt-1'; + metaEl.textContent = item.current !== null && item.current !== undefined + ? `Current: ${item.current}% | Benchmark: ${item.benchmark}% | ${item.message}` + : item.message; + + wrapper.append(labelEl, metaEl); + recContainer.appendChild(wrapper); + }); + }; + const renderChannelChart = (campaignList) => { const channelCounts = { EMAIL: 0, @@ -499,6 +598,7 @@
renderCampaignPerformanceChart(); renderChannelChart(campaigns); renderCampaignSelector(campaigns); + renderBenchmarkSection(); timeRangeSelect.addEventListener('change', async () => { const days = Number(timeRangeSelect.value || 30); @@ -506,6 +606,7 @@
updateKPIs(); renderActivityChart(); renderCampaignPerformanceChart(); + renderBenchmarkSection(); }); analyzeBtn.addEventListener('click', async () => {