feat: add campaign audit log and benchmarks#431
Conversation
📝 WalkthroughWalkthroughAdds an ChangesCampaign Audit Log Feature
Analytics Dashboard Benchmark Comparison
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~28 minutes Possibly related issues
Possibly related PRs
Suggested labels
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 6
🧹 Nitpick comments (1)
backend/campaigns/models.py (1)
56-57: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick winConsider adding database indexes for frequently queried fields.
Audit logs will likely be filtered by
target_type,target_id, andaction. Whilecreated_atis used for ordering, Django'sMeta.orderingdoes not automatically create a database index. Adding explicit indexes on these fields will improve query performance, especially as the audit log table grows.⚡ Proposed fix to add indexes
class Meta: ordering = ['-created_at'] + indexes = [ + models.Index(fields=['-created_at']), + models.Index(fields=['action']), + models.Index(fields=['target_type', 'target_id']), + ]Also applies to: 68-69
🤖 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` around lines 56 - 57, The audit log model has fields like target_type, target_id, action, and created_at that are frequently used for filtering and ordering, but they lack explicit database indexes which can impact query performance as the table grows. Add db_index=True parameter to the target_type and target_id CharField definitions, and also add db_index=True to the action field and created_at DateTimeField (if present) to ensure the database creates indexes on these columns for faster lookups and sorting operations.
🤖 Prompt for all review comments with 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.
Inline comments:
In `@backend/campaigns/models.py`:
- 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.
In `@backend/campaigns/serializers.py`:
- Around line 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.
In `@backend/campaigns/views.py`:
- Around line 228-233: The audit log recording for the campaign status
transition is happening after the queue and processing calls, which means if
process_active_leads_once() or the delay() call fails, the status change will be
persisted without an audit entry. Move the record_audit_log call that logs the
campaign_launched event to immediately after the
campaign.save(update_fields=['status']) call, before any processing or queue
operations occur. The enrolled_leads and immediate_processed metrics can be
retained in a separate response or follow-up audit event if needed. Apply this
same fix to both the launch and resume methods (referenced around lines 298-303
as well).
- Line 46: The permission_classes attribute in the view class is defined as a
mutable list using square brackets, which violates the RUF012 linting rule for
mutable class attributes. Convert the list to an immutable tuple by replacing
the square brackets with parentheses in the permission_classes assignment to
preserve DRF behavior while satisfying the linting requirement.
- Around line 23-41: In the record_audit_log function, the ip_address field is
currently set using only request.META.get('REMOTE_ADDR'), which returns the
proxy address instead of the actual client IP when deployed behind a reverse
proxy. Modify the ip_address assignment to first check for the forwarded client
IP header (such as HTTP_X_FORWARDED_FOR), extract the client IP from it if
available, and only fall back to REMOTE_ADDR if the forwarded header is not
present. This ensures the actual client IP is captured in audit logs for
deployments behind reverse proxies.
- Around line 132-137: The current audit log is recording all enrolled leads
unconditionally, including those already enrolled previously. When
get_or_create(...) is called upstream, it returns a tuple where the second
element indicates whether the record was newly created. Only increment
enrolled_count and include lead_ids in the audit metadata for newly created
enrollments by checking the created flag from get_or_create(...), so that
re-submitted leads that already exist are not counted or included in the
persisted audit metadata.
---
Nitpick comments:
In `@backend/campaigns/models.py`:
- Around line 56-57: The audit log model has fields like target_type, target_id,
action, and created_at that are frequently used for filtering and ordering, but
they lack explicit database indexes which can impact query performance as the
table grows. Add db_index=True parameter to the target_type and target_id
CharField definitions, and also add db_index=True to the action field and
created_at DateTimeField (if present) to ensure the database creates indexes on
these columns for faster lookups and sorting operations.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: c4bfda6e-0764-4cc3-89ce-efa7d3b88b40
📒 Files selected for processing (8)
backend/backend/urls.pybackend/campaigns/migrations/0010_merge_20260622_0655.pybackend/campaigns/migrations/0011_auditlog.pybackend/campaigns/models.pybackend/campaigns/serializers.pybackend/campaigns/tasks.pybackend/campaigns/tests.pybackend/campaigns/views.py
|
|
||
|
|
||
| class AuditLog(TenantModel): | ||
| action = models.CharField(max_length=255) |
There was a problem hiding this comment.
🛠️ 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.
| fields = [ | ||
| 'id', | ||
| 'action', | ||
| 'target_type', | ||
| 'target_id', | ||
| 'actor', | ||
| 'ip_address', | ||
| 'metadata', | ||
| 'created_at', | ||
| ] |
There was a problem hiding this comment.
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.
| 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 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 {}, | ||
| ) |
There was a problem hiding this comment.
Capture the forwarded client IP before falling back to REMOTE_ADDR.
Line 39 currently stores only REMOTE_ADDR, so deployments behind a reverse proxy will audit the proxy address instead of the client address. Use the forwarded header only if your edge proxy sanitizes it.
Proposed fix
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 '')
+ forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
+ ip_address = (
+ forwarded_for.split(',', 1)[0].strip()
+ if forwarded_for
+ else request.META.get('REMOTE_ADDR')
+ ) or None
+
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,
+ ip_address=ip_address,
metadata=metadata or {},
)📝 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.
| 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 {}, | |
| ) | |
| 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 '') | |
| forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') | |
| ip_address = ( | |
| forwarded_for.split(',', 1)[0].strip() | |
| if forwarded_for | |
| else request.META.get('REMOTE_ADDR') | |
| ) or None | |
| AuditLog.objects.create( | |
| organization=organization, | |
| actor=user, | |
| action=action, | |
| target_type=target_type, | |
| target_id=target_id, | |
| ip_address=ip_address, | |
| metadata=metadata or {}, | |
| ) |
🤖 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/views.py` around lines 23 - 41, In the record_audit_log
function, the ip_address field is currently set using only
request.META.get('REMOTE_ADDR'), which returns the proxy address instead of the
actual client IP when deployed behind a reverse proxy. Modify the ip_address
assignment to first check for the forwarded client IP header (such as
HTTP_X_FORWARDED_FOR), extract the client IP from it if available, and only fall
back to REMOTE_ADDR if the forwarded header is not present. This ensures the
actual client IP is captured in audit logs for deployments behind reverse
proxies.
|
|
||
| class AuditLogViewSet(viewsets.ReadOnlyModelViewSet): | ||
| serializer_class = AuditLogSerializer | ||
| permission_classes = [IsAuthenticated, IsOrgManager] |
There was a problem hiding this comment.
Use an immutable tuple for permission_classes.
Ruff flags the mutable class attribute here; a tuple preserves DRF behavior and satisfies RUF012.
Proposed fix
- permission_classes = [IsAuthenticated, IsOrgManager]
+ permission_classes = (IsAuthenticated, IsOrgManager)📝 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.
| permission_classes = [IsAuthenticated, IsOrgManager] | |
| permission_classes = (IsAuthenticated, IsOrgManager) |
🧰 Tools
🪛 Ruff (0.15.17)
[warning] 46-46: 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/views.py` at line 46, The permission_classes attribute in
the view class is defined as a mutable list using square brackets, which
violates the RUF012 linting rule for mutable class attributes. Convert the list
to an immutable tuple by replacing the square brackets with parentheses in the
permission_classes assignment to preserve DRF behavior while satisfying the
linting requirement.
Source: Linters/SAST tools
| 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]}, | ||
| ) |
There was a problem hiding this comment.
Only audit newly enrolled leads in enrolled_count.
The upstream get_or_create(...) result is counted unconditionally, so re-submitting an already-enrolled lead inflates the persisted audit metadata and response count.
Proposed fix
- CampaignLead.objects.get_or_create(
+ _, created = CampaignLead.objects.get_or_create(
campaign=campaign,
lead=lead,
defaults={'organization': request.user.organization},
)
- enrolled_count += 1
+ if created:
+ enrolled_count += 1🤖 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/views.py` around lines 132 - 137, The current audit log is
recording all enrolled leads unconditionally, including those already enrolled
previously. When get_or_create(...) is called upstream, it returns a tuple where
the second element indicates whether the record was newly created. Only
increment enrolled_count and include lead_ids in the audit metadata for newly
created enrollments by checking the created flag from get_or_create(...), so
that re-submitted leads that already exist are not counted or included in the
persisted audit metadata.
| record_audit_log( | ||
| request, | ||
| 'campaign_launched', | ||
| target=campaign, | ||
| metadata={'enrolled_leads': enrolled_count, 'immediate_processed': immediate_processed}, | ||
| ) |
There was a problem hiding this comment.
Write the audit event before queue/processing calls can fail.
launch and resume persist ACTIVE before calling processing/queue code, but the audit write happens afterward. If process_active_leads_once() or .delay() raises, the status change remains without an audit entry.
Suggested direction
campaign.status = 'ACTIVE'
campaign.save(update_fields=['status'])
+ record_audit_log(
+ request,
+ 'campaign_resumed',
+ target=campaign,
+ metadata={'status': campaign.status},
+ )
process_active_leads.delay()
-
- record_audit_log(
- request,
- 'campaign_resumed',
- target=campaign,
- metadata={'status': campaign.status},
- )For launch, record the status transition immediately after campaign.save(update_fields=['status']), then keep processing metrics in the response or a separate follow-up audit event.
Also applies to: 298-303
🤖 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/views.py` around lines 228 - 233, The audit log recording
for the campaign status transition is happening after the queue and processing
calls, which means if process_active_leads_once() or the delay() call fails, the
status change will be persisted without an audit entry. Move the
record_audit_log call that logs the campaign_launched event to immediately after
the campaign.save(update_fields=['status']) call, before any processing or queue
operations occur. The enrolled_leads and immediate_processed metrics can be
retained in a separate response or follow-up audit event if needed. Apply this
same fix to both the launch and resume methods (referenced around lines 298-303
as well).
There was a problem hiding this comment.
🧹 Nitpick comments (2)
backend/campaigns/views.py (1)
674-729: 🧹 Nitpick | 🔵 Trivial | 💤 Low valueConsider adding a semantic "good/bad" indicator for each metric.
The
statusfield indicates whether the current value is "above" or "below" the benchmark, but forbounce_rate, being above the benchmark is undesirable (unlike the other metrics). The frontend would need special-case handling to display the correct sentiment.Adding a field like
'is_favorable': delta >= 0 if item['metric'] != 'bounce_rate' else delta <= 0would make the API self-describing and simplify frontend logic.🤖 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/views.py` around lines 674 - 729, The status field in benchmark_comparison and benchmark_recommendations dictionaries only indicates whether the current value is above or below the benchmark, but doesn't convey whether that direction is actually favorable for each metric. Add an is_favorable field to both dictionaries inside the for loop that evaluates to True when delta >= 0 for metrics like open_rate, reply_rate, and click_rate, but evaluates to True when delta <= 0 for bounce_rate (since lower bounce rates are desirable). This makes the API self-describing and eliminates the need for frontend special-case handling to determine whether each metric's performance is good or bad.frontend/analytics.html (1)
426-439: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick winPrefer DOM methods over innerHTML with template literals.
While the current data originates from server-side hardcoded values (per the backend context), using
innerHTMLwith template literals is a pattern that can become vulnerable if data sources change. Safer alternatives includetextContentfor text-only content or explicit DOM element creation.♻️ Proposed safer implementation using DOM methods
const recContainer = document.getElementById('benchmarkRecommendations'); - recContainer.innerHTML = recommendations.length ? recommendations.map(item => ` - <div class="benchmark-item"> - <div class="benchmark-label">${item.label}</div> - <div class="benchmark-meta mt-1"> - Current: ${item.current}% | Benchmark: ${item.benchmark}% | ${item.message} - </div> - </div> - `).join('') : ` - <div class="benchmark-item"> - <div class="benchmark-label">No recommendations yet</div> - <div class="benchmark-meta mt-1">Launch a campaign to compare its performance against benchmark targets.</div> - </div> - `; + 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, + }]; + items.forEach(item => { + const div = document.createElement('div'); + div.className = 'benchmark-item'; + 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 + ? `Current: ${item.current}% | Benchmark: ${item.benchmark}% | ${item.message}` + : item.message; + div.append(labelEl, metaEl); + recContainer.appendChild(div); + });🤖 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 `@frontend/analytics.html` around lines 426 - 439, Replace the innerHTML assignment with explicit DOM method calls in the benchmarkRecommendations container initialization. Instead of using template literals with innerHTML, create the benchmark item elements programmatically using document.createElement for divs and other elements, set textContent for the label and meta information to avoid template literal interpolation, and append each created element to the recContainer. Handle both the case when recommendations exist (building items from the recommendations array) and when they don't (creating the "No recommendations yet" message element).Source: Linters/SAST tools
🤖 Prompt for all review comments with 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.
Nitpick comments:
In `@backend/campaigns/views.py`:
- Around line 674-729: The status field in benchmark_comparison and
benchmark_recommendations dictionaries only indicates whether the current value
is above or below the benchmark, but doesn't convey whether that direction is
actually favorable for each metric. Add an is_favorable field to both
dictionaries inside the for loop that evaluates to True when delta >= 0 for
metrics like open_rate, reply_rate, and click_rate, but evaluates to True when
delta <= 0 for bounce_rate (since lower bounce rates are desirable). This makes
the API self-describing and eliminates the need for frontend special-case
handling to determine whether each metric's performance is good or bad.
In `@frontend/analytics.html`:
- Around line 426-439: Replace the innerHTML assignment with explicit DOM method
calls in the benchmarkRecommendations container initialization. Instead of using
template literals with innerHTML, create the benchmark item elements
programmatically using document.createElement for divs and other elements, set
textContent for the label and meta information to avoid template literal
interpolation, and append each created element to the recContainer. Handle both
the case when recommendations exist (building items from the recommendations
array) and when they don't (creating the "No recommendations yet" message
element).
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 334c65cb-35b6-478d-8c09-9624b6756611
📒 Files selected for processing (3)
backend/campaigns/tests.pybackend/campaigns/views.pyfrontend/analytics.html
What changed
AuditLogmodel for campaign activity tracking./api/v1/audit-logs/endpoint for org admins.campaigns/tasks.pyimport header so the campaigns test suite can import cleanly.Why
Validation
git diff --checkpython3 -m py_compile campaigns/models.py campaigns/serializers.py campaigns/views.py campaigns/tasks.pypython3 manage.py test campaigns.tests.CampaignAuditLogTests campaigns.tests.CampaignAnalyticsBenchmarkTests campaigns.tests.CampaignWorkflowTests.test_create_campaign_syncs_sequence_steps_from_builder_payload -v 2Closes #404
Summary by CodeRabbit