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

@@ -80,6 +80,9 @@ async def pseo_dashboard():
total_published = sum(r["stats"]["published"] for r in template_rows)
stale_count = sum(1 for f in freshness if f["status"] == "stale")
noindex_row = await fetch_one("SELECT COUNT(*) as cnt FROM articles WHERE noindex = 1")
noindex_count = noindex_row["cnt"] if noindex_row else 0
# Recent generation jobs — enough for the dashboard summary.
jobs = await fetch_all(
"SELECT id, task_name, status, progress_current, progress_total,"
@@ -95,6 +98,7 @@ async def pseo_dashboard():
total_published=total_published,
total_templates=len(templates),
stale_count=stale_count,
noindex_count=noindex_count,
jobs=jobs,
admin_page="pseo",
)

View File

@@ -985,6 +985,30 @@ async def supplier_detail(supplier_id: int):
)
enquiry_count = enquiry_row["cnt"] if enquiry_row else 0
# Email activity timeline — correlate by contact_email (no FK)
timeline = []
contact_email = supplier["contact_email"] if supplier else None
if contact_email:
sent = await fetch_all(
"""SELECT created_at, subject, 'sent' AS direction
FROM email_log
WHERE to_addr = ? AND email_type = 'outreach'
ORDER BY created_at DESC LIMIT 50""",
(contact_email,),
)
received = await fetch_all(
"""SELECT received_at AS created_at, subject, 'received' AS direction
FROM inbound_emails
WHERE from_addr = ?
ORDER BY received_at DESC LIMIT 50""",
(contact_email,),
)
timeline = sorted(
list(sent) + list(received),
key=lambda x: x["created_at"] or "",
reverse=True,
)[:50]
return await render_template(
"admin/supplier_detail.html",
supplier=supplier,
@@ -994,6 +1018,7 @@ async def supplier_detail(supplier_id: int):
boosts=boosts,
forwards=forwards,
enquiry_count=enquiry_count,
timeline=timeline,
)
@@ -2595,6 +2620,15 @@ _CSV_OPTIONAL = {"country_code", "category", "website"}
_CSV_IMPORT_LIMIT = 500 # guard against huge uploads
async def get_follow_up_due_count() -> int:
"""Count pipeline suppliers with follow_up_at <= today."""
row = await fetch_one(
"""SELECT COUNT(*) as cnt FROM suppliers
WHERE outreach_status IS NOT NULL AND follow_up_at <= date('now')"""
)
return row["cnt"] if row else 0
async def get_outreach_pipeline() -> dict:
"""Count suppliers per outreach status for the pipeline summary cards."""
rows = await fetch_all(
@@ -2614,6 +2648,7 @@ async def get_outreach_suppliers(
status: str = None,
country: str = None,
search: str = None,
follow_up: str = None,
page: int = 1,
per_page: int = 50,
) -> list[dict]:
@@ -2630,6 +2665,10 @@ async def get_outreach_suppliers(
if search:
wheres.append("(name LIKE ? OR contact_email LIKE ?)")
params.extend([f"%{search}%", f"%{search}%"])
if follow_up == "due":
wheres.append("follow_up_at <= date('now')")
elif follow_up == "set":
wheres.append("follow_up_at IS NOT NULL")
where = " AND ".join(wheres)
offset = (page - 1) * per_page
@@ -2638,7 +2677,7 @@ async def get_outreach_suppliers(
return await fetch_all(
f"""SELECT id, name, country_code, category, contact_email,
outreach_status, outreach_notes, last_contacted_at,
outreach_sequence_step
outreach_sequence_step, follow_up_at
FROM suppliers
WHERE {where}
ORDER BY
@@ -2662,12 +2701,14 @@ async def outreach():
status = request.args.get("status", "")
country = request.args.get("country", "")
search = request.args.get("search", "").strip()
follow_up = request.args.get("follow_up", "")
page = max(1, int(request.args.get("page", "1") or "1"))
pipeline = await get_outreach_pipeline()
follow_up_due = await get_follow_up_due_count()
supplier_list = await get_outreach_suppliers(
status=status or None, country=country or None,
search=search or None, page=page,
search=search or None, follow_up=follow_up or None, page=page,
)
countries = await fetch_all(
"""SELECT DISTINCT country_code FROM suppliers
@@ -2678,12 +2719,14 @@ async def outreach():
return await render_template(
"admin/outreach.html",
pipeline=pipeline,
follow_up_due=follow_up_due,
suppliers=supplier_list,
statuses=OUTREACH_STATUSES,
countries=[c["country_code"] for c in countries],
current_status=status,
current_country=country,
current_search=search,
current_follow_up=follow_up,
page=page,
)
@@ -2695,11 +2738,12 @@ async def outreach_results():
status = request.args.get("status", "")
country = request.args.get("country", "")
search = request.args.get("search", "").strip()
follow_up = request.args.get("follow_up", "")
page = max(1, int(request.args.get("page", "1") or "1"))
supplier_list = await get_outreach_suppliers(
status=status or None, country=country or None,
search=search or None, page=page,
search=search or None, follow_up=follow_up or None, page=page,
)
return await render_template(
"admin/partials/outreach_results.html", suppliers=supplier_list,
@@ -2755,6 +2799,33 @@ async def outreach_note(supplier_id: int):
return note[:80] + ("" if len(note) > 80 else "") if note else ""
@bp.route("/outreach/<int:supplier_id>/follow-up", methods=["POST"])
@role_required("admin")
@csrf_protect
async def outreach_follow_up(supplier_id: int):
"""HTMX: set or clear the follow_up_at date for a supplier, return the updated row."""
supplier = await fetch_one(
"SELECT * FROM suppliers WHERE id = ? AND outreach_status IS NOT NULL",
(supplier_id,),
)
if not supplier:
return Response("Not found", status=404)
form = await request.form
follow_up_at_raw = form.get("follow_up_at", "").strip()
# Accept YYYY-MM-DD or empty (to clear)
follow_up_at = follow_up_at_raw if follow_up_at_raw else None
await execute(
"UPDATE suppliers SET follow_up_at = ? WHERE id = ?",
(follow_up_at, supplier_id),
)
updated = await fetch_one("SELECT * FROM suppliers WHERE id = ?", (supplier_id,))
return await render_template("admin/partials/outreach_row.html", s=updated)
@bp.route("/outreach/add-prospects", methods=["POST"])
@role_required("admin")
@csrf_protect

View File

@@ -37,6 +37,17 @@
{% endfor %}
</div>
<!-- Follow-up banner -->
{% if follow_up_due > 0 %}
<div style="background:#FEF3C7;border:1px solid #F59E0B;border-radius:6px;padding:0.75rem 1rem;margin-bottom:1rem;display:flex;align-items:center;justify-content:space-between">
<span style="color:#92400E;font-size:0.875rem">
&#x23F0; <strong>{{ follow_up_due }}</strong> follow-up{{ 's' if follow_up_due != 1 else '' }} due today
</span>
<a href="{{ url_for('admin.outreach') }}?follow_up=due"
class="btn-outline btn-sm" style="font-size:0.75rem;padding:3px 10px">Show them</a>
</div>
{% endif %}
<!-- Filters -->
<div class="card mb-4" style="padding:1rem 1.25rem;">
<form class="flex flex-wrap gap-3 items-end"
@@ -45,6 +56,7 @@
hx-trigger="change, input delay:300ms"
hx-indicator="#outreach-loading">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if current_follow_up %}<input type="hidden" name="follow_up" value="{{ current_follow_up }}">{% endif %}
<div>
<label class="text-xs font-semibold text-slate block mb-1">Status</label>

View File

@@ -9,6 +9,7 @@
{% else %}
<span class="badge">Draft</span>
{% endif %}
{% if a.noindex %}<span class="badge" style="background:#FEF3C7;color:#92400E;font-size:0.6rem">noindex</span>{% endif %}
</td>
<td class="mono">{{ a.published_at[:10] if a.published_at else '-' }}</td>
<td>{{ a.language | upper if a.language else '-' }}</td>

View File

@@ -8,6 +8,7 @@
<th>Status</th>
<th>Step</th>
<th>Last Contact</th>
<th>Follow-up</th>
<th>Notes</th>
<th>Actions</th>
</tr>

View File

@@ -35,6 +35,20 @@
{% endif %}
</td>
<td>
{# Follow-up date picker — submits on change, row swaps via HTMX #}
<form hx-post="{{ url_for('admin.outreach_follow_up', supplier_id=s.id) }}"
hx-target="#outreach-row-{{ s.id }}"
hx-swap="outerHTML"
class="m-0">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="date" name="follow_up_at"
value="{{ s.follow_up_at or '' }}"
class="form-input"
style="font-size:0.75rem;padding:2px 6px"
onchange="this.form.requestSubmit()">
</form>
</td>
<td style="max-width:160px">
{# Inline note edit #}
<form hx-post="{{ url_for('admin.outreach_note', supplier_id=s.id) }}"

View File

@@ -50,9 +50,9 @@
<p class="text-xs text-slate mt-1">data newer than articles</p>
</div>
<div class="card text-center">
<p class="card-header">Health Checks</p>
<p class="text-3xl font-bold text-navy"></p>
<p class="text-xs text-slate mt-1">see Health section below</p>
<p class="card-header">Noindex</p>
<p class="text-3xl font-bold {% if noindex_count > 0 %}text-amber-600{% else %}text-navy{% endif %}">{{ noindex_count }}</p>
<p class="text-xs text-slate mt-1">thin-data articles</p>
</div>
</div>

View File

@@ -143,6 +143,34 @@
</div>
{% endif %}
<!-- Email activity timeline -->
{% if supplier.contact_email %}
<div class="card mb-4" style="padding:1.5rem">
<h2 class="text-lg mb-3">Activity Timeline</h2>
{% if timeline %}
<div style="display:flex;flex-direction:column;gap:0.5rem">
{% for entry in timeline %}
<div style="display:flex;gap:0.75rem;align-items:baseline;font-size:0.8125rem">
<span style="flex-shrink:0;width:1.5rem;text-align:center;color:{% if entry.direction == 'sent' %}#2563EB{% else %}#059669{% endif %}">
{% if entry.direction == 'sent' %}&#x2190;{% else %}&#x2192;{% endif %}
</span>
<span class="mono text-xs text-slate" style="flex-shrink:0;width:6.5rem">
{{ entry.created_at[:10] if entry.created_at else '—' }}
</span>
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1"
title="{{ entry.subject or '' }}">
{{ (entry.subject or '(no subject)')[:80] }}
</span>
<span class="text-xs text-slate" style="flex-shrink:0">{{ entry.direction }}</span>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-sm text-slate">No email history yet.</p>
{% endif %}
</div>
{% endif %}
<!-- Active boosts -->
<div class="card" style="padding:1.5rem">
<h2 class="text-lg mb-3">Active Boosts</h2>

View File

@@ -26,6 +26,14 @@ logger = logging.getLogger(__name__)
TEMPLATES_DIR = Path(__file__).parent / "templates"
BUILD_DIR = Path("data/content/_build")
# Threshold functions per template slug.
# Return True → article should be noindex (insufficient data for quality content).
NOINDEX_THRESHOLDS: dict = {
"city-pricing": lambda row: (row.get("venue_count") or 0) < 3,
"city-cost-de": lambda row: (row.get("data_confidence") or 0) < 1.0,
"country-overview": lambda row: (row.get("total_venues") or 0) < 5,
}
_REQUIRED_FRONTMATTER = {
"name", "slug", "content_type", "data_table",
"natural_key", "languages", "url_pattern", "title_pattern",
@@ -499,25 +507,31 @@ async def generate_articles(
md_dir.mkdir(parents=True, exist_ok=True)
(md_dir / f"{article_slug}.md").write_text(body_md)
# Evaluate noindex threshold for this template + data row.
_threshold = NOINDEX_THRESHOLDS.get(slug)
should_noindex = 1 if _threshold and _threshold(row) else 0
# Upsert article — keyed by (url_path, language).
# Single statement: no SELECT round-trip, no per-row commit.
await db.execute(
"""INSERT INTO articles
(url_path, slug, title, meta_description, country, region,
status, published_at, template_slug, language, date_modified,
seo_head, created_at)
VALUES (?, ?, ?, ?, ?, ?, 'published', ?, ?, ?, ?, ?, ?)
seo_head, noindex, created_at)
VALUES (?, ?, ?, ?, ?, ?, 'published', ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(url_path, language) DO UPDATE SET
title = excluded.title,
meta_description = excluded.meta_description,
template_slug = excluded.template_slug,
date_modified = excluded.date_modified,
seo_head = excluded.seo_head,
noindex = excluded.noindex,
updated_at = excluded.date_modified""",
(
url_path, article_slug, title, meta_desc,
row.get("country", ""), row.get("region", ""),
publish_dt, slug, lang, now_iso, seo_head, now_iso,
publish_dt, slug, lang, now_iso, seo_head,
should_noindex, now_iso,
),
)

View File

@@ -3,6 +3,7 @@
{% block title %}{{ article.title }} - {{ config.APP_NAME }}{% endblock %}
{% block head %}
{% if article.noindex %}<meta name="robots" content="noindex, follow">{% endif %}
<meta name="description" content="{{ article.meta_description or '' }}">
<meta property="og:title" content="{{ article.title }}">
<meta property="og:description" content="{{ article.meta_description or '' }}">

View File

@@ -0,0 +1,15 @@
"""Migration 0025: Add follow_up_at to suppliers + noindex to articles.
follow_up_at: ISO date string (YYYY-MM-DD) for scheduled follow-up reminders.
NULL = no follow-up scheduled.
noindex: 1 = search engines should not index this article (thin/insufficient data).
0 = indexable (default). Set at generation time by NOINDEX_THRESHOLDS.
"""
def up(conn) -> None:
# Scheduled follow-up date for outreach pipeline suppliers.
conn.execute("ALTER TABLE suppliers ADD COLUMN follow_up_at TEXT DEFAULT NULL")
# Prevent indexing of articles with insufficient data.
conn.execute("ALTER TABLE articles ADD COLUMN noindex INTEGER NOT NULL DEFAULT 0")

View File

@@ -68,11 +68,12 @@ async def _generate_sitemap_xml(base_url: str) -> str:
# Billing pricing — no lang prefix, no hreflang
entries.append(_url_entry(f"{base}/billing/pricing", []))
# Published articles — both lang variants with accurate lastmod
# Published articles — both lang variants with accurate lastmod.
# Exclude noindex articles (thin data) to keep sitemap signal-dense.
articles = await fetch_all(
"""SELECT url_path, COALESCE(updated_at, published_at) AS lastmod
FROM articles
WHERE status = 'published' AND published_at <= datetime('now')
WHERE status = 'published' AND noindex = 0 AND published_at <= datetime('now')
ORDER BY published_at DESC
LIMIT 25000"""
)

196
web/tests/test_noindex.py Normal file
View File

@@ -0,0 +1,196 @@
"""
Tests for pSEO article noindex feature.
Covers:
- NOINDEX_THRESHOLDS: lambda functions evaluate correctly per template
- Sitemap excludes articles with noindex=1
- Article detail page emits <meta name="robots" content="noindex, follow"> for noindex articles
- Article detail page has no robots meta tag for indexable articles
"""
from datetime import UTC, datetime
from unittest.mock import AsyncMock, patch
import pytest
from padelnomics import core
from padelnomics.content import NOINDEX_THRESHOLDS
# ── Threshold unit tests ─────────────────────────────────────────────────────
class TestNoindexThresholds:
def test_city_pricing_low_venue_count_is_noindex(self):
check = NOINDEX_THRESHOLDS["city-pricing"]
assert check({"venue_count": 0}) is True
assert check({"venue_count": 1}) is True
assert check({"venue_count": 2}) is True
def test_city_pricing_sufficient_venues_is_indexable(self):
check = NOINDEX_THRESHOLDS["city-pricing"]
assert check({"venue_count": 3}) is False
assert check({"venue_count": 10}) is False
def test_city_pricing_missing_venue_count_treated_as_zero(self):
check = NOINDEX_THRESHOLDS["city-pricing"]
assert check({}) is True
assert check({"venue_count": None}) is True
def test_city_cost_de_partial_data_is_noindex(self):
check = NOINDEX_THRESHOLDS["city-cost-de"]
assert check({"data_confidence": 0.0}) is True
assert check({"data_confidence": 0.5}) is True
assert check({"data_confidence": 0.99}) is True
def test_city_cost_de_full_confidence_is_indexable(self):
check = NOINDEX_THRESHOLDS["city-cost-de"]
assert check({"data_confidence": 1.0}) is False
def test_city_cost_de_missing_confidence_is_noindex(self):
check = NOINDEX_THRESHOLDS["city-cost-de"]
assert check({}) is True
assert check({"data_confidence": None}) is True
def test_country_overview_low_venues_is_noindex(self):
check = NOINDEX_THRESHOLDS["country-overview"]
assert check({"total_venues": 0}) is True
assert check({"total_venues": 4}) is True
def test_country_overview_sufficient_venues_is_indexable(self):
check = NOINDEX_THRESHOLDS["country-overview"]
assert check({"total_venues": 5}) is False
assert check({"total_venues": 100}) is False
def test_unknown_template_slug_has_no_threshold(self):
assert "manual" not in NOINDEX_THRESHOLDS
assert "unknown-template" not in NOINDEX_THRESHOLDS
# ── Sitemap exclusion ────────────────────────────────────────────────────────
async def _insert_article(
db,
url_path: str = "/markets/de/berlin",
title: str = "Test Article",
language: str = "en",
noindex: int = 0,
) -> int:
"""Insert a published article row and return its id."""
# Use a past published_at in SQLite-compatible format (space separator, no tz).
# SQLite's datetime('now') returns "YYYY-MM-DD HH:MM:SS" with a space.
# ISO format with T is lexicographically AFTER the space format for the
# same instant, so current-time ISO strings fail the <= datetime('now') check.
published_at = "2020-01-01 08:00:00"
created_at = datetime.now(UTC).isoformat()
async with db.execute(
"""INSERT INTO articles
(url_path, slug, title, meta_description, status, published_at,
template_slug, language, noindex, created_at)
VALUES (?, ?, ?, '', 'published', ?, 'city-pricing', ?, ?, ?)""",
(url_path, f"slug-{url_path.replace('/', '-')}", title,
published_at, language, noindex, created_at),
) as cursor:
article_id = cursor.lastrowid
await db.commit()
return article_id
class TestSitemapNoindex:
async def test_indexable_article_in_sitemap(self, client, db):
"""Article with noindex=0 should appear in sitemap."""
await _insert_article(db, url_path="/markets/en/berlin", noindex=0)
resp = await client.get("/sitemap.xml")
xml = (await resp.data).decode()
assert "/markets/en/berlin" in xml
async def test_noindex_article_excluded_from_sitemap(self, client, db):
"""Article with noindex=1 must NOT appear in sitemap."""
await _insert_article(db, url_path="/markets/en/thin-city", noindex=1)
resp = await client.get("/sitemap.xml")
xml = (await resp.data).decode()
assert "/markets/en/thin-city" not in xml
async def test_mixed_articles_only_indexable_in_sitemap(self, client, db):
"""Only indexable articles appear; noindex articles are silently dropped."""
await _insert_article(db, url_path="/markets/en/good-city", noindex=0)
await _insert_article(db, url_path="/markets/en/bad-city", noindex=1)
resp = await client.get("/sitemap.xml")
xml = (await resp.data).decode()
assert "good-city" in xml
assert "bad-city" not in xml
# ── Article detail robots meta tag ──────────────────────────────────────────
class TestArticleDetailRobotsTag:
"""
Test that the article detail template emits (or omits) the robots meta tag.
We test via the content blueprint's article route.
Routes.py imports BUILD_DIR from content/__init__.py at module load time, so
we must patch padelnomics.content.routes.BUILD_DIR (the local binding), not
padelnomics.content.BUILD_DIR.
"""
async def test_noindex_article_has_robots_meta(self, client, db, tmp_path, monkeypatch):
"""Article with noindex=1 → <meta name="robots" content="noindex, follow"> in HTML."""
import padelnomics.content.routes as routes_mod
build_dir = tmp_path / "en"
build_dir.mkdir(parents=True)
url_path = "/markets/noindex-test"
slug = "city-pricing-en-noindex-test"
(build_dir / f"{slug}.html").write_text("<p>Article body</p>")
monkeypatch.setattr(routes_mod, "BUILD_DIR", tmp_path)
# Use past published_at in SQLite space-separator format
async with db.execute(
"""INSERT INTO articles
(url_path, slug, title, meta_description, status, published_at,
template_slug, language, noindex, created_at)
VALUES (?, ?, 'Noindex Test', '', 'published', '2020-01-01 08:00:00',
'city-pricing', 'en', 1, datetime('now'))""",
(url_path, slug),
) as cursor:
pass
await db.commit()
resp = await client.get(f"/en{url_path}")
assert resp.status_code == 200
html = (await resp.data).decode()
assert 'name="robots"' in html
assert "noindex" in html
async def test_indexable_article_has_no_robots_meta(self, client, db, tmp_path, monkeypatch):
"""Article with noindex=0 → no robots meta tag in HTML."""
import padelnomics.content.routes as routes_mod
build_dir = tmp_path / "en"
build_dir.mkdir(parents=True)
url_path = "/markets/indexable-test"
slug = "city-pricing-en-indexable-test"
(build_dir / f"{slug}.html").write_text("<p>Article body</p>")
monkeypatch.setattr(routes_mod, "BUILD_DIR", tmp_path)
async with db.execute(
"""INSERT INTO articles
(url_path, slug, title, meta_description, status, published_at,
template_slug, language, noindex, created_at)
VALUES (?, ?, 'Indexable Test', '', 'published', '2020-01-01 08:00:00',
'city-pricing', 'en', 0, datetime('now'))""",
(url_path, slug),
) as cursor:
pass
await db.commit()
resp = await client.get(f"/en{url_path}")
assert resp.status_code == 200
html = (await resp.data).decode()
assert 'content="noindex' not in html

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