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:
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
⏰ <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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) }}"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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' %}←{% else %}→{% 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>
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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 '' }}">
|
||||
|
||||
@@ -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")
|
||||
@@ -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
196
web/tests/test_noindex.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user