"""The Club Pulse report: findings in, one self-contained HTML file out. WHAT THIS IS. `pulse.analyse` turns four API reads into a plain dict of findings; this module turns that dict into a page a winery can open, read, print and forward. It is the last third of the example and the least interesting third on purpose — the subject of Club Pulse is the client code in `sipspos.py`, not the layout here. If you are copying this example, copy `sipspos.py`; this file exists so the example has somewhere to put its answer, and so that "renders a report" is a claim a test can check. It imports `html` and `datetime` and nothing else. No template engine: a templating dependency would put a `pip install` between a reader and the one file they came here to read, and the whole page is about two hundred lines of string building — small enough that the honest version is the readable one. TWO DECISIONS A READER WILL STOP AND QUESTION: 1. THERE IS NO JAVASCRIPT, AND NO SORTABLE TABLES OR COLLAPSIBLE SECTIONS BECAUSE OF IT. This file is not a web page in the usual sense; it is a document that gets emailed. Somebody at a winery opens it from a `file://` path, or in Outlook's preview pane, or forwards it to a partner who opens it on a phone — and every one of those contexts either strips scripts, blocks them, or runs them against a document with no server to talk to. A page whose content is assembled by script is blank in exactly the places this one has to survive. So every number is in the markup, the CSS is inline in a single `", "", "", "
"] # The banner goes FIRST, above the winery's own name, because its whole # job is to be read before anything else on the page is believed. out += _sandbox_banner(environment) out += _masthead(winery, environment, generated_at, totals) out += _contents() out += _at_risk_section(findings.get("at_risk") or []) out += _cohort_section(findings.get("cohorts") or []) out += _tier_section(findings.get("tiers") or []) out += _lost_section(findings.get("lost") or []) out += _footer(environment) out += ["
", "", "", ""] return "\n".join(out) # -- sections -------------------------------------------------------------- def _sandbox_banner(environment): """The sandbox warning, or nothing at all when this is real data. Only "test" produces a banner. A live report gets no banner because a warning that appears on every page is a warning nobody reads by the third one — the point of this block is that it is unusual. The environment is still stated in the masthead either way, so a reader who wants to check can always check. """ if environment != "test": return [] return [ "
", "Sandbox data — not your winery", "

This report was generated with a test API key, " "so every member, order and number below is invented sandbox data. " "Nobody named here is a real customer and nothing here is a real " "problem. Re-run with a live key to see the actual club.

", "
", ] def _masthead(winery, environment, generated_at, totals): """Title, provenance line, and the four numbers worth seeing first.""" # The environment is stated on EVERY report, live ones included. The # banner above answers "is this fake?" when the answer is yes; this line # answers "which is this?" for a reader who is holding two printouts. if environment == "test": env_line = ("Read from the sandbox (test API key) " "— invented data.") elif environment == "live": env_line = "Read from live data (live API key)." else: # None means no authenticated response carried the header. Saying # "unknown" is the honest answer; assuming "live" would be the # dangerous one and assuming "test" would be the useless one. env_line = ("Environment unknown — the API did " "not report one, so treat these numbers with care.") # A quiet list judged on register sales alone is a DIFFERENT question # from one that also saw the shop, and the difference decides whether a # name on this page is worth a phone call. Said in the provenance line # rather than left for the reader to infer from nothing. scope_line = "" if totals.get("orders_included") is False: scope_line = (" Online orders were not read " "(this key cannot see the shop), so \u201cquiet\u201d " "here means no register sale in the window.") return [ "
", f"

Club Pulse {MISSING} {_text(winery, 'Wine club')}

", f"

{_timestamp(generated_at)}. {env_line}" f"{scope_line}

", "", "
", ] def _total_item(value, label, *, numeric=True): """One headline number. The counts come from `totals` rather than from `len()` of the lists below them. They are the analysis's own answer, and if the two ever disagree the right place to find that out is a test of `analyse`, not a page that quietly recounts and papers over it. """ shown = _count(value) if numeric else value return (f"
  • {shown}" f"{label}
  • ") def _contents(): """Anchor links between sections. Plain `#fragment` links, which work from a `file://` path with no script and no server — which is the entire reason this page has no other navigation. Hidden when printed, where they are just underlined noise. """ return [ "", ] def _at_risk_section(at_risk): """The one section somebody acts on, grouped by reason, most urgent first. GROUPED RATHER THAN SORTED. A single table sorted by urgency would carry the same rows in the same order, and would make a reader compare each row against the one above it to work out where "call today" ends and "keep an eye on" begins. Headings do that work once. Each group also carries the count and the advice, so the first screen of this section answers "how many calls, and about what". """ out = ["

    Needs attention

    "] if not at_risk: out.append( "

    Nobody is at risk today. Every club member " "has a working card, an active subscription and a recent " "order.

    ") return out # "1 members" is the kind of small wrongness that makes a reader trust # the numbers above it less, and it costs one branch to avoid. plural = "member" if len(at_risk) == 1 else "members" out.append(f"

    {_count(len(at_risk))} {plural}, " f"most urgent first.

    ") # Bucket first, then walk REASON_ORDER — so the page order is fixed by # this module rather than by whatever order the analysis happened to # produce, and an unrecognised reason cannot silently disappear. buckets = {} for member in at_risk: buckets.setdefault((member or {}).get("reason"), []).append(member) for reason in REASON_ORDER: members = buckets.pop(reason, []) if members: out += _at_risk_group(reason, members) # Anything left is a reason this file has never heard of — one added to # `analyse` after this page was written. Printing it under its raw key # is ugly and correct: dropping members off a retention report because a # label is missing is the one outcome worth being ugly to avoid. for reason, members in buckets.items(): out += _at_risk_group(reason, members) return out def _at_risk_group(reason, members): """One reason: heading, count, advice, the field it came from, table.""" labels = REASON_LABELS.get(reason) or {} title = labels.get("title") or _text(reason, "Other") rows = [[_text(m.get("name"), "Unnamed member"), _text(m.get("email")), _text(m.get("detail"))] for m in (member or {} for member in members)] out = ["
    ", f"

    {title} · {_count(len(members))}

    "] if labels.get("advice"): out.append(f"

    {labels['advice']}

    ") if labels.get("field"): out.append(f"

    from {_text(labels['field'])}

    ") out += _table(["Member", "Email", "What's happening"], rows) out.append("
    ") return out def _cohort_section(cohorts): """Retention by join month: how many of each month's joiners are still in. Ascending by month, as `analyse` produces it — a reader scanning down is reading forwards in time, and the most recent cohort (the one with the least meaningful number, since it has had no time to churn) is at the bottom rather than leading the section. """ out = ["

    Retention by cohort

    "] if not cohorts: out.append( "

    No cohorts yet. This table fills in once " "members have a club start date behind them.

    ") return out rows = [] for cohort in (c or {} for c in cohorts): joined = cohort.get("joined") still = cohort.get("still_active") rows.append([_month(cohort.get("month")), _count(joined), _count(still), _retention(joined, still)]) out.append("

    Members grouped by the month they " "joined, and how many of them are still in the club.

    ") out.append("

    from club_since and is_club_member

    ") out += _table(["Joined in", "Joined", "Still active", "Retained"], rows, numeric_from=1) return out def _tier_section(tiers): """Tier mix and what each tier is worth per month.""" out = ["

    Tier mix

    "] if not tiers: out.append("

    No tiers to show.

    ") return out rows = [[_text(tier.get("name"), "Unknown tier"), _count(tier.get("members")), _money(tier.get("monthly_value_cents"), tier.get("currency"))] for tier in (t or {} for t in tiers)] out.append("

    from club_tier_id joined on " "/v1/club/tiers

    ") out += _table(["Tier", "Members", "Monthly value"], rows, numeric_from=1) return out def _lost_section(lost): """Members who cancelled — the only view of churn this API affords. A cancelled member is not in `/v1/club/members` at all (cancelling clears `club_status`), so these come from `/v1/customers?club_status=none` filtered to the ones that still carry a `club_since`. A retention dashboard that cannot show a loss is not one, which is why the section is here even though it is the least actionable one on the page. """ out = ["

    Lost members

    "] if not lost: out.append("

    No cancellations on record. Nothing to " "win back.

    ") return out rows = [[_text(member.get("name"), "Unnamed member"), _text(member.get("email")), _text(member.get("club_since"))] for member in (m or {} for m in lost)] out.append("

    from club_status == \"none\" with a " "club_since

    ") out += _table(["Member", "Email", "Club since"], rows) return out def _footer(environment): """Provenance, and the sandbox warning repeated for a printed second page. Somebody who prints this and reads page three has lost sight of the banner on page one, so the sandbox fact is stated again where the page ends. """ out = ["") return out # -- small helpers --------------------------------------------------------- def _text(value, missing=MISSING): """The ONLY way a value reaches the page, and it escapes. `quote=True` even for values landing between tags: this file has no attribute interpolation today, and the way that stops being true is somebody adding one and reusing this helper without noticing which mode it was in. Escaping the quotes always costs nothing here and removes the question. Emails are rendered as text, never as a `mailto:` link, and that is deliberate. An email address out of a winery's customer records is partner-supplied text; turning it into an `href` means escaping is no longer sufficient, because a URL is interpreted as well as displayed and `javascript:` is a valid-looking scheme. Validating a URL properly is more code than this example wants to carry and more code than a reader wants to audit, so the page shows the address and lets the reader copy it. """ if value is None: return missing text = html.escape(str(value), quote=True) return text if text.strip() else missing def _count(value): """An integer with thousands separators, or a dash if it isn't one.""" if isinstance(value, bool) or not isinstance(value, int): # Also catches None. A count the analysis did not produce shows as a # dash rather than as 0 — "we don't know" and "none" are different # answers, and only one of them means the club is fine. return MISSING return f"{value:,}" def _retention(joined, still_active): """`still_active / joined` as a percentage, guarding the empty cohort. A cohort with no joiners cannot have a retention rate, and 0 % would read as a catastrophe rather than as an absence. Divide-by-zero in a report generator is a crash at 6am on the one morning somebody needed the file. """ if not isinstance(joined, int) or isinstance(joined, bool) or joined <= 0: return MISSING if not isinstance(still_active, int) or isinstance(still_active, bool): return MISSING return f"{round(100 * still_active / joined)}%" def _money(cents, currency): """Integer cents to something a winery reads without decoding it. TWO DECIMAL PLACES, ALWAYS. SipsPOS stores money in minor units and this report assumes a hundred of them to the major unit, which is wrong for JPY and a handful of others. That is a known simplification rather than an oversight: the alternative is a table of currency exponents in a file whose subject is HTTP pagination, and a yen figure a hundred times too small is visibly wrong rather than quietly wrong. An unknown or missing currency prints the amount with no symbol instead of guessing dollars, because a euro figure with a dollar sign in front of it is the kind of error that survives review. """ if isinstance(cents, bool) or not isinstance(cents, (int, float)): return MISSING sign = "-" if cents < 0 else "" amount = f"{abs(cents) / 100:,.2f}" code = str(currency).strip().upper() if currency else "" if code in CURRENCY_SYMBOLS: return f"{sign}{CURRENCY_SYMBOLS[code]}{amount}" if code: return f"{sign}{amount} {_text(code)}" return f"{sign}{amount}" def _dominant_currency(totals): """The currency for the headline monthly figure. `totals` may carry one; if it does not, the figure is rendered bare rather than in a currency picked from the first tier that happened to have one. Summing several currencies into one number is already a question this example does not answer, and labelling that sum with one of their symbols would be answering it wrongly. """ return (totals or {}).get("currency") def _month(value): """"2026-03" to "March 2026", falling back to whatever we were given.""" try: year, month = str(value).split("-")[:2] year, month = int(year), int(month) if not 1 <= month <= 12: # Range-checked BEFORE indexing, because month 0 would otherwise # index MONTH_NAMES[-1] and confidently print December. raise ValueError(value) return f"{MONTH_NAMES[month - 1]} {year}" except (AttributeError, IndexError, TypeError, ValueError): # A month key this function cannot parse still names a real cohort # with real members in it, so it is printed as-is rather than dropped. return _text(value) def _timestamp(moment): """The generation time, in words, with the zone when there is one. A naive `datetime` is whatever clock the machine that ran this had, and this line does not claim otherwise — stamping "UTC" on a value that might be a laptop's local time is worse than saying nothing, because it invites somebody to subtract an offset from it. """ if not isinstance(moment, (datetime.datetime, datetime.date)): return f"Generated {_text(moment, 'at an unrecorded time')}" stamp = (f"{MONTH_NAMES[moment.month - 1]} {moment.day}, {moment.year}") if isinstance(moment, datetime.datetime): stamp += f" at {moment.hour:02d}:{moment.minute:02d}" zone = moment.tzname() if zone: stamp += f" {_text(zone)}" return f"Generated {stamp}" def _table(headers, rows, *, numeric_from=None): """A table. Cell values arrive ALREADY ESCAPED, by `_text` and friends. That is the one convention in this module worth stating out loud, because it is the inversion of the safer default. It exists so a cell can hold ` ` or `—` from `_money` and `_count` without those being escaped into visible ampersands — every producer of a cell in this file goes through `_text`, `_count`, `_money`, `_month` or `_retention`, and each of those escapes what it was given. If you add a column, route its value through one of them; do not interpolate a raw value here. `numeric_from` right-aligns every column from that index on, which is where the numbers live in all three tables that pass it. """ first_number = len(headers) if numeric_from is None else numeric_from def cls(index): return " class=\"num\"" if index >= first_number else "" out = ["", "", ""] out += [f"{_text(name)}" for i, name in enumerate(headers)] out += ["", "", ""] for row in rows: out.append("") out += [f"{cell}" for i, cell in enumerate(row)] out.append("") out += ["", "
    "] return out