v1
This commit is contained in:
@@ -0,0 +1,393 @@
|
||||
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,
|
||||
}
|
||||
Reference in New Issue
Block a user