CUSTOM_PERIODS in report.py now drives _filtered/_period_label/ compute_period_stats for the 3 CTA phases (date ranges from the proposed plan), and the campaign-report title treatment applies to them the same way it does for July-September. Removed the app.py placeholder guards (reports tab + docx download) now that real period computation exists instead of an alert box, and fixed the docx filename label lookup to check all campaigns' periods, not just the old single flat list. Added Core Message (reused) and Roadmap (per-phase) abstracts to monthly_report_notes.py for the 3 phases - no executive_summary yet since the campaign hasn't run and isn't approved, so KPI/chart cards render on real, currently-empty data rather than fabricated findings.
424 lines
21 KiB
Python
424 lines
21 KiB
Python
"""Builds the End of Month Campaign Report as an editable Word (.docx) file: a single
|
|
condensed Executive Summary page per month (goal, verdict, the deep-dive's actual
|
|
findings in prose, Key Metrics table, Follower Growth chart) — not a multi-page
|
|
dump of every table/chart the live dashboard already has. Anyone who wants the full
|
|
detail (register tables, format breakdowns, competitor snapshot, every other chart)
|
|
goes to the live dashboard itself; this report is the leadership-facing takeaway, not
|
|
a duplicate of the dashboard.
|
|
|
|
Deliberately has zero import-time dependency on app.py — app.py is run directly
|
|
(`python3 app.py`), so it's registered in sys.modules as "__main__", not "app". If this
|
|
module did `import app`, Python wouldn't recognize that as the same module and would
|
|
re-execute app.py from scratch as a second copy, which is still mid-import when this
|
|
module's functions get called — a circular-import AttributeError. Passing everything
|
|
in as arguments avoids that entirely.
|
|
"""
|
|
import io
|
|
|
|
import pandas as pd
|
|
from docx import Document
|
|
from docx.shared import Cm, Pt, RGBColor
|
|
from docx.oxml.ns import qn
|
|
from docx.oxml import OxmlElement
|
|
|
|
import engagement_audit
|
|
|
|
|
|
# The 3-month branding campaign's fixed window (per "LinkedIn Branding Campaign
|
|
# Roadmap.pdf"), used only by the "campaign-end" timeframe below — distinct from
|
|
# "all", which would also pull in pre-campaign June data (BASELINE_DATE 2026-06-16).
|
|
CAMPAIGN_START = pd.Timestamp("2026-07-01")
|
|
CAMPAIGN_END = pd.Timestamp("2026-09-30")
|
|
|
|
# Custom (non-calendar-month) reporting periods, keyed the same way the Reports
|
|
# dropdown keys them. "campaign-end" isn't listed here since it's handled directly
|
|
# above - everything after that is the CTA Campaign's 3 phases (dates from the
|
|
# proposed plan, pending approval). Each period is a plain date range with its own
|
|
# label, not tied to a calendar month, since the CTA Campaign's own phases span
|
|
# across month boundaries (Dec 1-Jan 7) on purpose, unlike July/August/September.
|
|
CUSTOM_PERIODS = {
|
|
"cta-phase-1": (pd.Timestamp("2026-11-03"), pd.Timestamp("2026-11-26"),
|
|
"Nov 3 - Nov 26 (Failure Analysis)"),
|
|
"cta-phase-2": (pd.Timestamp("2026-12-01"), pd.Timestamp("2027-01-07"),
|
|
"Dec 1 - Jan 7 (Standardization)"),
|
|
"cta-phase-3": (pd.Timestamp("2027-01-12"), pd.Timestamp("2027-02-04"),
|
|
"Jan 12 - Feb 4 (WaveGate)"),
|
|
}
|
|
|
|
|
|
def _filtered(df, date_col, value):
|
|
if not value or value == "all":
|
|
return df
|
|
if value == "campaign-end":
|
|
return df[(df[date_col] >= CAMPAIGN_START) & (df[date_col] <= CAMPAIGN_END)]
|
|
if value in CUSTOM_PERIODS:
|
|
start, end, _ = CUSTOM_PERIODS[value]
|
|
return df[(df[date_col] >= start) & (df[date_col] <= end)]
|
|
if value.startswith("year-"):
|
|
year = int(value.split("-", 1)[1])
|
|
return df[df[date_col].dt.year == year]
|
|
year, month = (int(p) for p in value.split("-"))
|
|
return df[(df[date_col].dt.year == year) & (df[date_col].dt.month == month)]
|
|
|
|
|
|
def _period_label(value):
|
|
"""Friendly label for a timeframe_value, built directly from the "YYYY-MM"/"year-Y"
|
|
string itself so it works even for a future campaign month (e.g. September, selectable
|
|
ahead of time in the Reports tab) that has no export data yet."""
|
|
if not value or value == "all":
|
|
return "All Time"
|
|
if value == "campaign-end":
|
|
return "3-Month Campaign (Jul-Sep 2026)"
|
|
if value in CUSTOM_PERIODS:
|
|
return CUSTOM_PERIODS[value][2]
|
|
if value.startswith("year-"):
|
|
return value.split("-", 1)[1]
|
|
import calendar as calmod
|
|
try:
|
|
year, month = (int(p) for p in value.split("-"))
|
|
return f"{calmod.month_name[month]} {year}"
|
|
except ValueError:
|
|
return value
|
|
|
|
|
|
def compute_period_stats(ctx, timeframe_value="all"):
|
|
"""Filters D/posts down to timeframe_value and computes the headline numbers shared
|
|
by the PDF report and the live Reports tab, so both always show the same figures
|
|
instead of two independently-computed versions drifting apart."""
|
|
D = ctx["D"]
|
|
posts = ctx["posts"]
|
|
|
|
fts_f = _filtered(D["fts"], "Date", timeframe_value)
|
|
posts_f = _filtered(posts, "Created date", timeframe_value).copy()
|
|
cts_f = _filtered(D["cts"], "Date", timeframe_value)
|
|
vts_f = _filtered(D["vts"], "Date", timeframe_value)
|
|
|
|
if timeframe_value == "campaign-end":
|
|
# A campaign-wide "Posts Published" should mean genuine campaign content —
|
|
# unlike a single month's own count (which deliberately still counts a
|
|
# repost, "a record of what was published"), the capstone number excludes
|
|
# the repost and the one non-campaign post the same way every per-post
|
|
# performance read already does elsewhere.
|
|
excluded = (ctx.get("repost_dates") or set()) | (ctx.get("non_campaign_dates") or set())
|
|
if excluded:
|
|
posts_f = posts_f[~posts_f["Created date"].dt.strftime("%Y-%m-%d").isin(excluded)]
|
|
|
|
new_followers = int(fts_f["Total followers"].sum()) if not fts_f.empty else 0
|
|
total_impressions = int(cts_f["Impressions (total)"].sum()) if not cts_f.empty else 0
|
|
total_page_views = int(vts_f["Total page views (total)"].sum()) if not vts_f.empty else 0
|
|
posts_published = len(posts_f)
|
|
|
|
# Real Eng % (Audience-only, Team-excluded, hand-audited — see engagement_audit.py)
|
|
# computed once here so every downstream consumer of posts_f (live app tables/
|
|
# charts and this PDF) reads the same number instead of recomputing it separately.
|
|
# None for any post that hasn't been audited yet. "Best Post Real Eng." is the max
|
|
# among audited posts only — unaudited posts can't compete on a number they don't
|
|
# have, and a period with zero audited posts reports None (shown as "Pending audit"
|
|
# by callers) rather than silently falling back to the raw, Team-inflated figure.
|
|
if len(posts_f):
|
|
posts_f["Real Eng %"] = posts_f.apply(
|
|
lambda r: engagement_audit.real_eng_pct(
|
|
r["Created date"].strftime("%d/%m"), r["Impressions"], r.get("Clicks", 0)),
|
|
axis=1)
|
|
else:
|
|
posts_f["Real Eng %"] = pd.Series(dtype=float)
|
|
audited_eng = posts_f["Real Eng %"].dropna()
|
|
best_eng = float(audited_eng.max()) if not audited_eng.empty else None
|
|
|
|
avg_eng_series = cts_f["Engagement rate (total)"].replace(0, float("nan")) if not cts_f.empty else pd.Series(dtype=float)
|
|
# "Engagement rate (total)" is stored as a 0-1 fraction (confirmed against raw export
|
|
# data, max observed 5.9 i.e. 590%), not already a percentage like "Eng %"/"CTR %" —
|
|
# scale to percentage-point form so the "%" already in every display string is correct.
|
|
avg_eng = float(avg_eng_series.mean()) * 100 if not avg_eng_series.dropna().empty else 0.0
|
|
|
|
# Monthly Growth Rate: this period's net new followers as a percentage of the
|
|
# cumulative total the page already had at the start of the period, resets every
|
|
# month against its own starting count rather than a single fixed campaign baseline
|
|
# (e.g. July: 22 new / 85 at start of July = +25.9%). "campaign-end" anchors to
|
|
# the campaign's own start (CAMPAIGN_START) instead of a calendar month's start,
|
|
# giving one growth figure for the full 3 months. Undefined for "all"/a whole
|
|
# year, since "start of the period" isn't a clean concept there.
|
|
start_of_month_followers = None
|
|
monthly_growth_rate = None
|
|
first_day = None
|
|
if timeframe_value == "campaign-end":
|
|
first_day = CAMPAIGN_START
|
|
elif timeframe_value in CUSTOM_PERIODS:
|
|
first_day = CUSTOM_PERIODS[timeframe_value][0]
|
|
elif timeframe_value and timeframe_value != "all" and not timeframe_value.startswith("year-"):
|
|
year, month = (int(p) for p in timeframe_value.split("-"))
|
|
first_day = pd.Timestamp(year=year, month=month, day=1)
|
|
if first_day is not None:
|
|
prior = D["fts"][D["fts"]["Date"] < first_day]
|
|
if not prior.empty:
|
|
start_of_month_followers = int(prior["Cumulative Followers"].iloc[-1])
|
|
if start_of_month_followers:
|
|
monthly_growth_rate = (new_followers / start_of_month_followers) * 100
|
|
|
|
# Total Followers, pinned to the end of this specific reporting period (start +
|
|
# this period's own new followers) — not the live/global current_total_followers,
|
|
# so a report read weeks later still shows that month's own total rather than
|
|
# whatever the count has drifted to by the time someone opens it. None whenever
|
|
# start_of_month_followers is None (same "all"/year-level cases).
|
|
end_of_month_followers = (
|
|
start_of_month_followers + new_followers if start_of_month_followers is not None else None
|
|
)
|
|
|
|
return {
|
|
"fts_f": fts_f, "posts_f": posts_f, "cts_f": cts_f, "vts_f": vts_f,
|
|
"new_followers": new_followers, "total_impressions": total_impressions,
|
|
"total_page_views": total_page_views, "posts_published": posts_published,
|
|
"best_eng": best_eng, "avg_eng": avg_eng,
|
|
"start_of_month_followers": start_of_month_followers,
|
|
"end_of_month_followers": end_of_month_followers,
|
|
"monthly_growth_rate": monthly_growth_rate,
|
|
"period_label": _period_label(timeframe_value),
|
|
}
|
|
|
|
|
|
def _docx_shade(cell, hex_color):
|
|
"""python-docx has no cell-background property, Word's shading is buried in the
|
|
cell's own XML — this appends the <w:shd> element that does it."""
|
|
shd = OxmlElement("w:shd")
|
|
shd.set(qn("w:val"), "clear")
|
|
shd.set(qn("w:color"), "auto")
|
|
shd.set(qn("w:fill"), hex_color)
|
|
cell._tc.get_or_add_tcPr().append(shd)
|
|
|
|
|
|
def _docx_table(doc, rows, col_widths_cm=None, header_bg="F0F4F8", excluded_rows=None,
|
|
striped_bg=None, font_size=8):
|
|
"""rows[0] is the header. excluded_rows is a set of 1-indexed data-row positions
|
|
(matching the reportlab tables' own convention elsewhere in this file) that get
|
|
struck through and pale-red shaded, same treatment as the PDF/live dashboard use
|
|
for a repost/non-campaign post — visible, not silently dropped, but clearly marked."""
|
|
excluded_rows = excluded_rows or set()
|
|
table = doc.add_table(rows=0, cols=len(rows[0]))
|
|
table.style = "Table Grid"
|
|
for i, row_data in enumerate(rows):
|
|
row = table.add_row()
|
|
is_header = i == 0
|
|
is_excluded = i in excluded_rows
|
|
for j, val in enumerate(row_data):
|
|
cell = row.cells[j]
|
|
run = cell.paragraphs[0].add_run(str(val))
|
|
run.font.size = Pt(font_size)
|
|
if is_header:
|
|
run.bold = True
|
|
_docx_shade(cell, header_bg)
|
|
elif is_excluded:
|
|
run.font.strike = True
|
|
_docx_shade(cell, "FBDCDC")
|
|
elif striped_bg and i % 2 == 0:
|
|
_docx_shade(cell, striped_bg)
|
|
if col_widths_cm:
|
|
for j, w in enumerate(col_widths_cm):
|
|
if j < len(row.cells):
|
|
row.cells[j].width = Cm(w)
|
|
return table
|
|
|
|
|
|
def _docx_chart(doc, fig, width_cm=16, height_px=300, width_px=900):
|
|
buf = io.BytesIO()
|
|
fig.write_image(buf, format="png", width=width_px, height=height_px, scale=2)
|
|
buf.seek(0)
|
|
doc.add_picture(buf, width=Cm(width_cm))
|
|
|
|
|
|
|
|
def build_report_docx(ctx, timeframe_value="all"):
|
|
"""A single condensed Executive Summary page per month: goal, verdict, the deep
|
|
dive's actual findings in prose (not raw tables), the Key Metrics table, and the
|
|
Follower Growth chart. Everything else a full month's analysis touches on —
|
|
register tables, format breakdowns, competitor snapshot, every other chart —
|
|
lives in the live dashboard, not duplicated here; this is the leadership-facing
|
|
takeaway someone will actually read, not an export of the whole dashboard.
|
|
executive_summary (monthly_report_notes.py) supplies every month-specific fact;
|
|
this function is a pure renderer, reading the same compute_period_stats() data
|
|
the live Reports tab does so the two can never drift apart on the numbers."""
|
|
D = ctx["D"]
|
|
daily_followers_figure = ctx["daily_followers_figure"]
|
|
monthly_notes = ctx.get("monthly_notes")
|
|
directory_data = ctx.get("directory_data") or []
|
|
|
|
period = compute_period_stats(ctx, timeframe_value)
|
|
fts_f, posts_f = period["fts_f"], period["posts_f"]
|
|
new_followers = period["new_followers"]
|
|
total_impressions = period["total_impressions"]
|
|
total_page_views = period["total_page_views"]
|
|
posts_published = period["posts_published"]
|
|
best_eng = period["best_eng"]
|
|
avg_eng = period["avg_eng"]
|
|
monthly_growth_rate = period["monthly_growth_rate"]
|
|
start_of_month_followers = period["start_of_month_followers"]
|
|
end_of_month_followers = period["end_of_month_followers"]
|
|
period_label = period["period_label"]
|
|
|
|
doc = Document()
|
|
# python-docx's own default template omits w:zoom's required w:percent attribute
|
|
# (every Document() has this) — added here so the file passes strict OOXML
|
|
# validation instead of relying on Word's silent auto-repair.
|
|
zoom_el = doc.settings.element.find(qn("w:zoom"))
|
|
if zoom_el is not None and zoom_el.get(qn("w:percent")) is None:
|
|
zoom_el.set(qn("w:percent"), "100")
|
|
for section in doc.sections:
|
|
section.top_margin = Cm(1.5)
|
|
section.bottom_margin = Cm(1.5)
|
|
section.left_margin = Cm(1.8)
|
|
section.right_margin = Cm(1.8)
|
|
|
|
# Only July/August/September were part of the 3-month branding campaign
|
|
# (CAMPAIGN_START-CAMPAIGN_END) — a later month like October gets a plain
|
|
# title, not "...Campaign Report", since there's no active campaign stage
|
|
# (no goal_line/core_message/roadmap) behind it. CTA Campaign phases
|
|
# (CUSTOM_PERIODS) get the same campaign-report treatment once that
|
|
# campaign is approved and running.
|
|
is_campaign_month = timeframe_value in CUSTOM_PERIODS
|
|
if timeframe_value and timeframe_value != "all" and not timeframe_value.startswith("year-") \
|
|
and timeframe_value != "campaign-end" and timeframe_value not in CUSTOM_PERIODS:
|
|
try:
|
|
year, month = (int(p) for p in timeframe_value.split("-"))
|
|
month_start = pd.Timestamp(year=year, month=month, day=1)
|
|
is_campaign_month = CAMPAIGN_START <= month_start <= CAMPAIGN_END
|
|
except ValueError:
|
|
pass
|
|
|
|
if timeframe_value == "campaign-end":
|
|
title_text = "TXproducts — 3-Month Campaign Report (July-September 2026)"
|
|
elif monthly_notes and is_campaign_month:
|
|
title_text = f"{period_label} End of Month Campaign Report"
|
|
elif monthly_notes:
|
|
title_text = f"TXproducts — {period_label}"
|
|
else:
|
|
title_text = "TXproducts, LinkedIn Performance Report"
|
|
doc.add_heading(title_text, level=0)
|
|
|
|
exec_summary = (monthly_notes or {}).get("executive_summary")
|
|
if exec_summary:
|
|
doc.add_heading("Executive Summary", level=1)
|
|
goal_line = exec_summary.get("goal_line")
|
|
if goal_line:
|
|
doc.add_paragraph(goal_line)
|
|
|
|
met_p = doc.add_paragraph(style="List Bullet")
|
|
met_p.add_run("Goal Met: ").bold = True
|
|
met_run = met_p.add_run(str(exec_summary.get("goal_met", "—")))
|
|
met_run.bold = True
|
|
goal_met_val = str(exec_summary.get("goal_met", "")).strip().lower()
|
|
if goal_met_val == "yes":
|
|
met_run.font.color.rgb = RGBColor(0x2E, 0x7D, 0x32)
|
|
elif goal_met_val == "no":
|
|
met_run.font.color.rgb = RGBColor(0xC6, 0x28, 0x28)
|
|
elif goal_met_val == "partial":
|
|
met_run.font.color.rgb = RGBColor(0xB2, 0x6A, 0x00)
|
|
growth_note = exec_summary.get("growth_note")
|
|
if growth_note:
|
|
met_p.add_run(" Growth: ").bold = True
|
|
met_p.add_run(growth_note)
|
|
|
|
# --- Key Metrics table, no heading (flows directly from the Goal/Growth line,
|
|
# matching Mckinley's own edit) — a summary that leaves out the actual numbers
|
|
# isn't a summary. ---
|
|
kpi_rows = [
|
|
["Month Start", "New Followers", "Total Followers (month end)", "Posts Published",
|
|
"Total Impressions", "Total Page Views", "Best Post Real Eng.",
|
|
"Avg. Eng. Rate (raw, unaudited)", "Monthly Growth Rate"],
|
|
[f"{start_of_month_followers:,}" if start_of_month_followers is not None else "—",
|
|
f"{new_followers:,}",
|
|
f"{end_of_month_followers:,}" if end_of_month_followers is not None else "—",
|
|
f"{posts_published:,}",
|
|
f"{total_impressions:,}", f"{total_page_views:,}",
|
|
f"{best_eng:.1f}%" if best_eng is not None else "Pending audit",
|
|
f"{avg_eng:.2f}%" if avg_eng else "—",
|
|
f"{monthly_growth_rate:+.1f}%" if monthly_growth_rate is not None else "—"],
|
|
]
|
|
_docx_table(doc, kpi_rows, font_size=9)
|
|
|
|
if exec_summary:
|
|
engagement_question = exec_summary.get("engagement_question")
|
|
if engagement_question:
|
|
doc.add_paragraph(engagement_question)
|
|
for item in exec_summary.get("engagement_findings") or []:
|
|
doc.add_paragraph(item, style="List Bullet")
|
|
|
|
trilogy_intro = exec_summary.get("trilogy_intro")
|
|
if trilogy_intro:
|
|
doc.add_paragraph(trilogy_intro)
|
|
for item in exec_summary.get("trilogy_posts") or []:
|
|
doc.add_paragraph(item, style="List Bullet")
|
|
trilogy_closing = exec_summary.get("trilogy_closing")
|
|
if trilogy_closing:
|
|
doc.add_paragraph(trilogy_closing)
|
|
|
|
best_day_note = exec_summary.get("best_posting_day_note")
|
|
if best_day_note:
|
|
doc.add_paragraph(best_day_note)
|
|
best_day_caveat = exec_summary.get("best_posting_day_caveat")
|
|
if best_day_caveat:
|
|
p = doc.add_paragraph()
|
|
p.add_run(best_day_caveat).italic = True
|
|
|
|
validation_gap = exec_summary.get("validation_gap")
|
|
if validation_gap:
|
|
doc.add_paragraph(validation_gap, style="List Bullet")
|
|
|
|
comp_line = exec_summary.get("audience_composition_line")
|
|
if comp_line:
|
|
doc.add_paragraph(comp_line)
|
|
|
|
# campaign-end only: Sector/Function/Seniority/Country breakdown across all
|
|
# 3 months combined (followedSort in {2026-07, 2026-08, 2026-09}) — the
|
|
# monthly reports don't carry this table (Mckinley adds it by hand from the
|
|
# live Audience Composition page), but the campaign-end synthesis has no
|
|
# single month's page to pull it from, so it's generated here directly.
|
|
if timeframe_value == "campaign-end":
|
|
campaign_months = {"2026-07", "2026-08", "2026-09"}
|
|
cohort_rows = [d for d in directory_data if d.get("followedSort") in campaign_months]
|
|
if cohort_rows:
|
|
from collections import Counter
|
|
sector = Counter(d.get("sector", "Unknown") for d in cohort_rows)
|
|
function = Counter(d.get("function", "Unknown") for d in cohort_rows)
|
|
seniority = Counter(d.get("seniority", "Unknown") for d in cohort_rows)
|
|
country = Counter(d.get("country") or "Unknown/blank" for d in cohort_rows)
|
|
comp_table = doc.add_table(rows=1, cols=4)
|
|
comp_table.rows[0].cells[0].merge(comp_table.rows[0].cells[3])
|
|
comp_table.rows[0].cells[0].paragraphs[0].add_run(
|
|
f"Audience Composition ({len(cohort_rows)} new followers profiled, "
|
|
f"July-September combined)"
|
|
).bold = True
|
|
for label, counter in [("Sector", sector), ("Function", function),
|
|
("Seniority", seniority), ("Country", country)]:
|
|
doc.add_paragraph(label, style="Heading 2")
|
|
rows = [[label, "Count"]] + [[str(k), str(v)] for k, v in counter.most_common()]
|
|
_docx_table(doc, rows, font_size=8, header_bg="FDF6D8")
|
|
|
|
for dim_title, dim_text in exec_summary.get("audience_dimensions") or []:
|
|
p = doc.add_paragraph()
|
|
p.add_run(f"{dim_title}: ").bold = True
|
|
p.add_run(dim_text)
|
|
|
|
insights = exec_summary.get("insights") or []
|
|
if insights:
|
|
doc.add_heading("Insights", level=1)
|
|
for item in insights:
|
|
doc.add_paragraph(item, style="List Bullet")
|
|
|
|
recommendations = exec_summary.get("recommendations") or []
|
|
if recommendations:
|
|
doc.add_heading("Opportunities", level=1)
|
|
for item in recommendations:
|
|
doc.add_paragraph(item, style="List Bullet")
|
|
elif monthly_notes:
|
|
doc.add_paragraph("Executive Summary not written up yet for this month.")
|
|
|
|
if not fts_f.empty:
|
|
doc.add_heading("Follower Growth", level=1)
|
|
fig = daily_followers_figure(fts_f, posts_f)
|
|
fig.update_layout(showlegend=True, font=dict(size=14))
|
|
_docx_chart(doc, fig)
|
|
|
|
buf = io.BytesIO()
|
|
doc.save(buf)
|
|
buf.seek(0)
|
|
return buf.getvalue()
|