Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion backend/backend/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class CustomTokenObtainPairView(BaseTokenObtainPairView):


from campaigns.views import (
AuditLogViewSet,
CampaignViewSet,
SequenceStepViewSet,
EmailTemplateViewSet,
Expand Down Expand Up @@ -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'),
Expand All @@ -65,4 +67,4 @@ def api_root(_request):
path('api/v1/connected-accounts/', ConnectedAccountsListView.as_view(), name='connected_accounts'),
path('api/v1/unsubscribe/<uuid:lead_id>/<str:token>/', unsubscribe_view, name='unsubscribe'),
path('api/v1/', include(router.urls)),
]
]
14 changes: 14 additions & 0 deletions backend/campaigns/migrations/0010_merge_20260622_0655.py
Original file line number Diff line number Diff line change
@@ -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 = [
]
36 changes: 36 additions & 0 deletions backend/campaigns/migrations/0011_auditlog.py
Original file line number Diff line number Diff line change
@@ -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'],
},
),
]
23 changes: 23 additions & 0 deletions backend/campaigns/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,29 @@ class Campaign(TenantModel):
def __str__(self):
return self.name


class AuditLog(TenantModel):
action = models.CharField(max_length=255)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Add choices constraint to validate action field values.

The action field currently accepts any string up to 255 characters without validation. According to the PR objectives, there are seven specific campaign actions (create, update, delete, enroll, launch, pause, resume), which downstream code records as strings like 'campaign_created', 'campaign_updated', etc. Without a choices constraint or validation, typos in action names won't be caught at write time, leading to inconsistent data that makes filtering and querying audit logs unreliable.

✨ Proposed fix to add choices validation
+    class ActionChoices(models.TextChoices):
+        CAMPAIGN_CREATED = 'campaign_created', 'Campaign Created'
+        CAMPAIGN_UPDATED = 'campaign_updated', 'Campaign Updated'
+        CAMPAIGN_DELETED = 'campaign_deleted', 'Campaign Deleted'
+        CAMPAIGN_ENROLLED = 'campaign_enrolled', 'Campaign Enrolled'
+        CAMPAIGN_LAUNCHED = 'campaign_launched', 'Campaign Launched'
+        CAMPAIGN_PAUSED = 'campaign_paused', 'Campaign Paused'
+        CAMPAIGN_RESUMED = 'campaign_resumed', 'Campaign Resumed'
+
-    action = models.CharField(max_length=255)
+    action = models.CharField(max_length=255, choices=ActionChoices.choices)
🧰 Tools
🪛 ast-grep (0.44.0)

[info] 55-55: use help_text to document model columns
Context: models.CharField(max_length=64, blank=True, default='')
Note: Security best practice.

(model-help-text)


[warning] 55-55: always specify max_length for a Charfield
Context: models.CharField(max_length=64, blank=True, default='')
Note: Security best practice.

(model-charfield-max-length)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/campaigns/models.py` at line 55, The `action` CharField in the
Campaign model lacks validation constraints for the specific campaign action
values it should accept. Define a choices constant or tuple containing the seven
valid campaign action strings (campaign_created, campaign_updated,
campaign_deleted, campaign_enrolled, campaign_launched, campaign_paused,
campaign_resumed), then update the `action` field definition to include the
choices parameter referencing this list. This will ensure that only valid action
values can be stored in the database and provide database-level validation to
prevent typos and inconsistent data.

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'),
Expand Down
35 changes: 34 additions & 1 deletion backend/campaigns/serializers.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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',
]
Comment on lines +241 to +250

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use an immutable tuple for serializer Meta.fields.

Ruff flags this mutable class attribute; switching to a tuple keeps the same DRF behavior and satisfies RUF012.

Proposed fix
-        fields = [
+        fields = (
             'id',
             'action',
             'target_type',
             'target_id',
             'actor',
             'ip_address',
             'metadata',
             'created_at',
-        ]
+        )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fields = [
'id',
'action',
'target_type',
'target_id',
'actor',
'ip_address',
'metadata',
'created_at',
]
fields = (
'id',
'action',
'target_type',
'target_id',
'actor',
'ip_address',
'metadata',
'created_at',
)
🧰 Tools
🪛 Ruff (0.15.17)

[warning] 241-250: Mutable default value for class attribute

(RUF012)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/campaigns/serializers.py` around lines 241 - 250, The fields
attribute in the Meta class is defined as a mutable list, which violates the
RUF012 linting rule. Convert the fields list to an immutable tuple by replacing
the square brackets with parentheses while keeping all the field names intact.
This change maintains the same DRY serializer behavior while satisfying the Ruff
linting requirement for immutable class attributes.

Source: Linters/SAST tools


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
Expand Down
24 changes: 13 additions & 11 deletions backend/campaigns/tasks.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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."
return f"Detected {total_replies} new replies."
70 changes: 70 additions & 0 deletions backend/campaigns/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Loading