stage-31: Newsline polish — featured case, time buckets, severity accents, generated visuals
The home page goes from a flat event stream to something that reads like a news blog: - Featured-case hero card at the top, picked as the highest-severity case (CRITICAL > HIGH, tie-break recency) from the last 7 days. Wide, with a procedurally generated SVG hero behind a gradient overlay that carries title + severity + TLP + feed + ingest time. - Recent activity is now grouped under Today / Yesterday / Earlier this week / Older bucket headers. - Each item gets a left-border severity accent (red CRITICAL, amber HIGH, muted MEDIUM/LOW) so the page is scannable at a glance. Images: new cockpit/case_visuals.py generates SVGs from case data — zero external image gen, zero curated assets. Every visual is deterministic from case_id (so a case keeps its identity across sessions) and themed to its severity: - case_hero_svg() — 880x220 hero with severity radial glow, a faint scan grid, a particle constellation with auto-connecting lines, HUD corner brackets, and the case id whispered in the bottom-right. - case_glyph_svg() — small mirror-symmetric identicon (5-grid), severity-colored, shown beside each case news item in place of an emoji icon. Two case_ids → two distinct glyphs; same id → same glyph. 7 news tests pass; visual sanity print confirms hero is deterministic and uses the right severity accent. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,7 @@ from fastapi.templating import Jinja2Templates
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from psyc import db, log
|
||||
from psyc.cockpit import adminauth, docker_view, inference, journey as journey_view
|
||||
from psyc.cockpit import adminauth, case_visuals, docker_view, inference, journey as journey_view
|
||||
from psyc.lines import courier as courier_line
|
||||
from psyc.lines import ledger as ledger_line
|
||||
from psyc.lines import lookup as lookup_line
|
||||
@@ -42,13 +42,24 @@ def _admin_ok(request: Request) -> bool:
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def index(request: Request) -> HTMLResponse:
|
||||
items = news_line.recent_items(limit=40)
|
||||
featured = news_line.featured_case()
|
||||
case_index: dict = {}
|
||||
for i in items:
|
||||
if i.kind == "case" and i.case_id and i.case_id not in case_index:
|
||||
got = db.get_case(i.case_id)
|
||||
if not isinstance(got, Err):
|
||||
case_index[i.case_id] = case_visuals.case_glyph_svg(got.value)
|
||||
return TEMPLATES.TemplateResponse(
|
||||
request,
|
||||
"home.html",
|
||||
{
|
||||
"kpis": news_line.kpis(),
|
||||
"items": news_line.recent_items(limit=24),
|
||||
"buckets": news_line.bucket_items(items),
|
||||
"feeds": news_line.feed_health(),
|
||||
"featured": featured,
|
||||
"featured_hero": case_visuals.case_hero_svg(featured) if featured else "",
|
||||
"case_glyphs": case_index,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
166
src/psyc/cockpit/case_visuals.py
Normal file
166
src/psyc/cockpit/case_visuals.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""Procedural SVG visuals derived from case data.
|
||||
|
||||
Zero external image-gen, zero curated assets — every visual is generated
|
||||
server-side from the case_id (deterministic per case) and severity. Cyber-HUD
|
||||
aesthetic, theme-coordinated with cockpit.css.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import math
|
||||
from typing import Optional
|
||||
|
||||
from psyc.models import Case, Severity
|
||||
|
||||
|
||||
_SEV_ACCENT = {
|
||||
Severity.CRITICAL: "#f87171",
|
||||
Severity.HIGH: "#fbbf24",
|
||||
Severity.MEDIUM: "#1ec8ff",
|
||||
Severity.LOW: "#7d8597",
|
||||
}
|
||||
_DEFAULT_ACCENT = "#1ec8ff"
|
||||
|
||||
|
||||
def _seed(case_id: str) -> int:
|
||||
"""Stable 32-bit int seed from a string — deterministic per case."""
|
||||
return int.from_bytes(hashlib.sha256(case_id.encode()).digest()[:4], "big")
|
||||
|
||||
|
||||
def _prng(seed: int):
|
||||
"""Mulberry32 — small fast deterministic PRNG. Yields floats in [0,1)."""
|
||||
state = [seed & 0xFFFFFFFF]
|
||||
def nxt():
|
||||
state[0] = (state[0] + 0x6D2B79F5) & 0xFFFFFFFF
|
||||
t = state[0]
|
||||
t = ((t ^ (t >> 15)) * (t | 1)) & 0xFFFFFFFF
|
||||
t ^= (t + ((t ^ (t >> 7)) * (t | 61))) & 0xFFFFFFFF
|
||||
return ((t ^ (t >> 14)) & 0xFFFFFFFF) / 4294967296.0
|
||||
return nxt
|
||||
|
||||
|
||||
def _accent(case: Optional[Case]) -> str:
|
||||
if case and case.classification.severity:
|
||||
return _SEV_ACCENT.get(case.classification.severity, _DEFAULT_ACCENT)
|
||||
return _DEFAULT_ACCENT
|
||||
|
||||
|
||||
# ---------- hero SVG (featured card) -----------------------------------
|
||||
|
||||
def case_hero_svg(case: Case, width: int = 880, height: int = 220) -> str:
|
||||
"""Wide SVG for the featured-case hero. Particle constellation + severity glow."""
|
||||
seed = _seed(case.case_id)
|
||||
rng = _prng(seed)
|
||||
accent = _accent(case)
|
||||
sid = f"h{seed:x}"
|
||||
|
||||
parts = []
|
||||
parts.append(
|
||||
f'<defs>'
|
||||
f'<linearGradient id="bg-{sid}" x1="0" y1="0" x2="1" y2="1">'
|
||||
f'<stop offset="0%" stop-color="#0f1115"/><stop offset="100%" stop-color="#1c2230"/>'
|
||||
f'</linearGradient>'
|
||||
f'<radialGradient id="glow-{sid}" cx="78%" cy="50%" r="60%">'
|
||||
f'<stop offset="0%" stop-color="{accent}" stop-opacity="0.42"/>'
|
||||
f'<stop offset="55%" stop-color="{accent}" stop-opacity="0.08"/>'
|
||||
f'<stop offset="100%" stop-color="{accent}" stop-opacity="0"/>'
|
||||
f'</radialGradient>'
|
||||
f'<radialGradient id="grain-{sid}" cx="20%" cy="35%" r="60%">'
|
||||
f'<stop offset="0%" stop-color="#1ec8ff" stop-opacity="0.10"/>'
|
||||
f'<stop offset="100%" stop-color="#1ec8ff" stop-opacity="0"/>'
|
||||
f'</radialGradient>'
|
||||
f'</defs>'
|
||||
)
|
||||
parts.append(f'<rect width="{width}" height="{height}" fill="url(#bg-{sid})"/>')
|
||||
parts.append(f'<rect width="{width}" height="{height}" fill="url(#grain-{sid})"/>')
|
||||
parts.append(f'<rect width="{width}" height="{height}" fill="url(#glow-{sid})"/>')
|
||||
|
||||
# Faint scan grid
|
||||
for x in range(0, width, 44):
|
||||
parts.append(f'<line x1="{x}" y1="0" x2="{x}" y2="{height}" stroke="{accent}" stroke-opacity="0.04"/>')
|
||||
for y in range(0, height, 44):
|
||||
parts.append(f'<line x1="0" y1="{y}" x2="{width}" y2="{y}" stroke="{accent}" stroke-opacity="0.04"/>')
|
||||
|
||||
# Particle field
|
||||
n = 40
|
||||
pts = [(rng() * width, rng() * height, 1 + rng() * 3, 0.3 + rng() * 0.55) for _ in range(n)]
|
||||
|
||||
# Connect close particles
|
||||
for i in range(n):
|
||||
ax, ay, *_ = pts[i]
|
||||
for j in range(i + 1, n):
|
||||
bx, by, *_ = pts[j]
|
||||
d = math.hypot(bx - ax, by - ay)
|
||||
if d < 90:
|
||||
op = (1 - d / 90) * 0.20
|
||||
parts.append(
|
||||
f'<line x1="{ax:.1f}" y1="{ay:.1f}" x2="{bx:.1f}" y2="{by:.1f}" '
|
||||
f'stroke="{accent}" stroke-width="0.6" stroke-opacity="{op:.2f}"/>'
|
||||
)
|
||||
|
||||
for x, y, s, op in pts:
|
||||
if rng() < 0.7:
|
||||
parts.append(f'<circle cx="{x:.1f}" cy="{y:.1f}" r="{s:.1f}" fill="{accent}" fill-opacity="{op:.2f}"/>')
|
||||
else:
|
||||
parts.append(
|
||||
f'<rect x="{x - s:.1f}" y="{y - s:.1f}" width="{s * 2:.1f}" height="{s * 2:.1f}" '
|
||||
f'fill="{accent}" fill-opacity="{op:.2f}" transform="rotate(45 {x:.1f} {y:.1f})"/>'
|
||||
)
|
||||
|
||||
# HUD corner brackets
|
||||
for cx, cy, dx, dy in [
|
||||
(12, 12, 1, 1), (width - 12, 12, -1, 1),
|
||||
(12, height - 12, 1, -1), (width - 12, height - 12, -1, -1),
|
||||
]:
|
||||
parts.append(
|
||||
f'<polyline points="{cx + dx*18},{cy} {cx},{cy} {cx},{cy + dy*18}" '
|
||||
f'fill="none" stroke="{accent}" stroke-width="1.6" stroke-opacity="0.55"/>'
|
||||
)
|
||||
|
||||
# Ornamental case id, very faint
|
||||
parts.append(
|
||||
f'<text x="{width - 14}" y="{height - 12}" text-anchor="end" '
|
||||
f'fill="{accent}" fill-opacity="0.22" font-family="ui-monospace,Menlo,monospace" font-size="11">'
|
||||
f'{case.case_id}</text>'
|
||||
)
|
||||
|
||||
return (
|
||||
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {width} {height}" '
|
||||
f'preserveAspectRatio="xMidYMid slice" class="case-hero-svg" aria-hidden="true">'
|
||||
+ "".join(parts) + "</svg>"
|
||||
)
|
||||
|
||||
|
||||
# ---------- glyph SVG (news list items) ---------------------------------
|
||||
|
||||
def case_glyph_svg(case: Case, size: int = 36) -> str:
|
||||
"""A small identicon-like SVG glyph for a case — symmetric, severity-colored."""
|
||||
seed = _seed(case.case_id)
|
||||
rng = _prng(seed)
|
||||
accent = _accent(case)
|
||||
grid = 5
|
||||
cell = size / grid
|
||||
parts = [f'<rect width="{size}" height="{size}" fill="#1c2230" rx="7"/>']
|
||||
pad = 3
|
||||
# mirror-symmetric pattern
|
||||
for cy in range(grid):
|
||||
for cx in range((grid + 1) // 2):
|
||||
if rng() < 0.55:
|
||||
op = 0.45 + rng() * 0.45
|
||||
x = cx * cell + pad / 2
|
||||
y = cy * cell + pad / 2
|
||||
w = cell - pad
|
||||
h = cell - pad
|
||||
parts.append(f'<rect x="{x:.1f}" y="{y:.1f}" width="{w:.1f}" height="{h:.1f}" '
|
||||
f'fill="{accent}" fill-opacity="{op:.2f}" rx="1"/>')
|
||||
# mirror
|
||||
mx = (grid - 1 - cx) * cell + pad / 2
|
||||
if cx != grid - 1 - cx:
|
||||
parts.append(f'<rect x="{mx:.1f}" y="{y:.1f}" width="{w:.1f}" height="{h:.1f}" '
|
||||
f'fill="{accent}" fill-opacity="{op:.2f}" rx="1"/>')
|
||||
return (
|
||||
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {size} {size}" '
|
||||
f'width="{size}" height="{size}" class="case-glyph-svg" aria-hidden="true">'
|
||||
+ "".join(parts) + "</svg>"
|
||||
)
|
||||
@@ -867,3 +867,63 @@ body.wide .net-grid { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr
|
||||
.model-status { display: none; }
|
||||
.brand-icon { height: 32px; width: 100px; }
|
||||
}
|
||||
|
||||
/* ── featured case hero ────────────────────────────────────── */
|
||||
.featured {
|
||||
position: relative; margin: 0 0 18px;
|
||||
border: 1px solid var(--border); border-radius: 14px; overflow: hidden;
|
||||
background: var(--panel-2);
|
||||
box-shadow: 0 6px 30px rgba(0,0,0,0.45);
|
||||
transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s;
|
||||
}
|
||||
.featured.sev-critical { border-color: var(--red); box-shadow: 0 6px 30px rgba(248,113,113,0.18); }
|
||||
.featured.sev-high { border-color: var(--amber); box-shadow: 0 6px 30px rgba(251,191,36,0.16); }
|
||||
.featured:hover { transform: translateY(-1px); box-shadow: 0 10px 36px rgba(30,200,255,0.18); }
|
||||
.featured-link { display: block; color: inherit; text-decoration: none; position: relative; }
|
||||
.featured-link:hover { text-decoration: none; }
|
||||
.featured-hero { position: relative; aspect-ratio: 880 / 220; max-height: 240px; overflow: hidden; }
|
||||
.featured-hero svg { width: 100%; height: 100%; display: block; }
|
||||
.featured-overlay {
|
||||
position: absolute; left: 0; right: 0; bottom: 0;
|
||||
padding: 18px 22px 18px;
|
||||
background: linear-gradient(180deg, transparent, rgba(15,17,21,0.85) 55%);
|
||||
}
|
||||
.featured-tag {
|
||||
font-family: var(--font-display); font-size: 10px; letter-spacing: 0.22em;
|
||||
color: var(--accent); text-transform: uppercase; opacity: 0.85;
|
||||
text-shadow: 0 0 12px var(--accent-glow);
|
||||
}
|
||||
.featured.sev-critical .featured-tag { color: var(--red); text-shadow: 0 0 12px rgba(248,113,113,0.45); }
|
||||
.featured.sev-high .featured-tag { color: var(--amber); text-shadow: 0 0 12px rgba(251,191,36,0.45); }
|
||||
.featured-title {
|
||||
margin: 6px 0 8px; font-size: clamp(18px, 2.4vw, 24px); color: #eaf6ff;
|
||||
text-shadow: 0 0 18px rgba(0,0,0,0.6);
|
||||
}
|
||||
.featured-meta { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; font-size: 12px; }
|
||||
.featured-meta .muted { color: var(--muted); font-family: ui-monospace, Menlo, monospace; font-size: 11px; }
|
||||
@media (max-width: 720px) {
|
||||
.featured-hero { aspect-ratio: 16 / 9; }
|
||||
.featured-overlay { padding: 12px 14px; }
|
||||
}
|
||||
|
||||
/* ── time-bucket headers ───────────────────────────────────── */
|
||||
.bucket-head {
|
||||
display: flex; align-items: baseline; gap: 10px;
|
||||
font-family: var(--font-display); font-size: 13px;
|
||||
letter-spacing: 0.12em; text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
margin: 18px 0 6px; padding-bottom: 4px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
.bucket-head:first-of-type { margin-top: 12px; }
|
||||
.bucket-count { font-family: ui-monospace, Menlo, monospace; color: var(--accent); font-size: 12px; letter-spacing: 0; }
|
||||
|
||||
/* ── per-item severity accent + glyph ──────────────────────── */
|
||||
.news-item { border-left: 3px solid transparent; padding-left: 10px; transition: background 0.12s; }
|
||||
.news-item:hover { background: rgba(30,200,255,0.04); }
|
||||
.news-item.sev-critical { border-left-color: var(--red); }
|
||||
.news-item.sev-high { border-left-color: var(--amber); }
|
||||
.news-item.sev-medium { border-left-color: #fde68a; }
|
||||
.news-item.sev-low { border-left-color: var(--muted); }
|
||||
.news-item .news-icon { background: transparent; border: none; padding: 0; }
|
||||
.news-item .news-icon .case-glyph-svg { display: block; border: 1px solid var(--border); border-radius: 7px; }
|
||||
|
||||
@@ -37,34 +37,63 @@
|
||||
</a>
|
||||
</section>
|
||||
|
||||
{% if featured %}
|
||||
<section class="featured sev-{{ featured.classification.severity.value }}">
|
||||
<a class="featured-link" href="/cases/{{ featured.case_id }}">
|
||||
<div class="featured-hero">{{ featured_hero|safe }}</div>
|
||||
<div class="featured-overlay">
|
||||
<div class="featured-tag">⌖ FEATURED THREAT</div>
|
||||
<h2 class="featured-title">{{ featured.summary }}</h2>
|
||||
<div class="featured-meta">
|
||||
<span class="sev-badge">{{ featured.classification.severity.value if featured.classification.severity else '—' }}</span>
|
||||
<span class="tlp-badge tlp-{{ featured.classification.tlp.value }}">{{ featured.classification.tlp.value }}</span>
|
||||
<span class="muted">from {{ featured.source_metadata.feed or 'unknown' }}</span>
|
||||
<span class="muted">ingested {{ featured.ingested_at.strftime('%Y-%m-%d %H:%M') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<div class="home-grid">
|
||||
<section class="panel home-news">
|
||||
<div class="panel-head">
|
||||
<h2>Recent activity</h2>
|
||||
<span class="count">{{ items|length }} items</span>
|
||||
<span class="count">{{ buckets|sum(attribute='items')|length }} items</span>
|
||||
</div>
|
||||
<p class="page-intro">Live feed of what psyc has detected and what it has done about it.</p>
|
||||
{% if not items %}
|
||||
|
||||
{% if not buckets %}
|
||||
<p class="empty">Nothing recent yet — start with <code>psyc fetch-all</code>.</p>
|
||||
{% endif %}
|
||||
<ol class="news-list">
|
||||
{% for i in items %}
|
||||
<li class="news-item news-kind-{{ i.kind }}{% if i.severity %} sev-{{ i.severity }}{% endif %}">
|
||||
<div class="news-icon">{{ i.icon }}</div>
|
||||
<div class="news-body">
|
||||
<div class="news-head">
|
||||
<span class="news-headline">{{ i.headline }}</span>
|
||||
{% if i.severity %}<span class="sev-badge">{{ i.severity }}</span>{% endif %}
|
||||
|
||||
{% for b in buckets %}
|
||||
<h3 class="bucket-head">{{ b.label }} <span class="bucket-count">{{ b.items|length }}</span></h3>
|
||||
<ol class="news-list">
|
||||
{% for i in b.items %}
|
||||
<li class="news-item news-kind-{{ i.kind }}{% if i.severity %} sev-{{ i.severity }}{% endif %}">
|
||||
<div class="news-icon">
|
||||
{% if i.kind == 'case' and i.case_id and case_glyphs.get(i.case_id) %}
|
||||
{{ case_glyphs[i.case_id]|safe }}
|
||||
{% else %}
|
||||
{{ i.icon }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="news-sub">{{ i.body }}</div>
|
||||
<div class="news-meta">
|
||||
<time>{{ i.timestamp.strftime('%Y-%m-%d %H:%M') }}</time>
|
||||
{% if i.case_id %} · <a href="/cases/{{ i.case_id }}">{{ i.case_id }}</a>{% endif %}
|
||||
<div class="news-body">
|
||||
<div class="news-head">
|
||||
<span class="news-headline">{{ i.headline }}</span>
|
||||
{% if i.severity %}<span class="sev-badge">{{ i.severity }}</span>{% endif %}
|
||||
</div>
|
||||
<div class="news-sub">{{ i.body }}</div>
|
||||
<div class="news-meta">
|
||||
<time>{{ i.timestamp.strftime('%H:%M' if b.label == 'Today' else '%Y-%m-%d %H:%M') }}</time>
|
||||
{% if i.case_id %} · <a href="/cases/{{ i.case_id }}">{{ i.case_id }}</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
||||
<aside class="panel home-side">
|
||||
|
||||
@@ -108,6 +108,54 @@ def recent_items(limit: int = 30, high_only: bool = False) -> List[NewsItem]:
|
||||
|
||||
# ---------- feed health (sidebar / footer of home) -----------------------
|
||||
|
||||
class Bucket(BaseModel):
|
||||
label: str
|
||||
items: List[NewsItem]
|
||||
|
||||
|
||||
def bucket_items(items: List[NewsItem]) -> List[Bucket]:
|
||||
"""Group items into Today / Yesterday / Earlier this week / Older."""
|
||||
now = datetime.now(timezone.utc)
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
yesterday_start = today_start - timedelta(days=1)
|
||||
week_start = today_start - timedelta(days=7)
|
||||
order = ("Today", "Yesterday", "Earlier this week", "Older")
|
||||
buckets: Dict[str, List[NewsItem]] = {k: [] for k in order}
|
||||
for i in items:
|
||||
ts = i.timestamp
|
||||
if ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=timezone.utc)
|
||||
if ts >= today_start: buckets["Today"].append(i)
|
||||
elif ts >= yesterday_start: buckets["Yesterday"].append(i)
|
||||
elif ts >= week_start: buckets["Earlier this week"].append(i)
|
||||
else: buckets["Older"].append(i)
|
||||
return [Bucket(label=k, items=buckets[k]) for k in order if buckets[k]]
|
||||
|
||||
|
||||
_SEV_RANK = {Severity.CRITICAL: 3, Severity.HIGH: 2, Severity.MEDIUM: 1, Severity.LOW: 0}
|
||||
|
||||
|
||||
def featured_case() -> Optional[Case]:
|
||||
"""Pick a case to spotlight: highest-severity from the last 7 days,
|
||||
breaking ties by recency. Returns None if nothing HIGH+ in the window."""
|
||||
now = datetime.now(timezone.utc)
|
||||
week = now - timedelta(days=7)
|
||||
candidates: List[Case] = []
|
||||
for c in db.list_cases(limit=2000):
|
||||
ts = c.ingested_at
|
||||
if ts and ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=timezone.utc)
|
||||
if ts and ts >= week and c.classification.severity in (Severity.HIGH, Severity.CRITICAL):
|
||||
candidates.append(c)
|
||||
if not candidates:
|
||||
return None
|
||||
candidates.sort(
|
||||
key=lambda c: (_SEV_RANK.get(c.classification.severity, -1), c.ingested_at),
|
||||
reverse=True,
|
||||
)
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def feed_health() -> List[FeedHealth]:
|
||||
"""Per-feed counts + most-recent ingest. Useful as a 'sources live?' panel."""
|
||||
cases = db.list_cases(limit=10_000)
|
||||
|
||||
@@ -59,6 +59,35 @@ def test_feed_health_groups_by_feed(fresh_db):
|
||||
assert h[0].feed == "urlhaus"
|
||||
|
||||
|
||||
def test_bucket_items_groups_by_recency(fresh_db):
|
||||
from datetime import datetime, timedelta, timezone
|
||||
now = datetime.now(timezone.utc)
|
||||
items = [
|
||||
news.NewsItem(timestamp=now, kind="case", headline="t1", body="", icon="•"),
|
||||
news.NewsItem(timestamp=now - timedelta(days=1), kind="case", headline="t2", body="", icon="•"),
|
||||
news.NewsItem(timestamp=now - timedelta(days=3), kind="case", headline="t3", body="", icon="•"),
|
||||
news.NewsItem(timestamp=now - timedelta(days=14), kind="case", headline="t4", body="", icon="•"),
|
||||
]
|
||||
labels = [b.label for b in news.bucket_items(items)]
|
||||
# all four buckets should appear, in chronological order
|
||||
assert labels == ["Today", "Yesterday", "Earlier this week", "Older"]
|
||||
|
||||
|
||||
def test_featured_case_picks_highest_severity(fresh_db):
|
||||
db.upsert_case(make_case(feed="urlhaus", urls=["http://1.1.1.1/x"], severity=Severity.LOW, age_days=0))
|
||||
db.upsert_case(make_case(feed="urlhaus", urls=["http://2.2.2.2/x"], severity=Severity.HIGH, age_days=1))
|
||||
db.upsert_case(make_case(feed="urlhaus", urls=["http://3.3.3.3/x"], severity=Severity.CRITICAL, age_days=2))
|
||||
f = news.featured_case()
|
||||
assert f is not None
|
||||
assert f.classification.severity is Severity.CRITICAL
|
||||
|
||||
|
||||
def test_featured_case_none_when_nothing_high(fresh_db):
|
||||
db.upsert_case(make_case(feed="urlhaus", urls=["http://1.1.1.1/x"], severity=Severity.LOW, age_days=0))
|
||||
db.upsert_case(make_case(feed="urlhaus", urls=["http://2.2.2.2/x"], severity=Severity.MEDIUM, age_days=1))
|
||||
assert news.featured_case() is None
|
||||
|
||||
|
||||
def test_outcome_kinds_match_render_map(fresh_db):
|
||||
# Every Outcome should produce a NewsItem (no KeyError).
|
||||
c = make_case(feed="urlhaus", ips=["1.2.3.4"])
|
||||
|
||||
Reference in New Issue
Block a user