fix(campaigns): defer Jira ticket creation to start_date, gate recurring campaigns behind manager approval
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
- Approve endpoint now only creates Jira tickets immediately when start_date is now/past; a new periodic job (every 15 min) catches campaigns whose scheduled start_date has since arrived. - Recurring campaign clones now go to pending_approval instead of active, routing through the same manager-approval gate as any other campaign; managers are notified instead of red_tech. - Fix UTC conversion for the campaign approval start_date input and extract shared isoToDatetimeLocal/datetimeLocalToIso helpers.
This commit is contained in:
@@ -27,7 +27,10 @@ from app.jobs.jira_sync_job import sync_all_jira_links
|
|||||||
from app.jobs.retention_job import run_retention_job
|
from app.jobs.retention_job import run_retention_job
|
||||||
|
|
||||||
# Import check_and_run_recurring_campaigns from app.services.campaign_scheduler_service
|
# Import check_and_run_recurring_campaigns from app.services.campaign_scheduler_service
|
||||||
from app.services.campaign_scheduler_service import check_and_run_recurring_campaigns
|
from app.services.campaign_scheduler_service import (
|
||||||
|
check_and_run_recurring_campaigns,
|
||||||
|
sync_due_campaign_jira_tickets,
|
||||||
|
)
|
||||||
|
|
||||||
# Import scan_intel from app.services.intel_service
|
# Import scan_intel from app.services.intel_service
|
||||||
from app.services.intel_service import scan_intel
|
from app.services.intel_service import scan_intel
|
||||||
@@ -164,6 +167,19 @@ def _run_recurring_campaigns() -> None:
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _run_due_campaign_jira_sync() -> None:
|
||||||
|
"""Create Jira tickets for campaigns whose scheduled start_date has arrived."""
|
||||||
|
logger.info("Scheduled due-campaign Jira sync starting...")
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
processed = sync_due_campaign_jira_tickets(db)
|
||||||
|
logger.info("Due-campaign Jira sync finished — processed %d campaigns", processed)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Due-campaign Jira sync failed")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def _run_intel_scan() -> None:
|
def _run_intel_scan() -> None:
|
||||||
"""Execute an intel scan inside its own DB session."""
|
"""Execute an intel scan inside its own DB session."""
|
||||||
# Log info: "Scheduled intel scan job starting..."
|
# Log info: "Scheduled intel scan job starting..."
|
||||||
@@ -497,6 +513,14 @@ def start_scheduler() -> None:
|
|||||||
# Keyword argument: replace_existing
|
# Keyword argument: replace_existing
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
)
|
)
|
||||||
|
scheduler.add_job(
|
||||||
|
_run_due_campaign_jira_sync,
|
||||||
|
trigger="interval",
|
||||||
|
minutes=15,
|
||||||
|
id="due_campaign_jira_sync",
|
||||||
|
name="Due campaign Jira ticket sync (every 15 min)",
|
||||||
|
replace_existing=True,
|
||||||
|
)
|
||||||
# Call scheduler.add_job()
|
# Call scheduler.add_job()
|
||||||
scheduler.add_job(
|
scheduler.add_job(
|
||||||
sync_all_jira_links,
|
sync_all_jira_links,
|
||||||
@@ -605,7 +629,7 @@ def start_scheduler() -> None:
|
|||||||
# Literal argument value
|
# Literal argument value
|
||||||
"notification_cleanup (24h), weekly_snapshot (Sundays 00:00), "
|
"notification_cleanup (24h), weekly_snapshot (Sundays 00:00), "
|
||||||
# Literal argument value
|
# Literal argument value
|
||||||
"recurring_campaigns (daily), jira_sync (1h), "
|
"recurring_campaigns (daily), due_campaign_jira_sync (15m), jira_sync (1h), "
|
||||||
# Literal argument value
|
# Literal argument value
|
||||||
"osint_enrichment (weekly), stale_detection (daily), "
|
"osint_enrichment (weekly), stale_detection (daily), "
|
||||||
"retention_policies (daily), data_sources_sync (6h), "
|
"retention_policies (daily), data_sources_sync (6h), "
|
||||||
|
|||||||
@@ -135,39 +135,21 @@ router = APIRouter(prefix="/campaigns", tags=["campaigns"])
|
|||||||
|
|
||||||
|
|
||||||
def _create_jira_tickets_for_campaign(db: Session, campaign: Campaign, campaign_id: str, user: User) -> None:
|
def _create_jira_tickets_for_campaign(db: Session, campaign: Campaign, campaign_id: str, user: User) -> None:
|
||||||
"""Create Jira tickets for a campaign and its already-linked tests, if missing.
|
"""Create Jira tickets for *campaign* now, if its start_date has arrived.
|
||||||
|
|
||||||
Shared by both paths that bring a campaign to ``active``: the admin-only
|
Shared by both paths that bring a campaign to ``active``: the admin-only
|
||||||
emergency `/activate` override and the normal manager `/approve` flow.
|
emergency `/activate` override and the normal manager `/approve` flow.
|
||||||
Tests may already be linked to the campaign from when it was still a
|
If ``start_date`` is still in the future, ticket creation is skipped
|
||||||
draft, i.e. before any Jira ticket existed for the campaign — so they
|
here and left to the periodic ``sync_due_campaign_jira_tickets`` job,
|
||||||
need their own tickets created here too. Best-effort: failures are
|
which creates them once that date actually arrives — this is what makes
|
||||||
logged, not raised, since a Jira outage must not block activation.
|
the campaign's real scheduled date/time authoritative for when tickets
|
||||||
|
(and their Jira start-date field) appear, instead of always at approval
|
||||||
|
time.
|
||||||
"""
|
"""
|
||||||
try:
|
if campaign.start_date and campaign.start_date > datetime.utcnow():
|
||||||
from app.services.jira_service import (
|
return
|
||||||
auto_create_campaign_issue,
|
from app.services.jira_service import ensure_campaign_jira_tickets
|
||||||
auto_create_test_issue,
|
ensure_campaign_jira_tickets(db, campaign, user)
|
||||||
get_campaign_jira_key,
|
|
||||||
get_test_jira_key,
|
|
||||||
)
|
|
||||||
campaign_jira_key = get_campaign_jira_key(db, campaign_id)
|
|
||||||
if not campaign_jira_key:
|
|
||||||
campaign_jira_key = auto_create_campaign_issue(db, campaign, user)
|
|
||||||
if campaign_jira_key:
|
|
||||||
for ct in campaign.campaign_tests:
|
|
||||||
if ct.test and not get_test_jira_key(db, ct.test.id):
|
|
||||||
auto_create_test_issue(
|
|
||||||
db, ct.test, user,
|
|
||||||
parent_ticket_override=campaign_jira_key,
|
|
||||||
campaign_start_date=campaign.start_date,
|
|
||||||
)
|
|
||||||
db.commit()
|
|
||||||
except Exception:
|
|
||||||
logger.exception(
|
|
||||||
"Jira ticket creation failed while activating campaign %s",
|
|
||||||
campaign_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Pydantic schemas ─────────────────────────────────────────────────
|
# ── Pydantic schemas ─────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -72,32 +72,24 @@ def _clone_campaign(db: Session, original: Campaign) -> Campaign:
|
|||||||
1. Clone the campaign with a date-stamped name.
|
1. Clone the campaign with a date-stamped name.
|
||||||
2. For each ``CampaignTest`` in the original, create a new ``Test``
|
2. For each ``CampaignTest`` in the original, create a new ``Test``
|
||||||
with the same base data (in ``draft`` state) and link it.
|
with the same base data (in ``draft`` state) and link it.
|
||||||
3. Activate the new campaign.
|
3. Queue the new campaign for manager approval — recurrence only
|
||||||
|
automates *creating* the run, not skipping the same approval gate
|
||||||
|
every other campaign goes through. A manager still picks the real
|
||||||
|
start date (and that's what triggers Jira ticket creation) via the
|
||||||
|
normal /approve endpoint.
|
||||||
"""
|
"""
|
||||||
# Assign now = datetime.utcnow()
|
|
||||||
now = datetime.utcnow()
|
now = datetime.utcnow()
|
||||||
# Assign run_label = now.strftime("%Y-%m-%d")
|
|
||||||
run_label = now.strftime("%Y-%m-%d")
|
run_label = now.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
# Assign child = Campaign(
|
|
||||||
child = Campaign(
|
child = Campaign(
|
||||||
# Keyword argument: name
|
|
||||||
name=f"{original.name} (Run {run_label})",
|
name=f"{original.name} (Run {run_label})",
|
||||||
# Keyword argument: description
|
|
||||||
description=original.description,
|
description=original.description,
|
||||||
# Keyword argument: type
|
|
||||||
type=original.type,
|
type=original.type,
|
||||||
# Keyword argument: threat_actor_id
|
|
||||||
threat_actor_id=original.threat_actor_id,
|
threat_actor_id=original.threat_actor_id,
|
||||||
# Keyword argument: status
|
status="pending_approval",
|
||||||
status="active",
|
|
||||||
# Keyword argument: created_by
|
|
||||||
created_by=original.created_by,
|
created_by=original.created_by,
|
||||||
# Keyword argument: target_platform
|
|
||||||
target_platform=original.target_platform,
|
target_platform=original.target_platform,
|
||||||
# Keyword argument: tags
|
|
||||||
tags=original.tags or [],
|
tags=original.tags or [],
|
||||||
# Keyword argument: parent_campaign_id
|
|
||||||
parent_campaign_id=original.id,
|
parent_campaign_id=original.id,
|
||||||
)
|
)
|
||||||
# Stage new record(s) for database insertion
|
# Stage new record(s) for database insertion
|
||||||
@@ -239,46 +231,34 @@ def check_and_run_recurring_campaigns(db: Session) -> int:
|
|||||||
# Commit all pending changes to the database
|
# Commit all pending changes to the database
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# Notify
|
# Notify the creator — the run happened, but it still needs a
|
||||||
|
# manager's approval before it goes anywhere.
|
||||||
if campaign.created_by:
|
if campaign.created_by:
|
||||||
# Call create_notification()
|
|
||||||
create_notification(
|
create_notification(
|
||||||
db,
|
db,
|
||||||
# Keyword argument: user_id
|
|
||||||
user_id=campaign.created_by,
|
user_id=campaign.created_by,
|
||||||
# Keyword argument: type
|
|
||||||
type="recurring_campaign_run",
|
type="recurring_campaign_run",
|
||||||
# Keyword argument: title
|
title="Recurring campaign created — awaiting approval",
|
||||||
title="Recurring campaign executed",
|
|
||||||
# Keyword argument: message
|
|
||||||
message=(
|
message=(
|
||||||
f'Campaign "{child.name}" was automatically created '
|
f'Campaign "{child.name}" was automatically created '
|
||||||
f'from recurring template "{campaign.name}".'
|
f'from recurring template "{campaign.name}" and is now '
|
||||||
|
f'queued for manager approval.'
|
||||||
),
|
),
|
||||||
# Keyword argument: entity_type
|
|
||||||
entity_type="campaign",
|
entity_type="campaign",
|
||||||
# Keyword argument: entity_id
|
|
||||||
entity_id=child.id,
|
entity_id=child.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Notify red_tech users
|
# Notify managers — same approval gate as any other campaign,
|
||||||
red_techs = db.query(User).filter(User.role == "red_tech", User.is_active == True).all() # noqa: E712
|
# recurrence only automates spawning the run, not skipping review.
|
||||||
# Iterate over red_techs
|
managers = db.query(User).filter(User.role == "manager", User.is_active == True).all() # noqa: E712
|
||||||
for user in red_techs:
|
for user in managers:
|
||||||
# Call create_notification()
|
|
||||||
create_notification(
|
create_notification(
|
||||||
db,
|
db,
|
||||||
# Keyword argument: user_id
|
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
# Keyword argument: type
|
type="campaign_pending_approval",
|
||||||
type="campaign_activated",
|
title="Recurring campaign needs approval",
|
||||||
# Keyword argument: title
|
message=f'Campaign "{child.name}" was auto-created from a recurring template and needs your approval.',
|
||||||
title="New recurring campaign active",
|
|
||||||
# Keyword argument: message
|
|
||||||
message=f'Campaign "{child.name}" is now active and ready for execution.',
|
|
||||||
# Keyword argument: entity_type
|
|
||||||
entity_type="campaign",
|
entity_type="campaign",
|
||||||
# Keyword argument: entity_id
|
|
||||||
entity_id=child.id,
|
entity_id=child.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -296,3 +276,61 @@ def check_and_run_recurring_campaigns(db: Session) -> int:
|
|||||||
|
|
||||||
# Return spawned
|
# Return spawned
|
||||||
return spawned
|
return spawned
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Catch up on due campaigns' Jira tickets (periodic job)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def sync_due_campaign_jira_tickets(db: Session) -> int:
|
||||||
|
"""Create Jira tickets for active campaigns whose start_date has arrived.
|
||||||
|
|
||||||
|
A campaign approved with a future ``start_date`` intentionally skips
|
||||||
|
Jira ticket creation at approval time (see
|
||||||
|
``app.routers.campaigns._create_jira_tickets_for_campaign``). This job
|
||||||
|
is what actually creates those tickets once that date arrives — it
|
||||||
|
finds active campaigns lacking a Jira link and, for any whose
|
||||||
|
start_date is now due, calls the same idempotent creation helper.
|
||||||
|
|
||||||
|
Returns the number of campaigns processed.
|
||||||
|
"""
|
||||||
|
from app.models.jira_link import JiraLink, JiraLinkEntityType
|
||||||
|
from app.services.jira_service import ensure_campaign_jira_tickets
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
linked_campaign_ids = {
|
||||||
|
row[0]
|
||||||
|
for row in db.query(JiraLink.entity_id).filter(
|
||||||
|
JiraLink.entity_type == JiraLinkEntityType.campaign
|
||||||
|
).all()
|
||||||
|
}
|
||||||
|
|
||||||
|
due_campaigns = (
|
||||||
|
db.query(Campaign)
|
||||||
|
.filter(
|
||||||
|
Campaign.status == "active",
|
||||||
|
Campaign.start_date.isnot(None),
|
||||||
|
Campaign.start_date <= now,
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
processed = 0
|
||||||
|
for campaign in due_campaigns:
|
||||||
|
if campaign.id in linked_campaign_ids:
|
||||||
|
continue
|
||||||
|
actor = db.query(User).filter(User.id == campaign.approved_by).first()
|
||||||
|
if not actor:
|
||||||
|
actor = db.query(User).filter(User.id == campaign.created_by).first()
|
||||||
|
if not actor:
|
||||||
|
logger.warning(
|
||||||
|
"Cannot sync Jira tickets for campaign %s: no valid actor found",
|
||||||
|
campaign.id,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
ensure_campaign_jira_tickets(db, campaign, actor)
|
||||||
|
processed += 1
|
||||||
|
|
||||||
|
return processed
|
||||||
|
|||||||
@@ -705,6 +705,37 @@ def auto_create_campaign_issue(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_campaign_jira_tickets(db: Session, campaign, user: User) -> None:
|
||||||
|
"""Create Jira tickets for a campaign and its already-linked tests, if missing.
|
||||||
|
|
||||||
|
Called from two places: immediately at manager-approval time when the
|
||||||
|
campaign's ``start_date`` is now/past, and from the periodic
|
||||||
|
``sync_due_campaign_jira_tickets`` job for campaigns approved with a
|
||||||
|
future ``start_date`` once that date actually arrives. Idempotent —
|
||||||
|
checks for existing links before creating anything — so it is safe to
|
||||||
|
call repeatedly. Best-effort: failures are logged, not raised, since a
|
||||||
|
Jira outage must not block campaign activation.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
campaign_jira_key = get_campaign_jira_key(db, campaign.id)
|
||||||
|
if not campaign_jira_key:
|
||||||
|
campaign_jira_key = auto_create_campaign_issue(db, campaign, user)
|
||||||
|
if campaign_jira_key:
|
||||||
|
for ct in campaign.campaign_tests:
|
||||||
|
if ct.test and not get_test_jira_key(db, ct.test.id):
|
||||||
|
auto_create_test_issue(
|
||||||
|
db, ct.test, user,
|
||||||
|
parent_ticket_override=campaign_jira_key,
|
||||||
|
campaign_start_date=campaign.start_date,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Jira ticket creation failed for campaign %s",
|
||||||
|
campaign.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def auto_create_test_issue(
|
def auto_create_test_issue(
|
||||||
db: Session,
|
db: Session,
|
||||||
test: Test,
|
test: Test,
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ def test_manager_can_approve_and_sets_start_date(api, db, red_lead_user, red_lea
|
|||||||
"post",
|
"post",
|
||||||
f"/api/v1/campaigns/{campaign.id}/approve",
|
f"/api/v1/campaigns/{campaign.id}/approve",
|
||||||
manager_headers,
|
manager_headers,
|
||||||
json={"start_date": "2026-09-01T00:00:00"},
|
json={"start_date": "2020-01-01T00:00:00"},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
@@ -53,10 +53,13 @@ def test_manager_can_approve_and_sets_start_date(api, db, red_lead_user, red_lea
|
|||||||
assert body["start_date"] is not None
|
assert body["start_date"] is not None
|
||||||
|
|
||||||
|
|
||||||
def test_manager_approval_creates_jira_tickets(api, db, red_lead_user, red_lead_headers, manager_headers):
|
def test_manager_approval_creates_jira_tickets_when_start_date_is_due(
|
||||||
|
api, db, red_lead_user, red_lead_headers, manager_headers
|
||||||
|
):
|
||||||
"""The normal manager-approval path must create Jira tickets too, not just
|
"""The normal manager-approval path must create Jira tickets too, not just
|
||||||
the admin-only emergency /activate override — this was the Block 1 gap:
|
the admin-only emergency /activate override — this was the Block 1 gap:
|
||||||
campaigns approved through the standard flow never got a Jira ticket."""
|
campaigns approved through the standard flow never got a Jira ticket.
|
||||||
|
Uses a past start_date so ticket creation is due immediately."""
|
||||||
campaign = _make_draft_campaign(db, red_lead_user.id)
|
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||||||
api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
|
api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
|
||||||
|
|
||||||
@@ -68,7 +71,7 @@ def test_manager_approval_creates_jira_tickets(api, db, red_lead_user, red_lead_
|
|||||||
"post",
|
"post",
|
||||||
f"/api/v1/campaigns/{campaign.id}/approve",
|
f"/api/v1/campaigns/{campaign.id}/approve",
|
||||||
manager_headers,
|
manager_headers,
|
||||||
json={"start_date": "2026-09-01T00:00:00"},
|
json={"start_date": "2020-01-01T00:00:00"},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
@@ -77,6 +80,29 @@ def test_manager_approval_creates_jira_tickets(api, db, red_lead_user, red_lead_
|
|||||||
mock_create_test.assert_called_once()
|
mock_create_test.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_approval_skips_jira_tickets_when_start_date_is_future(
|
||||||
|
api, db, red_lead_user, red_lead_headers, manager_headers
|
||||||
|
):
|
||||||
|
"""A campaign scheduled to start in the future must not get its Jira
|
||||||
|
tickets created at approval time — that must wait for the periodic
|
||||||
|
due-campaign sync job, once the scheduled start_date actually arrives."""
|
||||||
|
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||||||
|
api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
|
||||||
|
|
||||||
|
with patch("app.services.jira_service.get_campaign_jira_key", return_value=None) as mock_get_key, \
|
||||||
|
patch("app.services.jira_service.auto_create_campaign_issue", return_value="PT-100") as mock_create_campaign:
|
||||||
|
resp = api(
|
||||||
|
"post",
|
||||||
|
f"/api/v1/campaigns/{campaign.id}/approve",
|
||||||
|
manager_headers,
|
||||||
|
json={"start_date": "2099-01-01T00:00:00"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
mock_get_key.assert_not_called()
|
||||||
|
mock_create_campaign.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
def test_lead_cannot_approve(api, db, red_lead_user, red_lead_headers):
|
def test_lead_cannot_approve(api, db, red_lead_user, red_lead_headers):
|
||||||
campaign = _make_draft_campaign(db, red_lead_user.id)
|
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||||||
api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
|
api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from app.services.campaign_service import (
|
|||||||
from app.services.campaign_scheduler_service import (
|
from app.services.campaign_scheduler_service import (
|
||||||
calculate_next_run,
|
calculate_next_run,
|
||||||
check_and_run_recurring_campaigns,
|
check_and_run_recurring_campaigns,
|
||||||
|
sync_due_campaign_jira_tickets,
|
||||||
)
|
)
|
||||||
from app.services.snapshot_service import (
|
from app.services.snapshot_service import (
|
||||||
create_snapshot,
|
create_snapshot,
|
||||||
@@ -199,7 +200,10 @@ class TestCampaigns:
|
|||||||
)
|
)
|
||||||
assert child is not None
|
assert child is not None
|
||||||
assert "Run" in child.name
|
assert "Run" in child.name
|
||||||
assert child.status == "active"
|
# Recurrence only automates spawning the run — it still needs a
|
||||||
|
# manager's approval like any other campaign, same as the manual
|
||||||
|
# draft -> submit -> approve path.
|
||||||
|
assert child.status == "pending_approval"
|
||||||
|
|
||||||
# Check child tests are fresh copies (new IDs, draft state)
|
# Check child tests are fresh copies (new IDs, draft state)
|
||||||
child_cts = (
|
child_cts = (
|
||||||
@@ -217,12 +221,92 @@ class TestCampaigns:
|
|||||||
test = db.query(Test).filter(Test.id == ct.test_id).first()
|
test = db.query(Test).filter(Test.id == ct.test_id).first()
|
||||||
assert test.state == TestState.draft
|
assert test.state == TestState.draft
|
||||||
|
|
||||||
|
def test_campaign_cloning_notifies_managers(self, db, campaign_with_tests, manager_user):
|
||||||
|
"""Managers must hear about a spawned recurring run — it's sitting in
|
||||||
|
their approval queue just like a manually-submitted campaign."""
|
||||||
|
from app.models.notification import Notification
|
||||||
|
|
||||||
|
campaign = campaign_with_tests["campaign"]
|
||||||
|
campaign.is_recurring = True
|
||||||
|
campaign.recurrence_pattern = "monthly"
|
||||||
|
campaign.next_run_at = datetime.utcnow() - timedelta(hours=1)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
check_and_run_recurring_campaigns(db)
|
||||||
|
|
||||||
|
notif = (
|
||||||
|
db.query(Notification)
|
||||||
|
.filter(Notification.user_id == manager_user.id, Notification.type == "campaign_pending_approval")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
assert notif is not None
|
||||||
|
|
||||||
# Check parent was updated
|
# Check parent was updated
|
||||||
db.refresh(campaign)
|
db.refresh(campaign)
|
||||||
assert campaign.last_run_at is not None
|
assert campaign.last_run_at is not None
|
||||||
assert campaign.next_run_at > datetime.utcnow()
|
assert campaign.next_run_at > datetime.utcnow()
|
||||||
|
|
||||||
|
|
||||||
|
class TestDueCampaignJiraSync:
|
||||||
|
"""sync_due_campaign_jira_tickets — the periodic catch-up job for
|
||||||
|
campaigns approved with a future start_date whose Jira tickets were
|
||||||
|
deliberately skipped at approval time."""
|
||||||
|
|
||||||
|
def test_creates_tickets_for_due_campaign_without_jira_link(self, db, campaign_with_tests, admin_user):
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
campaign = campaign_with_tests["campaign"]
|
||||||
|
campaign.status = "active"
|
||||||
|
campaign.approved_by = admin_user.id
|
||||||
|
campaign.start_date = datetime.utcnow() - timedelta(hours=1) # now due
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.services.jira_service.get_campaign_jira_key", return_value=None
|
||||||
|
) as mock_get_key, patch(
|
||||||
|
"app.services.jira_service.auto_create_campaign_issue", return_value="PT-200"
|
||||||
|
) as mock_create_campaign, patch(
|
||||||
|
"app.services.jira_service.get_test_jira_key", return_value=None
|
||||||
|
), patch(
|
||||||
|
"app.services.jira_service.auto_create_test_issue"
|
||||||
|
) as mock_create_test:
|
||||||
|
processed = sync_due_campaign_jira_tickets(db)
|
||||||
|
|
||||||
|
assert processed == 1
|
||||||
|
mock_get_key.assert_called_once()
|
||||||
|
mock_create_campaign.assert_called_once()
|
||||||
|
assert mock_create_test.call_count == len(campaign_with_tests["tests"])
|
||||||
|
|
||||||
|
def test_skips_campaign_with_future_start_date(self, db, campaign_with_tests, admin_user):
|
||||||
|
campaign = campaign_with_tests["campaign"]
|
||||||
|
campaign.status = "active"
|
||||||
|
campaign.approved_by = admin_user.id
|
||||||
|
campaign.start_date = datetime.utcnow() + timedelta(days=30)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
processed = sync_due_campaign_jira_tickets(db)
|
||||||
|
assert processed == 0
|
||||||
|
|
||||||
|
def test_skips_campaign_already_linked_to_jira(self, db, campaign_with_tests, admin_user):
|
||||||
|
from app.models.jira_link import JiraLink, JiraLinkEntityType
|
||||||
|
|
||||||
|
campaign = campaign_with_tests["campaign"]
|
||||||
|
campaign.status = "active"
|
||||||
|
campaign.approved_by = admin_user.id
|
||||||
|
campaign.start_date = datetime.utcnow() - timedelta(hours=1)
|
||||||
|
db.add(JiraLink(
|
||||||
|
entity_type=JiraLinkEntityType.campaign,
|
||||||
|
entity_id=campaign.id,
|
||||||
|
jira_issue_key="PT-1",
|
||||||
|
jira_project_key="PT",
|
||||||
|
created_by=admin_user.id,
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
processed = sync_due_campaign_jira_tickets(db)
|
||||||
|
assert processed == 0
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
# Snapshot Tests
|
# Snapshot Tests
|
||||||
# ═══════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import JiraLinkPanel from "../components/JiraLinkPanel";
|
|||||||
import CampaignTimingPanel from "../components/CampaignTimingPanel";
|
import CampaignTimingPanel from "../components/CampaignTimingPanel";
|
||||||
import AddTestToCampaignModal from "../components/AddTestToCampaignModal";
|
import AddTestToCampaignModal from "../components/AddTestToCampaignModal";
|
||||||
import RequestCampaignModificationModal from "../components/RequestCampaignModificationModal";
|
import RequestCampaignModificationModal from "../components/RequestCampaignModificationModal";
|
||||||
|
import { datetimeLocalToIso } from "../utils/datetime";
|
||||||
|
|
||||||
const statusColors: Record<string, string> = {
|
const statusColors: Record<string, string> = {
|
||||||
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
|
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
|
||||||
@@ -115,7 +116,12 @@ export default function CampaignDetailPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const approveMutation = useMutation({
|
const approveMutation = useMutation({
|
||||||
mutationFn: () => approveCampaign(campaignId!, approveStartDate),
|
// approveStartDate comes from <input type="datetime-local"> — wall-clock
|
||||||
|
// time in the browser's own timezone. Sending it raw let the backend
|
||||||
|
// (which treats it as UTC) silently shift the campaign's real start
|
||||||
|
// time by the browser's UTC offset, causing Jira tickets to be created
|
||||||
|
// before the date/time the manager actually picked.
|
||||||
|
mutationFn: () => approveCampaign(campaignId!, datetimeLocalToIso(approveStartDate)!),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["campaign", campaignId] });
|
queryClient.invalidateQueries({ queryKey: ["campaign", campaignId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["campaign-timeline", campaignId] });
|
queryClient.invalidateQueries({ queryKey: ["campaign-timeline", campaignId] });
|
||||||
|
|||||||
@@ -43,32 +43,7 @@ import ConfirmDialog from "../components/ConfirmDialog";
|
|||||||
import JiraLinkPanel from "../components/JiraLinkPanel";
|
import JiraLinkPanel from "../components/JiraLinkPanel";
|
||||||
import TestPhaseTimeline from "../components/TestPhaseTimeline";
|
import TestPhaseTimeline from "../components/TestPhaseTimeline";
|
||||||
import { createTemplate } from "../api/test-templates";
|
import { createTemplate } from "../api/test-templates";
|
||||||
|
import { isoToDatetimeLocal, datetimeLocalToIso } from "../utils/datetime";
|
||||||
// ── Helpers ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// The backend always stores/returns naive UTC datetimes (no "Z", no
|
|
||||||
// offset). <input type="datetime-local"> shows/edits wall-clock time in
|
|
||||||
// the browser's own timezone, so both directions need an explicit
|
|
||||||
// UTC <-> local conversion — treating the raw digits as interchangeable
|
|
||||||
// silently shifts every value by the browser's UTC offset.
|
|
||||||
function isoToDatetimeLocal(iso: string | null): string {
|
|
||||||
if (!iso) return "";
|
|
||||||
const utcStr = iso.endsWith("Z") || iso.includes("+") ? iso : iso + "Z";
|
|
||||||
const d = new Date(utcStr);
|
|
||||||
if (isNaN(d.getTime())) return "";
|
|
||||||
const pad = (n: number) => String(n).padStart(2, "0");
|
|
||||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function datetimeLocalToIso(local: string): string | undefined {
|
|
||||||
if (!local) return undefined;
|
|
||||||
// New Date() on a plain "YYYY-MM-DDTHH:mm" string (no zone designator)
|
|
||||||
// parses it as local time per the ECMA-262 date string spec — exactly
|
|
||||||
// the wall-clock time the user picked.
|
|
||||||
const d = new Date(local);
|
|
||||||
if (isNaN(d.getTime())) return undefined;
|
|
||||||
return d.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Page Component ─────────────────────────────────────────────────
|
// ── Page Component ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// The backend always stores/returns naive UTC datetimes (no "Z", no
|
||||||
|
// offset). <input type="datetime-local"> shows/edits wall-clock time in
|
||||||
|
// the browser's own timezone, so both directions need an explicit
|
||||||
|
// UTC <-> local conversion — treating the raw digits as interchangeable
|
||||||
|
// silently shifts every value by the browser's UTC offset.
|
||||||
|
|
||||||
|
export function isoToDatetimeLocal(iso: string | null): string {
|
||||||
|
if (!iso) return "";
|
||||||
|
const utcStr = iso.endsWith("Z") || iso.includes("+") ? iso : iso + "Z";
|
||||||
|
const d = new Date(utcStr);
|
||||||
|
if (isNaN(d.getTime())) return "";
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function datetimeLocalToIso(local: string): string | undefined {
|
||||||
|
if (!local) return undefined;
|
||||||
|
// New Date() on a plain "YYYY-MM-DDTHH:mm" string (no zone designator)
|
||||||
|
// parses it as local time per the ECMA-262 date string spec — exactly
|
||||||
|
// the wall-clock time the user picked.
|
||||||
|
const d = new Date(local);
|
||||||
|
if (isNaN(d.getTime())) return undefined;
|
||||||
|
return d.toISOString();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user