Skip to content

Commit eb68525

Browse files
committed
feat: add local PostgreSQL and data quality reporting
1 parent 7c97b95 commit eb68525

13 files changed

Lines changed: 189 additions & 13 deletions

.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Alleen voor lokale ontwikkeling. Plaats echte cloudcredentials nooit in Git.
2+
DATABASE_URL=postgresql+psycopg://analytics_user:analytics_dev_password@localhost:5433/analytics

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ __pycache__/
55

66
# Local virtual environment
77
.venv/
8+
.env
89

910
# Deployment secrets: never commit database credentials.
1011
.streamlit/secrets.toml

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,9 +119,26 @@ De portfolio-app kan al queryresultaten uit PostgreSQL in Pandas laden via
119119
in [docs/CLOUD_POSTGRES.md](docs/CLOUD_POSTGRES.md). Databasecredentials horen
120120
uitsluitend in Streamlit secrets, nooit in Git.
121121

122+
## PostgreSQL lokaal met Docker
123+
124+
Voor een realistische lokale bedrijfsflow start je PostgreSQL met Docker en
125+
laad je de schoongemaakte data erin:
126+
127+
```bash
128+
docker compose up -d
129+
python src/load_to_postgres.py
130+
DATABASE_URL="postgresql+psycopg://analytics_user:analytics_dev_password@localhost:5433/analytics" streamlit run dashboard/app.py
131+
```
132+
133+
De app toont dan **Data source: PostgreSQL**. Zonder `DATABASE_URL` gebruikt
134+
dezelfde app automatisch de CSV-fallback, waardoor de publieke demo betrouwbaar
135+
en gratis blijft.
136+
122137
## Data quality
123138

124139
Naast de cleaning-pipeline valideert [Pandera](https://pandera.readthedocs.io/)
125140
de finale dataset als data contract: verplichte kolommen, geldige landen en
126141
producten, unieke order-ID's, positieve prijzen/kosten en een geldige marge.
127142
Een foute bronrij komt dus niet stilzwijgend in het dashboard terecht.
143+
De pipeline schrijft bovendien `reports/data_quality_report.json` met het aantal
144+
bronrijen, verwijderde rijen, dubbele orders en behoudpercentage.

dashboard/app.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Start met: streamlit run dashboard/app.py"""
22

33
from pathlib import Path
4+
import os
45
import sys
56

67
import pandas as pd
@@ -28,7 +29,7 @@
2829
top_customers,
2930
)
3031
from src.clean_data import clean_sales_data # noqa: E402
31-
from src.load_data import load_customer_directory_csv, load_monthly_targets_csv, load_sales_csv # noqa: E402
32+
from src.load_data import load_customer_directory_csv, load_monthly_targets_csv, load_sales_csv, load_sales_from_postgres # noqa: E402
3233
from visualizations.plotly_charts import create_actual_vs_target_chart, create_cohort_retention_chart, create_monthly_performance_chart # noqa: E402
3334

3435

@@ -49,12 +50,26 @@
4950
RAW_DATA_PATH = PROJECT_ROOT / "data" / "raw" / "sales.csv"
5051
TARGETS_DATA_PATH = PROJECT_ROOT / "data" / "raw" / "monthly_targets.csv"
5152
CUSTOMERS_DATA_PATH = PROJECT_ROOT / "data" / "raw" / "customers.csv"
53+
POSTGRES_QUERY_PATH = PROJECT_ROOT / "sql" / "dashboard_sales.sql"
54+
55+
56+
def get_database_url() -> str | None:
57+
"""Lees een optionele lokale of cloud databaseverbinding zonder secrets te loggen."""
58+
if database_url := os.getenv("DATABASE_URL"):
59+
return database_url
60+
try:
61+
return st.secrets.get("DATABASE_URL")
62+
except Exception:
63+
return None
5264

5365

5466
@st.cache_data
55-
def get_sales_data(source_version: int) -> pd.DataFrame:
56-
"""Clean raw data; its file version safely refreshes Streamlit's cache."""
67+
def get_sales_data(source_version: int, database_url: str | None) -> pd.DataFrame:
68+
"""Gebruik PostgreSQL wanneer geconfigureerd, anders de betrouwbare CSV-fallback."""
5769
del source_version
70+
if database_url:
71+
query = POSTGRES_QUERY_PATH.read_text(encoding="utf-8")
72+
return clean_sales_data(load_sales_from_postgres(database_url, query))
5873
return clean_sales_data(load_sales_csv(RAW_DATA_PATH))
5974

6075

@@ -72,12 +87,14 @@ def get_customer_directory(source_version: int) -> pd.DataFrame:
7287
return load_customer_directory_csv(CUSTOMERS_DATA_PATH)
7388

7489

75-
data = get_sales_data(RAW_DATA_PATH.stat().st_mtime_ns)
90+
database_url = get_database_url()
91+
data = get_sales_data(RAW_DATA_PATH.stat().st_mtime_ns, database_url)
7692
monthly_targets = get_monthly_targets(TARGETS_DATA_PATH.stat().st_mtime_ns)
7793
customer_directory = get_customer_directory(CUSTOMERS_DATA_PATH.stat().st_mtime_ns)
7894
st.markdown('<p class="eyebrow">Portfolio project · Sales intelligence</p>', unsafe_allow_html=True)
7995
st.title("Sales analytics dashboard")
8096
st.caption("Analyseer 24 maanden verkoopdata, vergelijk periodes en exporteer gefilterde inzichten.")
97+
st.caption("Data source: PostgreSQL" if database_url else "Data source: CSV fallback")
8198

8299
with st.sidebar:
83100
st.header("Filters")

docker-compose.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
services:
2+
postgres:
3+
image: postgres:16-alpine
4+
container_name: analytics-postgres
5+
environment:
6+
POSTGRES_DB: analytics
7+
POSTGRES_USER: analytics_user
8+
POSTGRES_PASSWORD: analytics_dev_password
9+
ports:
10+
- "5433:5432"
11+
volumes:
12+
- postgres_data:/var/lib/postgresql/data
13+
- ./sql/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
14+
healthcheck:
15+
test: ["CMD-SHELL", "pg_isready -U analytics_user -d analytics"]
16+
interval: 5s
17+
timeout: 5s
18+
retries: 10
19+
20+
volumes:
21+
postgres_data:

reports/data_quality_report.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"raw_rows": 904,
3+
"clean_rows": 900,
4+
"removed_rows": 4,
5+
"duplicate_orders": 1,
6+
"retention_percent": 99.56
7+
}

sql/dashboard_sales.sql

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
SELECT order_id, order_date, customer_id, country, product, quantity, price, unit_cost
2+
FROM sales_orders
3+
ORDER BY order_date, order_id;

sql/queries.sql

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
1-
-- Gebruik deze query later met load_sales_from_postgres().
2-
SELECT order_id, order_date, customer_id, country, product, quantity, price,
3-
quantity * price AS revenue
1+
-- Dashboardbron: PostgreSQL-resultaat met dezelfde vorm als de CSV-bron.
2+
SELECT order_id, order_date, customer_id, country, product, quantity, price, unit_cost
43
FROM sales_orders
5-
WHERE order_date >= DATE '2026-01-01'
4+
WHERE order_date >= DATE '2024-01-01'
65
ORDER BY order_date, order_id;
76

8-
-- Een voorbeeld van analyse rechtstreeks in SQL.
9-
SELECT country, SUM(quantity * price) AS revenue
7+
-- Zakelijke analyse rechtstreeks in SQL.
8+
SELECT
9+
country,
10+
SUM(revenue) AS revenue,
11+
SUM(gross_profit) AS gross_profit,
12+
ROUND(SUM(gross_profit) / NULLIF(SUM(revenue), 0) * 100, 2) AS margin_percent
1013
FROM sales_orders
1114
GROUP BY country
12-
ORDER BY revenue DESC;
15+
ORDER BY gross_profit DESC;

sql/schema.sql

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,26 @@
1-
-- PostgreSQL-schema voor wanneer CSV door een echte database wordt vervangen.
1+
-- Lokale PostgreSQL-datalaag voor de analytics-app.
22
CREATE TABLE IF NOT EXISTS sales_orders (
33
order_id INTEGER PRIMARY KEY,
44
order_date DATE NOT NULL,
55
customer_id VARCHAR(50) NOT NULL,
66
country VARCHAR(100) NOT NULL,
77
product VARCHAR(100) NOT NULL,
88
quantity INTEGER NOT NULL CHECK (quantity > 0),
9-
price NUMERIC(10, 2) NOT NULL CHECK (price > 0)
9+
price NUMERIC(10, 2) NOT NULL CHECK (price > 0),
10+
unit_cost NUMERIC(10, 2) NOT NULL CHECK (unit_cost > 0 AND unit_cost < price),
11+
revenue NUMERIC(12, 2) NOT NULL CHECK (revenue > 0),
12+
gross_profit NUMERIC(12, 2) NOT NULL CHECK (gross_profit > 0),
13+
margin_percent NUMERIC(5, 2) NOT NULL CHECK (margin_percent BETWEEN 0 AND 100)
14+
);
15+
16+
CREATE TABLE IF NOT EXISTS monthly_targets (
17+
month DATE PRIMARY KEY,
18+
target_revenue NUMERIC(12, 2) NOT NULL CHECK (target_revenue > 0)
19+
);
20+
21+
CREATE TABLE IF NOT EXISTS customers (
22+
customer_id VARCHAR(50) PRIMARY KEY,
23+
customer_name VARCHAR(100) NOT NULL,
24+
customer_since DATE NOT NULL,
25+
acquisition_channel VARCHAR(50) NOT NULL
1026
);

src/data_quality.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""Meet en rapporteer datakwaliteit vóór en na de cleaning-pipeline."""
2+
3+
import json
4+
from pathlib import Path
5+
6+
import pandas as pd
7+
8+
9+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
10+
DEFAULT_REPORT_PATH = PROJECT_ROOT / "reports" / "data_quality_report.json"
11+
12+
13+
def build_data_quality_report(raw_data: pd.DataFrame, cleaned_data: pd.DataFrame) -> dict[str, int | float]:
14+
"""Geef transparante aantallen voor verwijderde en behouden rijen."""
15+
raw_rows = len(raw_data)
16+
clean_rows = len(cleaned_data)
17+
duplicate_orders = int(raw_data.duplicated(subset="order_id").sum())
18+
return {
19+
"raw_rows": raw_rows,
20+
"clean_rows": clean_rows,
21+
"removed_rows": raw_rows - clean_rows,
22+
"duplicate_orders": duplicate_orders,
23+
"retention_percent": round(clean_rows / raw_rows * 100, 2) if raw_rows else 0.0,
24+
}
25+
26+
27+
def save_data_quality_report(report: dict[str, int | float], path: str | Path = DEFAULT_REPORT_PATH) -> Path:
28+
"""Schrijf het kwaliteitsrapport als leesbaar JSON-bestand weg."""
29+
output_path = Path(path)
30+
output_path.parent.mkdir(parents=True, exist_ok=True)
31+
output_path.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
32+
return output_path

0 commit comments

Comments
 (0)