Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ class Settings(BaseSettings):

POOL_SIZE: int = 5
POOL_MAX_OVERFLOW: int = 10
REPORT_CACHE_MINUTES: int = 60

SERVICE_API_KEY: str = "default_service_secret_change_me"
ADMIN_API_KEY: str = "default_admin_secret_change_me"
Expand Down
11 changes: 9 additions & 2 deletions src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ async def custom_rate_limit_exceeded_handler(
def on_startup() -> None:
global model_pipeline, etl_scheduler
settings = get_settings()
create_generated_reports_table()
if not settings.SKIP_MODEL_TRAINING:
model_pipeline = train_logistic_regression_pipeline()

Expand Down Expand Up @@ -519,9 +520,13 @@ def get_example_revenue_input() -> EventRevenueInput:
def generate_daily_report(payload: DailyReportRequest) -> Any:
try:
target_date: date = payload.target_date or date.today()
report_path = generate_daily_report_csv(
settings = get_settings()
report_path, cache_hit = generate_daily_report_csv(
target_date=target_date,
output_format=payload.output_format,
event_id=payload.event_id,
force_regenerate=payload.force_regenerate,
cache_minutes=settings.REPORT_CACHE_MINUTES,
)
sales_data = _query_daily_sales(target_date)
transfer_stats = _query_transfer_stats(target_date)
Expand All @@ -530,6 +535,7 @@ def generate_daily_report(payload: DailyReportRequest) -> Any:
total_sales: int = sum(row["tickets_sold"] for row in sales_data)
total_revenue: float = sum(row["revenue"] for row in sales_data)

msg = "Report served from cache" if cache_hit else f"Report generated successfully at {report_path}"
return DailyReportResponse(
success=True,
report_path=report_path,
Expand All @@ -540,7 +546,8 @@ def generate_daily_report(payload: DailyReportRequest) -> Any:
"total_transfers": transfer_stats.get("total_transfers", 0),
"invalid_scans": invalid_scan_stats.get("invalid_scans", 0),
},
message=f"Report generated successfully at {report_path}",
cache_hit=cache_hit,
message=msg,
)
except Exception as exc:
log_error("Daily report generation failed", {"error": str(exc)})
Expand Down
131 changes: 126 additions & 5 deletions src/report_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import re
from datetime import date, datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Tuple

from sqlalchemy import text

Expand Down Expand Up @@ -119,6 +119,106 @@ def scan_and_populate_reports() -> None:
logger.info("reports/ directory scan complete")


# ---------------------------------------------------------------------------
# generated_reports table helpers
# ---------------------------------------------------------------------------

def create_generated_reports_table() -> None:
"""Create the generated_reports table if it does not yet exist."""
engine = _pg_engine()
if engine is None:
logger.info("Skipping generated_reports table creation — no DB engine")
return
with engine.connect() as conn:
conn.execute(text("""
CREATE TABLE IF NOT EXISTS generated_reports (
id SERIAL PRIMARY KEY,
filename TEXT NOT NULL,
report_date DATE NOT NULL,
event_id TEXT,
format TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
generated_at TIMESTAMP NOT NULL DEFAULT NOW()
)
"""))
conn.commit()
logger.info("generated_reports table ready")


def insert_report_metadata(
filename: str,
report_date: date,
event_id: Optional[str],
fmt: str,
size_bytes: int,
generated_at: datetime,
) -> None:
"""Insert a row into generated_reports after a file is written."""
engine = _pg_engine()
if engine is None:
return
with engine.connect() as conn:
conn.execute(
text("""
INSERT INTO generated_reports
(filename, report_date, event_id, format, size_bytes, generated_at)
VALUES
(:filename, :report_date, :event_id, :format, :size_bytes, :generated_at)
"""),
{
"filename": filename,
"report_date": report_date,
"event_id": event_id,
"format": fmt,
"size_bytes": size_bytes,
"generated_at": generated_at,
},
)
conn.commit()


def check_report_cache(
report_date: date,
event_id: Optional[str],
fmt: str,
cache_minutes: int,
) -> Optional[Dict[str, Any]]:
"""Return metadata for the most recent matching cached report, or None."""
engine = _pg_engine()
if engine is None:
return None
with engine.connect() as conn:
result = conn.execute(
text("""
SELECT filename, size_bytes, generated_at
FROM generated_reports
WHERE report_date = :report_date
AND format = :format
AND (
(:event_id IS NULL AND event_id IS NULL)
OR event_id = :event_id
)
AND generated_at >= NOW() - (:cache_minutes || ' minutes')::INTERVAL
ORDER BY generated_at DESC
LIMIT 1
"""),
{
"report_date": report_date,
"event_id": event_id,
"format": fmt,
"cache_minutes": cache_minutes,
},
)
row = result.fetchone()
if row is None:
return None
return {
"filename": row[0],
"size_bytes": row[1],
"generated_at": row[2],
}


def _pg_engine():
return _db.get_engine()

Expand Down Expand Up @@ -194,14 +294,27 @@ def _query_invalid_scans(target_date: Optional[date] = None) -> Dict[str, int]:
def generate_daily_report_csv(
target_date: Optional[date] = None,
output_format: str = "csv",
) -> str:
event_id: Optional[str] = None,
force_regenerate: bool = False,
cache_minutes: int = 60,
) -> Tuple[str, bool]:
"""Generate a daily sales report as CSV or JSON.

Returns the path to the generated file as a string.
Returns (path_to_generated_file, cache_hit).
When cache_hit is True the file already existed and was not regenerated.
"""
if target_date is None:
target_date = date.today()

# --- cache check ---
if not force_regenerate:
cached = check_report_cache(target_date, event_id, output_format, cache_minutes)
if cached is not None:
cached_path = str(REPORTS_DIR / cached["filename"])
if Path(cached_path).exists():
logger.info("Cache hit — returning existing report %s", cached["filename"])
return cached_path, True

_ensure_reports_dir()

sales_data = _query_daily_sales(target_date)
Expand Down Expand Up @@ -238,6 +351,10 @@ def generate_daily_report_csv(
with open(filepath, "w") as f:
json.dump(report_data, f, indent=2)

size_bytes = filepath.stat().st_size
now = datetime.utcnow()
insert_report_metadata(filename, report_date=target_date, event_id=event_id,
fmt="json", size_bytes=size_bytes, generated_at=now)
generated_at = datetime.utcnow()
insert_report_metadata(
filename=filename,
Expand All @@ -247,7 +364,7 @@ def generate_daily_report_csv(
generated_at=generated_at,
)
logger.info("Generated JSON report: %s", filepath)
return str(filepath)
return str(filepath), False

# CSV format (default)
filename = f"daily_report_{target_date}_{timestamp}.csv"
Expand Down Expand Up @@ -277,6 +394,10 @@ def generate_daily_report_csv(
f"${row['revenue']:.2f}",
])

size_bytes = filepath.stat().st_size
now = datetime.utcnow()
insert_report_metadata(filename, report_date=target_date, event_id=event_id,
fmt="csv", size_bytes=size_bytes, generated_at=now)
generated_at = datetime.utcnow()
insert_report_metadata(
filename=filename,
Expand All @@ -286,4 +407,4 @@ def generate_daily_report_csv(
generated_at=generated_at,
)
logger.info("Generated CSV report: %s", filepath)
return str(filepath)
return str(filepath), False
3 changes: 3 additions & 0 deletions src/types_custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ class DailyReportRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
target_date: Optional[date] = Field(None, description="Target date in YYYY-MM-DD format. Defaults to today.")
output_format: Literal["csv", "json"] = Field("csv", description="Output format: 'csv' or 'json'")
event_id: Optional[str] = Field(None, description="Optional event ID to scope the report. Null means all events.")
force_regenerate: bool = Field(False, description="When True, skip cache and always generate a fresh report.")


class DailyReportResponse(BaseModel):
Expand All @@ -125,6 +127,7 @@ class DailyReportResponse(BaseModel):
report_path: Optional[str] = Field(None, description="Path to generated report file")
report_date: str = Field(..., description="Date of the report")
summary: Dict[str, Any] = Field(..., description="Summary statistics")
cache_hit: bool = Field(False, description="True when the response was served from a cached report")
message: Optional[str] = Field(None, description="Additional information or error message")


Expand Down
14 changes: 6 additions & 8 deletions tests/test_daily_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,7 @@ def test_generate_daily_report_csv_format(mock_db_data):
patch("src.report_service._query_invalid_scans", return_value={"invalid_scans": 2}):

target_date = date(2025, 10, 4)
report_path = generate_daily_report_csv(target_date=target_date, output_format="csv")

assert report_path is not None
report_path, _ = generate_daily_report_csv(target_date=target_date, output_format="csv")
assert Path(report_path).exists()
assert "daily_report_2025-10-04" in report_path
assert report_path.endswith(".csv")
Expand All @@ -90,7 +88,7 @@ def test_generate_daily_report_json_format(mock_db_data):
patch("src.report_service._query_invalid_scans", return_value={"invalid_scans": 2}):

target_date = date(2025, 10, 4)
report_path = generate_daily_report_csv(target_date=target_date, output_format="json")
report_path, _ = generate_daily_report_csv(target_date=target_date, output_format="json")

assert report_path is not None
assert Path(report_path).exists()
Expand Down Expand Up @@ -200,8 +198,8 @@ def test_report_includes_all_summary_fields(mock_db_data):
patch("src.report_service._query_invalid_scans", return_value={"invalid_scans": 7}):

target_date = date(2025, 10, 4)
report_path = generate_daily_report_csv(target_date=target_date, output_format="json")
report_path, _ = generate_daily_report_csv(target_date=target_date, output_format="json")

with open(report_path, "r") as f:
data = json.load(f)

Expand Down Expand Up @@ -235,8 +233,8 @@ def test_csv_report_structure(mock_db_data):
patch("src.report_service._query_invalid_scans", return_value={"invalid_scans": 0}):

target_date = date(2025, 10, 4)
report_path = generate_daily_report_csv(target_date=target_date, output_format="csv")
report_path, _ = generate_daily_report_csv(target_date=target_date, output_format="csv")

with open(report_path, "r") as f:
reader = csv.reader(f)
rows = list(reader)
Expand Down
Loading
Loading