394 lines
13 KiB
Python
394 lines
13 KiB
Python
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,
|
|
}
|