Skip to content

Commit 3a28ea6

Browse files
Clarify report layout wording
1 parent 1cd51b3 commit 3a28ea6

4 files changed

Lines changed: 210 additions & 0 deletions

File tree

requirements.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
beautifulsoup4
22
fake_useragent
33
imageio
4+
jinja2
45
keras
56
lxml
67
matplotlib
78
numpy
89
opencv-python
910
pandas
11+
pdfkit
1012
pillow
1113
requests
1214
rich
@@ -16,4 +18,5 @@ statsmodels
1618
sympy
1719
tweepy
1820
typing_extensions
21+
weasyprint
1922
xgboost

sustainability/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Sustainability compliance tools for CTRL Environmental."""
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
"""Generate a sustainability compliance dashboard for CTRL Environmental.
2+
3+
The script reads inspection data from CSV or Excel files, categorizes findings
4+
into environmental topics, flags non-compliances with traffic-light colours,
5+
and exports the results to a styled HTML report. Optional PDF export is
6+
attempted, preferring WeasyPrint with a fallback to pdfkit when available.
7+
The layout uses a black, red, and white palette reminiscent of Berlin poster
8+
art and includes placeholders for the CTRL logo and report metadata.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import argparse
14+
import logging
15+
import re
16+
from dataclasses import dataclass, field
17+
from datetime import UTC, datetime
18+
from pathlib import Path
19+
20+
import pandas as pd
21+
from jinja2 import Environment, FileSystemLoader, select_autoescape
22+
23+
# Optional PDF backends
24+
try:
25+
import pdfkit
26+
except ImportError: # pragma: no cover - optional dependency
27+
pdfkit = None # type: ignore[assignment]
28+
try:
29+
from weasyprint import HTML
30+
except ImportError: # pragma: no cover - optional dependency
31+
HTML = None # type: ignore[assignment]
32+
33+
logging.basicConfig(level=logging.INFO)
34+
35+
# Regex for safer status parsing
36+
_NEG = re.compile(
37+
r"\b(?:major\s*nc|fail|non[-\s]?compliance|(?:^|[^a-z])nc(?:$|[^a-z]))\b", re.I
38+
)
39+
_AMB = re.compile(r"\b(?:minor|observation|warning)\b", re.I)
40+
41+
CATEGORY_KEYWORDS = {
42+
"Waste": ["waste", "trash", "garbage", "recycle"],
43+
"Water": ["water", "effluent", "sewage", "storm"],
44+
"Air": ["air", "emission", "dust", "smoke"],
45+
"Chemicals": ["chemical", "hazard", "solvent", "acid", "alkali"],
46+
"ESG": ["esg", "governance", "social", "sustainability", "diversity"],
47+
}
48+
49+
50+
@dataclass
51+
class Metadata:
52+
"""Metadata describing the inspection report."""
53+
54+
site: str
55+
client: str
56+
inspector: str
57+
logo: str
58+
date: str = field(
59+
default_factory=lambda: datetime.now(UTC).strftime("%Y-%m-%d %H:%M UTC")
60+
)
61+
62+
63+
def read_inspection_data(path: str) -> pd.DataFrame:
64+
"""Read inspection data from a CSV or Excel file."""
65+
66+
ext = Path(path).suffix.lower()
67+
if ext in {".xls", ".xlsx"}:
68+
return pd.read_excel(path)
69+
return pd.read_csv(path)
70+
71+
72+
def categorize_finding(text: str) -> str:
73+
"""Return the category that best matches *text*."""
74+
75+
lowered = text.lower()
76+
for category, keywords in CATEGORY_KEYWORDS.items():
77+
if any(word in lowered for word in keywords):
78+
return category
79+
return "Other"
80+
81+
82+
def traffic_light(status: str) -> str:
83+
"""Return a CSS colour for a traffic-light style status."""
84+
85+
s = (status or "").strip()
86+
if _NEG.search(s):
87+
return "#d50000" # red
88+
if _AMB.search(s):
89+
return "#ffab00" # amber
90+
return "#00c853" # green
91+
92+
93+
def style_dataframe(df: pd.DataFrame) -> pd.io.formats.style.Styler:
94+
"""Apply traffic-light styling to status columns in *df* and hide the index."""
95+
96+
subset_cols = [c for c in df.columns if c.strip().lower() == "status"]
97+
styler = df.style
98+
if subset_cols:
99+
styler = styler.map(
100+
lambda v: f"background-color:{traffic_light(v)}", subset=subset_cols
101+
)
102+
return styler.hide(axis="index")
103+
104+
105+
# Jinja environment
106+
env = Environment(
107+
loader=FileSystemLoader("templates"),
108+
autoescape=select_autoescape(["html", "xml"]),
109+
)
110+
111+
112+
def render_report(
113+
df: pd.DataFrame, metadata: Metadata, html_out: str, pdf_out: str | None = None
114+
) -> None:
115+
"""Render *df* to HTML and optionally PDF."""
116+
117+
styled_html = style_dataframe(df).to_html()
118+
template = env.get_template("report.html.j2")
119+
report_html = template.render(metadata=metadata, table_html=styled_html)
120+
121+
Path(html_out).write_text(report_html, encoding="utf-8")
122+
logging.info("Report written to %s", html_out)
123+
124+
if pdf_out:
125+
if HTML:
126+
try:
127+
HTML(filename=html_out).write_pdf(pdf_out) # type: ignore[no-untyped-call]
128+
logging.info("PDF written via WeasyPrint to %s", pdf_out)
129+
return
130+
except Exception as exc: # noqa: BLE001 # pragma: no cover - optional dependency
131+
logging.warning("WeasyPrint failed (%s); falling back to pdfkit", exc)
132+
if pdfkit:
133+
try:
134+
pdfkit.from_file(html_out, pdf_out) # type: ignore[no-untyped-call]
135+
logging.info("PDF written via pdfkit to %s", pdf_out)
136+
except OSError as exc: # wkhtmltopdf missing
137+
logging.warning("pdfkit failed (%s); skipped PDF export", exc)
138+
else:
139+
logging.warning("No PDF backend available; skipped PDF export")
140+
141+
142+
def main() -> None:
143+
"""Command line interface for the compliance dashboard."""
144+
145+
parser = argparse.ArgumentParser(description=__doc__)
146+
parser.add_argument("input", help="CSV or Excel file containing inspection data")
147+
parser.add_argument("--html", default="report.html", help="Output HTML report path")
148+
parser.add_argument("--pdf", help="Optional output PDF path")
149+
parser.add_argument(
150+
"--logo", default="logo_placeholder.png", help="Path to CTRL logo"
151+
)
152+
parser.add_argument("--site", default="Unknown Site")
153+
parser.add_argument("--client", default="Unknown Client")
154+
parser.add_argument("--inspector", default="Unknown Inspector")
155+
parser.add_argument("--date", help="Report date (defaults to now UTC)")
156+
args = parser.parse_args()
157+
158+
data = read_inspection_data(args.input)
159+
if "Category" not in data.columns and "Finding" in data.columns:
160+
data["Category"] = data["Finding"].map(categorize_finding)
161+
162+
meta_kwargs = {
163+
"site": args.site,
164+
"client": args.client,
165+
"inspector": args.inspector,
166+
"logo": args.logo,
167+
}
168+
if args.date:
169+
meta_kwargs["date"] = args.date
170+
metadata = Metadata(**meta_kwargs)
171+
render_report(data, metadata, args.html, args.pdf)
172+
print(f"Report written to {args.html}")
173+
174+
175+
if __name__ == "__main__":
176+
main()

templates/report.html.j2

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
<title>CTRL Environmental Compliance Report</title>
6+
<style>
7+
body { background:#fff; color:#000; font-family:Arial, sans-serif; }
8+
header { background:#000; color:#fff; padding:20px; text-align:center; }
9+
h1 { color:#e10600; margin:0; text-transform:uppercase; }
10+
.meta { margin-top:10px; }
11+
table { border-collapse:collapse; width:100%; }
12+
th, td { border:1px solid #000; padding:4px; }
13+
</style>
14+
</head>
15+
<body>
16+
<header>
17+
<img src="{{ metadata.logo }}" alt="CTRL Logo" style="max-height:80px;" />
18+
<h1>Compliance Dashboard</h1>
19+
<div class="meta">
20+
<strong>Site:</strong> {{ metadata.site }} |
21+
<strong>Client:</strong> {{ metadata.client }} |
22+
<strong>Inspector:</strong> {{ metadata.inspector }} |
23+
<strong>Date:</strong> {{ metadata.date }}
24+
</div>
25+
</header>
26+
<div class="table-container">
27+
{{ table_html | safe }}
28+
</div>
29+
</body>
30+
</html>

0 commit comments

Comments
 (0)