Files
gw/backend/app/services/analytics.py
T

394 lines
13 KiB
Python
Raw Normal View History

2026-04-18 07:23:55 +12:00
from datetime import date, timedelta
from sqlalchemy import Date, case, cast, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.analytics import AnalyticsEvent
from app.models.member import Booking, Member
from app.schemas.analytics import EventCreate
async def record_event(
db: AsyncSession,
data: EventCreate,
ip_hash: str | None,
ip_partial: str | None = None,
user_agent: str | None = None,
browser: str | None = None,
os_name: str | None = None,
country: str | None = None,
city: str | None = None,
) -> AnalyticsEvent:
"""Insert a new analytics event and return it."""
event = AnalyticsEvent(
event_type=data.event_type,
page=data.page,
element=data.element,
metadata_=data.metadata,
session_id=data.session_id,
ip_hash=ip_hash,
ip_partial=ip_partial,
user_agent=user_agent,
browser=browser,
os_name=os_name,
country=country,
city=city,
)
db.add(event)
await db.commit()
await db.refresh(event)
return event
async def get_summary(db: AsyncSession) -> dict:
"""Return all summary data needed for AnalyticsSummary."""
today = date.today()
yesterday = today - timedelta(days=1)
week_ago = today - timedelta(days=6)
date_col = cast(AnalyticsEvent.created_at, Date)
# Total events today
result = await db.execute(
select(func.count()).select_from(AnalyticsEvent).where(date_col == today)
)
total_events_today = result.scalar_one()
# Total events yesterday
result = await db.execute(
select(func.count()).select_from(AnalyticsEvent).where(date_col == yesterday)
)
total_events_yesterday = result.scalar_one()
# Page views today
result = await db.execute(
select(func.count())
.select_from(AnalyticsEvent)
.where(date_col == today)
.where(AnalyticsEvent.event_type == "page_view")
)
page_views_today = result.scalar_one()
# Unique sessions today
result = await db.execute(
select(func.count(AnalyticsEvent.session_id.distinct()))
.select_from(AnalyticsEvent)
.where(date_col == today)
)
unique_sessions_today = result.scalar_one()
# Unique sessions total
result = await db.execute(
select(func.count(AnalyticsEvent.session_id.distinct())).select_from(AnalyticsEvent)
)
unique_sessions_total = result.scalar_one()
# Total events all time
result = await db.execute(
select(func.count()).select_from(AnalyticsEvent)
)
total_events_all_time = result.scalar_one()
# Events by type (top 10, all time)
result = await db.execute(
select(AnalyticsEvent.event_type, func.count().label("cnt"))
.group_by(AnalyticsEvent.event_type)
.order_by(func.count().desc())
.limit(10)
)
events_by_type = [{"label": r.event_type, "count": r.cnt} for r in result.all()]
# Top pages (page_view events, top 10)
result = await db.execute(
select(AnalyticsEvent.page, func.count().label("cnt"))
.where(AnalyticsEvent.event_type == "page_view")
.group_by(AnalyticsEvent.page)
.order_by(func.count().desc())
.limit(10)
)
top_pages = [{"label": r.page, "count": r.cnt} for r in result.all()]
# Top elements (non page_view, top 10)
result = await db.execute(
select(AnalyticsEvent.element, func.count().label("cnt"))
.where(AnalyticsEvent.event_type != "page_view")
.where(AnalyticsEvent.element.isnot(None))
.group_by(AnalyticsEvent.element)
.order_by(func.count().desc())
.limit(10)
)
top_elements = [{"label": r.element, "count": r.cnt} for r in result.all()]
# Top journeys (page-to-page flows derived from page_view events per session)
result = await db.execute(
select(
AnalyticsEvent.session_id,
AnalyticsEvent.page,
)
.where(AnalyticsEvent.event_type == "page_view")
.order_by(AnalyticsEvent.session_id, AnalyticsEvent.created_at, AnalyticsEvent.id)
)
journey_counts: dict[str, int] = {}
current_session = None
previous_page = None
for row in result.all():
if row.session_id != current_session:
current_session = row.session_id
previous_page = None
if row.page == previous_page:
continue
if previous_page is not None:
journey = f"{previous_page} -> {row.page}"
journey_counts[journey] = journey_counts.get(journey, 0) + 1
previous_page = row.page
top_journeys = [
{"label": label, "count": count}
for label, count in sorted(
journey_counts.items(),
key=lambda item: (-item[1], item[0]),
)[:10]
]
# Top browsers
result = await db.execute(
select(AnalyticsEvent.browser, func.count().label("cnt"))
.where(AnalyticsEvent.browser.isnot(None))
.group_by(AnalyticsEvent.browser)
.order_by(func.count().desc())
.limit(8)
)
top_browsers = [{"label": r.browser, "count": r.cnt} for r in result.all()]
# Top OS
result = await db.execute(
select(AnalyticsEvent.os_name, func.count().label("cnt"))
.where(AnalyticsEvent.os_name.isnot(None))
.group_by(AnalyticsEvent.os_name)
.order_by(func.count().desc())
.limit(8)
)
top_os = [{"label": r.os_name, "count": r.cnt} for r in result.all()]
# Top countries
result = await db.execute(
select(AnalyticsEvent.country, func.count().label("cnt"))
.where(AnalyticsEvent.country.isnot(None))
.group_by(AnalyticsEvent.country)
.order_by(func.count().desc())
.limit(8)
)
top_countries = [{"label": r.country, "count": r.cnt} for r in result.all()]
# Last 7 days counts
result = await db.execute(
select(date_col.label("day"), func.count().label("cnt"))
.where(date_col >= week_ago)
.group_by(date_col)
.order_by(date_col)
)
days = {str(r.day): r.cnt for r in result.all()}
last_7 = []
for i in range(6, -1, -1):
d = str(today - timedelta(days=i))
last_7.append({"date": d, "count": days.get(d, 0)})
# Recent events (last 30)
result = await db.execute(
select(AnalyticsEvent)
.order_by(AnalyticsEvent.created_at.desc())
.limit(30)
)
recent = list(result.scalars().all())
return {
"total_events_today": total_events_today,
"total_events_yesterday": total_events_yesterday,
"page_views_today": page_views_today,
"unique_sessions_today": unique_sessions_today,
"unique_sessions_total": unique_sessions_total,
"total_events_all_time": total_events_all_time,
"events_by_type": events_by_type,
"top_pages": top_pages,
"top_elements": top_elements,
"top_journeys": top_journeys,
"top_browsers": top_browsers,
"top_os": top_os,
"top_countries": top_countries,
"events_last_7_days": last_7,
"recent_events": recent,
}
async def get_booking_operations_summary(db: AsyncSession) -> dict:
"""Return booking operations reporting for the admin Reporting page."""
today = date.today()
activity_start = today - timedelta(days=29)
forward_load_end = today + timedelta(days=13)
created_date_col = cast(Booking.created_at, Date)
updated_date_col = cast(Booking.updated_at, Date)
requested_date_col = cast(Booking.requested_date, Date)
active_statuses = ("pending", "confirmed", "completed")
forward_statuses = ("pending", "confirmed")
active_total_result = await db.execute(
select(func.count())
.select_from(Booking)
.where(Booking.status.in_(active_statuses))
)
active_bookings_total = active_total_result.scalar_one()
forward_load_total_result = await db.execute(
select(func.count())
.select_from(Booking)
.where(Booking.status.in_(forward_statuses))
.where(Booking.requested_date.is_not(None))
.where(requested_date_col >= today)
.where(requested_date_col <= forward_load_end)
)
forward_load_total = forward_load_total_result.scalar_one()
booked_last_30_days_result = await db.execute(
select(func.count())
.select_from(Booking)
.where(created_date_col >= activity_start)
.where(created_date_col <= today)
)
booked_last_30_days = booked_last_30_days_result.scalar_one()
cancellations_last_30_days_result = await db.execute(
select(func.count())
.select_from(Booking)
.where(Booking.status == "cancelled")
.where(updated_date_col >= activity_start)
.where(updated_date_col <= today)
)
cancellations_last_30_days = cancellations_last_30_days_result.scalar_one()
high_volume_result = await db.execute(
select(func.count().label("booking_count"))
.select_from(Booking)
.where(Booking.status.in_(forward_statuses))
.where(Booking.requested_date.is_not(None))
.where(requested_date_col >= today)
.group_by(Booking.member_id)
.having(func.count() >= 3)
)
high_volume_customer_count = len(high_volume_result.all())
forward_load_result = await db.execute(
select(
requested_date_col.label("day"),
func.count().label("total"),
func.sum(
case(
(func.extract("hour", Booking.requested_date) < 12, 1),
else_=0,
)
).label("am"),
func.sum(
case(
(func.extract("hour", Booking.requested_date) >= 12, 1),
else_=0,
)
).label("pm"),
)
.where(Booking.status.in_(forward_statuses))
.where(Booking.requested_date.is_not(None))
.where(requested_date_col >= today)
.where(requested_date_col <= forward_load_end)
.group_by(requested_date_col)
.order_by(requested_date_col)
)
forward_load_by_day = {
str(row.day): {
"total": int(row.total or 0),
"am": int(row.am or 0),
"pm": int(row.pm or 0),
}
for row in forward_load_result.all()
}
forward_load_next_14_days = []
for offset in range(14):
current_day = str(today + timedelta(days=offset))
values = forward_load_by_day.get(current_day, {"total": 0, "am": 0, "pm": 0})
forward_load_next_14_days.append({
"date": current_day,
"total": values["total"],
"am": values["am"],
"pm": values["pm"],
})
booked_activity_result = await db.execute(
select(created_date_col.label("day"), func.count().label("count"))
.where(created_date_col >= activity_start)
.where(created_date_col <= today)
.group_by(created_date_col)
.order_by(created_date_col)
)
booked_by_day = {str(row.day): int(row.count or 0) for row in booked_activity_result.all()}
cancellation_activity_result = await db.execute(
select(updated_date_col.label("day"), func.count().label("count"))
.where(Booking.status == "cancelled")
.where(updated_date_col >= activity_start)
.where(updated_date_col <= today)
.group_by(updated_date_col)
.order_by(updated_date_col)
)
cancellations_by_day = {
str(row.day): int(row.count or 0)
for row in cancellation_activity_result.all()
}
activity_last_30_days = []
for offset in range(30):
current_day = str(activity_start + timedelta(days=offset))
activity_last_30_days.append({
"date": current_day,
"booked": booked_by_day.get(current_day, 0),
"cancellations": cancellations_by_day.get(current_day, 0),
})
volume_result = await db.execute(
select(
Member.first_name,
Member.last_name,
func.count(Booking.id).label("count"),
)
.join(Member, Booking.member_id == Member.id)
.where(Booking.status.in_(forward_statuses))
.where(Booking.requested_date.is_not(None))
.where(requested_date_col >= today)
.group_by(Member.id, Member.first_name, Member.last_name)
.order_by(func.count(Booking.id).desc(), Member.first_name.asc(), Member.last_name.asc())
.limit(8)
)
top_high_volume_customers = [
{
"label": " ".join(part for part in [row.first_name, row.last_name] if part).strip() or "Client",
"count": int(row.count or 0),
}
for row in volume_result.all()
]
return {
"active_bookings_total": int(active_bookings_total or 0),
"forward_load_total": int(forward_load_total or 0),
"booked_last_30_days": int(booked_last_30_days or 0),
"cancellations_last_30_days": int(cancellations_last_30_days or 0),
"high_volume_customer_count": int(high_volume_customer_count or 0),
"forward_load_next_14_days": forward_load_next_14_days,
"activity_last_30_days": activity_last_30_days,
"top_high_volume_customers": top_high_volume_customers,
}