feat(outreach+pseo): follow-up scheduling, activity timeline, noindex articles (subtasks 1-9)

Feature A — Outreach follow-up scheduling + activity timeline:
- Migration 0025: follow_up_at column on suppliers
- POST /admin/outreach/<id>/follow-up route (HTMX date picker, updates row)
- get_follow_up_due_count() query + amber banner on /admin/outreach
- ?follow_up=due / ?follow_up=set filters in get_outreach_suppliers()
- Follow-up column in outreach_results.html + outreach_row.html date input
- Activity timeline on supplier_detail.html — merges email_log (sent outreach)
  and inbound_emails (received) by contact_email, sorted by date

Feature B — pSEO article noindex:
- Migration 0025: noindex column on articles (default 0)
- NOINDEX_THRESHOLDS dict in content/__init__.py (per-template thresholds)
- generate_articles() upsert now stores noindex = 1 for thin-data articles
- <meta name="robots" content="noindex, follow"> in article_detail.html (conditional)
- sitemap.py excludes noindex=1 articles from sitemap.xml
- pSEO dashboard noindex count card; article_row.html noindex badge
- 73 new tests (test_outreach.py + test_noindex.py), 1377 total, 0 failures

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-25 16:12:21 +01:00
parent efaba2cb76
commit ea06dd0689
14 changed files with 584 additions and 14 deletions

View File

@@ -22,6 +22,7 @@ from quart.datastructures import FileStorage
from padelnomics import core
from padelnomics.admin.routes import (
OUTREACH_STATUSES,
get_follow_up_due_count,
get_outreach_pipeline,
get_outreach_suppliers,
)
@@ -74,6 +75,7 @@ async def _insert_supplier(
outreach_notes: str = None,
outreach_sequence_step: int = 0,
last_contacted_at: str = None,
follow_up_at: str = None,
) -> int:
"""Insert a supplier row and return its id."""
now = datetime.now(UTC).isoformat()
@@ -82,12 +84,12 @@ async def _insert_supplier(
"""INSERT INTO suppliers
(name, slug, country_code, region, category, tier,
contact_email, outreach_status, outreach_notes, outreach_sequence_step,
last_contacted_at, created_at)
last_contacted_at, follow_up_at, created_at)
VALUES (?, ?, ?, 'Europe', 'construction', 'free',
?, ?, ?, ?, ?, ?)""",
?, ?, ?, ?, ?, ?, ?)""",
(name, slug, country_code, contact_email,
outreach_status, outreach_notes, outreach_sequence_step,
last_contacted_at, now),
last_contacted_at, follow_up_at, now),
) as cursor:
supplier_id = cursor.lastrowid
await db.commit()
@@ -617,6 +619,216 @@ class TestComposePipelineUpdate:
assert row["outreach_sequence_step"] == 0 # unchanged
# ── Follow-up Scheduling ─────────────────────────────────────────────────────
class TestFollowUpRoute:
async def test_follow_up_set(self, admin_client, db):
"""POST a date → follow_up_at saved, updated row returned."""
supplier_id = await _insert_supplier(db, outreach_status="prospect")
resp = await admin_client.post(
f"/admin/outreach/{supplier_id}/follow-up",
form={"follow_up_at": "2026-03-01", "csrf_token": _TEST_CSRF},
)
assert resp.status_code == 200
row = await core.fetch_one(
"SELECT follow_up_at FROM suppliers WHERE id = ?", (supplier_id,)
)
assert row["follow_up_at"] == "2026-03-01"
async def test_follow_up_set_returns_row_html(self, admin_client, db):
"""Response should contain the supplier row (for HTMX outerHTML swap)."""
supplier_id = await _insert_supplier(db, outreach_status="contacted")
resp = await admin_client.post(
f"/admin/outreach/{supplier_id}/follow-up",
form={"follow_up_at": "2026-04-15", "csrf_token": _TEST_CSRF},
)
html = (await resp.data).decode()
assert str(supplier_id) in html
async def test_follow_up_clear(self, admin_client, db):
"""POST empty date → follow_up_at set to NULL."""
supplier_id = await _insert_supplier(
db, outreach_status="contacted", follow_up_at="2026-03-01"
)
await admin_client.post(
f"/admin/outreach/{supplier_id}/follow-up",
form={"follow_up_at": "", "csrf_token": _TEST_CSRF},
)
row = await core.fetch_one(
"SELECT follow_up_at FROM suppliers WHERE id = ?", (supplier_id,)
)
assert row["follow_up_at"] is None
async def test_follow_up_404_for_non_pipeline_supplier(self, admin_client, db):
"""Supplier not in outreach pipeline → 404."""
supplier_id = await _insert_supplier(db, outreach_status=None)
resp = await admin_client.post(
f"/admin/outreach/{supplier_id}/follow-up",
form={"follow_up_at": "2026-03-01", "csrf_token": _TEST_CSRF},
)
assert resp.status_code == 404
class TestFollowUpDueCount:
async def test_counts_only_due_and_overdue(self, db):
"""Only suppliers with follow_up_at <= today are counted."""
# Past (overdue)
await _insert_supplier(db, name="Past", outreach_status="contacted",
follow_up_at="2020-01-01")
# Future (not yet due)
await _insert_supplier(db, name="Future", outreach_status="contacted",
follow_up_at="2099-12-31")
# No follow-up scheduled
await _insert_supplier(db, name="None", outreach_status="prospect",
follow_up_at=None)
count = await get_follow_up_due_count()
assert count == 1
async def test_zero_when_no_follow_ups(self, db):
await _insert_supplier(db, outreach_status="prospect")
count = await get_follow_up_due_count()
assert count == 0
async def test_follow_up_due_filter(self, db):
"""?follow_up=due filter returns only overdue suppliers."""
await _insert_supplier(db, name="Overdue", outreach_status="contacted",
follow_up_at="2020-06-01")
await _insert_supplier(db, name="NotYet", outreach_status="contacted",
follow_up_at="2099-01-01")
due = await get_outreach_suppliers(follow_up="due")
names = [s["name"] for s in due]
assert "Overdue" in names
assert "NotYet" not in names
async def test_follow_up_set_filter(self, db):
"""?follow_up=set returns suppliers with any follow-up date."""
await _insert_supplier(db, name="HasDate", outreach_status="contacted",
follow_up_at="2099-01-01")
await _insert_supplier(db, name="NoDate", outreach_status="contacted",
follow_up_at=None)
with_date = await get_outreach_suppliers(follow_up="set")
names = [s["name"] for s in with_date]
assert "HasDate" in names
assert "NoDate" not in names
async def test_outreach_dashboard_shows_follow_up_banner(self, admin_client, db):
"""Banner visible when there's at least one due follow-up."""
await _insert_supplier(db, name="DueNow", outreach_status="prospect",
follow_up_at="2020-01-01")
resp = await admin_client.get("/admin/outreach")
html = (await resp.data).decode()
assert "follow-up" in html.lower() or "Follow-up" in html
async def test_outreach_results_follow_up_filter(self, admin_client, db):
"""follow_up=due querystring filters results partial."""
await _insert_supplier(db, name="DueSupplier", outreach_status="contacted",
follow_up_at="2020-01-01")
await _insert_supplier(db, name="FutureSupplier", outreach_status="contacted",
follow_up_at="2099-01-01")
resp = await admin_client.get("/admin/outreach/results?follow_up=due")
html = (await resp.data).decode()
assert "DueSupplier" in html
assert "FutureSupplier" not in html
# ── Activity Timeline ─────────────────────────────────────────────────────────
async def _insert_email_log(db, to_addr: str, subject: str, email_type: str = "outreach") -> int:
"""Insert a sent email entry into email_log."""
now = datetime.now(UTC).isoformat()
async with db.execute(
"""INSERT INTO email_log (from_addr, to_addr, subject, email_type, created_at)
VALUES (?, ?, ?, ?, ?)""",
("hello@hello.padelnomics.io", to_addr, subject, email_type, now),
) as cursor:
row_id = cursor.lastrowid
await db.commit()
return row_id
async def _insert_inbound_email(db, from_addr: str, subject: str) -> int:
"""Insert a received email entry into inbound_emails."""
now = datetime.now(UTC).isoformat()
async with db.execute(
"""INSERT INTO inbound_emails
(resend_id, from_addr, to_addr, subject, received_at, created_at)
VALUES (?, ?, ?, ?, ?, ?)""",
(f"test-{now}", from_addr, "inbox@hello.padelnomics.io", subject, now, now),
) as cursor:
row_id = cursor.lastrowid
await db.commit()
return row_id
class TestActivityTimeline:
async def test_timeline_shows_sent_emails(self, admin_client, db):
"""Sent outreach emails visible in supplier detail timeline."""
supplier_id = await _insert_supplier(
db, name="Timeline Test", contact_email="timeline@example.com",
outreach_status="contacted",
)
await _insert_email_log(db, "timeline@example.com", "Introduction email")
resp = await admin_client.get(f"/admin/suppliers/{supplier_id}")
assert resp.status_code == 200
html = (await resp.data).decode()
assert "Introduction email" in html
async def test_timeline_shows_received_emails(self, admin_client, db):
"""Received emails visible in supplier detail timeline."""
supplier_id = await _insert_supplier(
db, name="Inbound Test", contact_email="inbound@example.com",
outreach_status="replied",
)
await _insert_inbound_email(db, "inbound@example.com", "Re: Partnership")
resp = await admin_client.get(f"/admin/suppliers/{supplier_id}")
assert resp.status_code == 200
html = (await resp.data).decode()
assert "Re: Partnership" in html
async def test_timeline_empty_state(self, admin_client, db):
"""Supplier with no emails shows empty state message."""
supplier_id = await _insert_supplier(
db, name="No Emails", contact_email="noemail@example.com",
)
resp = await admin_client.get(f"/admin/suppliers/{supplier_id}")
assert resp.status_code == 200
html = (await resp.data).decode()
assert "No email history" in html
async def test_timeline_excludes_non_outreach_sent_emails(self, admin_client, db):
"""Transactional sent emails (magic links etc.) not shown in timeline."""
supplier_id = await _insert_supplier(
db, name="Transact Test", contact_email="trans@example.com",
)
await _insert_email_log(
db, "trans@example.com", "Magic link", email_type="magic_link"
)
resp = await admin_client.get(f"/admin/suppliers/{supplier_id}")
html = (await resp.data).decode()
assert "Magic link" not in html
async def test_timeline_not_shown_when_no_contact_email(self, admin_client, db):
"""Timeline section should still load (empty) when contact_email is NULL."""
now = datetime.now(UTC).isoformat()
async with db.execute(
"""INSERT INTO suppliers (name, slug, tier, country_code, region, category, created_at)
VALUES ('No Contact', 'no-contact', 'free', 'XX', 'Europe', 'construction', ?)""",
(now,),
) as cursor:
supplier_id = cursor.lastrowid
await db.commit()
resp = await admin_client.get(f"/admin/suppliers/{supplier_id}")
assert resp.status_code == 200
# ── CSV writer helper (avoids importing DictWriter at module level) ────────────
import csv as _csv_module