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, }