import json import asyncpg from utils.read.models import ( CoverageGap, Page, PresenceDay, PresenceHour, PresenceSession, PresenceStats, PresenceSummary, ) from utils.read.period import period_bounds from utils.read.presence import current_presence SECONDS_IN_HOUR = 3600 SECONDS_IN_DAY = 86_400 MIN_CELL_COVERAGE = 1800 MIN_CELL_SHARE = 0.5 STATS_WORK_MEM = "32MB" FUZZY_STATUSES = {"recently", "last_week", "last_month", "long_time_ago"} _SESSIONS = """ marks AS ( SELECT ts, status, least(next_offline_date, ts + interval '5 minutes', lead(coalesce(last_online_date, ts)) OVER w, $4) AS ended, lag(CASE WHEN status = 'online' THEN least(next_offline_date, ts + interval '5 minutes') ELSE coalesce(last_online_date, ts) END) OVER w AS previous FROM presence WHERE account_id = $1 AND peer_id = $2 AND ts >= $3::timestamptz - interval '5 minutes' AND ts <= $4 WINDOW w AS (ORDER BY ts) ), online AS ( SELECT greatest(ts, $3) AS started, ended, count(*) FILTER (WHERE previous IS NULL OR ts > previous + interval '5 minutes') OVER (ORDER BY ts) AS visit FROM marks WHERE status = 'online' AND ended > greatest(ts, $3) ), sessions AS ( SELECT min(started) AS started, max(ended) AS ended, extract(epoch FROM sum(ended - started))::float8 AS online_seconds FROM online GROUP BY visit ) """ _SESSION_FIELDS = """ started, ended, extract(epoch FROM ended - started)::float8 AS seconds, online_seconds, ended >= $4 AS ongoing """ _SESSION_PAGE = f""" WITH {_SESSIONS} SELECT {_SESSION_FIELDS} FROM sessions ORDER BY started DESC LIMIT $5 OFFSET $6 """ # noqa: S608 _COVERED_HOURS = """ SELECT local_hour, extract(epoch FROM sum( least(ended, (local_hour + interval '1 hour') AT TIME ZONE $5) - greatest(started, local_hour AT TIME ZONE $5)))::float8 AS seconds FROM uptime, generate_series(date_trunc('hour', started AT TIME ZONE $5), date_trunc('hour', ended AT TIME ZONE $5), interval '1 hour') AS local_hour GROUP BY local_hour """ _STATS = f""" WITH {_SESSIONS}, uptime AS ( SELECT greatest(started_at, $3) AS started, least(seen_at + interval '90 seconds', $4) AS ended FROM capture_uptime WHERE account_id = $1 AND started_at < $4 AND seen_at + interval '90 seconds' > $3 ), quarters AS ( SELECT quarter, sum(ended - started - spill) AS inside, sum(spill) AS spill FROM (SELECT started, ended, quarter, greatest(ended - quarter - interval '15 minutes', interval '0') AS spill FROM (SELECT started, ended, date_bin('15 minutes', started, timestamptz 'epoch') AS quarter FROM online) binned) split GROUP BY quarter ), online_hours AS ( SELECT date_trunc('hour', quarter AT TIME ZONE $5) AS local_hour, extract(epoch FROM sum(share))::float8 AS seconds FROM (SELECT quarter, inside AS share FROM quarters UNION ALL SELECT quarter + interval '15 minutes', spill FROM quarters) shares GROUP BY 1 ), covered_hours AS ({_COVERED_HOURS}), hours AS ( SELECT local_hour, extract(epoch FROM least((local_hour + interval '1 hour') AT TIME ZONE $5, $4) - greatest(local_hour AT TIME ZONE $5, $3))::float8 AS span, coalesce(online.seconds, 0) AS online, least(3600, greatest(coalesce(covered.seconds, 0), coalesce(online.seconds, 0))) AS covered FROM generate_series(date_trunc('hour', $3 AT TIME ZONE $5), date_trunc('hour', $4 AT TIME ZONE $5), interval '1 hour') AS local_hour LEFT JOIN online_hours AS online USING (local_hour) LEFT JOIN covered_hours AS covered USING (local_hour) ), week AS ( SELECT extract(isodow FROM local_hour)::int - 1 AS weekday, extract(hour FROM local_hour)::int AS hour, count(*) AS hours, sum(span) AS span, sum(online) AS online, sum(covered) AS covered FROM hours GROUP BY 1, 2 ), days AS ( SELECT local_hour::date AS day, sum(online) AS online, sum(covered) AS covered FROM hours GROUP BY 1 ), uptime_reach AS ( SELECT started, coalesce(max(ended) OVER (ORDER BY started ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING), $3) AS reached FROM uptime ), gaps AS ( SELECT reached AS started, started AS ended FROM uptime_reach UNION ALL SELECT coalesce(max(ended), $3), $4 FROM uptime ), quiet AS ( SELECT hour FROM week GROUP BY hour ORDER BY sum(online) / nullif(sum(covered), 0) NULLS LAST, hour LIMIT 1 ), activity_days AS ( SELECT (sessions.started AT TIME ZONE $5) - make_interval(hours => quiet.hour) AS started, (sessions.ended AT TIME ZONE $5) - make_interval(hours => quiet.hour) AS ended, (($4 AT TIME ZONE $5) - make_interval(hours => quiet.hour))::date AS today FROM sessions, quiet ), daily AS ( SELECT extract(epoch FROM min(started - started::date))::float8 AS first_online, extract(epoch FROM max(ended - started::date))::float8 AS last_offline FROM activity_days WHERE started::date < today GROUP BY started::date ) SELECT (SELECT count(*) FROM sessions) AS session_count, (SELECT coalesce(sum(online_seconds), 0) FROM sessions) AS online_seconds, (SELECT sum(covered) FROM hours) AS covered_seconds, (SELECT percentile_cont(0.5) WITHIN GROUP ( ORDER BY extract(epoch FROM ended - started)) FROM sessions) AS median_session, (SELECT max(ended) FROM sessions) AS last_seen, (SELECT hour FROM quiet) AS day_start, (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY first_online) FROM daily) AS first_online, (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY last_offline) FROM daily) AS last_offline, (SELECT json_agg(json_build_object( 'weekday', weekday, 'hour', hour, 'hours', hours, 'span', span, 'online', online, 'covered', covered)) FROM week) AS week, (SELECT json_agg(json_build_object( 'day', day, 'online_seconds', online, 'covered_seconds', covered) ORDER BY day) FROM days) AS days, (SELECT json_agg(json_build_object('started', started, 'ended', ended) ORDER BY started) FROM gaps WHERE ended - started > interval '5 minutes') AS gaps, (SELECT json_agg(row_to_json(page) ORDER BY page.started DESC) FROM (SELECT {_SESSION_FIELDS} FROM sessions ORDER BY started DESC LIMIT $6 OFFSET $7) page) AS sessions """ # noqa: S608 def _time_of_day(shifted: float | None, day_start: int | None) -> int | None: if shifted is None or day_start is None: return None return round(shifted + day_start * SECONDS_IN_HOUR) % SECONDS_IN_DAY def _week(rows: list[dict]) -> list[PresenceHour]: cells = {(row["weekday"], row["hour"]): row for row in rows} week: list[PresenceHour] = [] for weekday in range(7): for hour in range(24): row = cells.get( (weekday, hour), {"hours": 0, "span": 0, "online": 0, "covered": 0} ) covered = row["covered"] minutes = ( min(60.0, row["online"] / covered * 60) if covered > 0 and covered >= min(MIN_CELL_COVERAGE, row["span"] * MIN_CELL_SHARE) else None ) week.append( PresenceHour( weekday=weekday, hour=hour, hours=row["hours"], online_minutes=minutes, covered_hours=covered / SECONDS_IN_HOUR, ) ) return week async def presence_stats( # noqa: PLR0913 pool: asyncpg.Pool, account_id: int, peer_id: int, page: Page, *, days: int, tz: str ) -> PresenceStats: since, until = await period_bounds(pool, days, tz) async with pool.acquire() as conn, conn.transaction(): await conn.execute(f"SET LOCAL work_mem = '{STATS_WORK_MEM}'") row = await conn.fetchrow( _STATS, account_id, peer_id, since, until, tz, page.capped_limit, page.offset, ) current = await current_presence(pool, account_id, peer_id) sessions = [PresenceSession(**item) for item in json.loads(row["sessions"] or "[]")] covered = row["covered_seconds"] or 0 online = row["online_seconds"] return PresenceStats( peer_id=peer_id, tz=tz, since=since, until=until, current=current, online=current is not None and current.status == "online" and current.next_offline_date is not None and current.next_offline_date > until, hidden=row["session_count"] == 0 and current is not None and current.status in FUZZY_STATUSES, summary=PresenceSummary( online_seconds=online, daily_seconds=online / covered * SECONDS_IN_DAY if covered >= SECONDS_IN_HOUR else None, covered_seconds=covered, session_count=row["session_count"], median_session_seconds=row["median_session"], first_online=_time_of_day(row["first_online"], row["day_start"]), last_offline=_time_of_day(row["last_offline"], row["day_start"]), last_seen=row["last_seen"] or (current.last_online_date if current else None), ), week=_week(json.loads(row["week"] or "[]")), days=[PresenceDay(**item) for item in json.loads(row["days"] or "[]")], gaps=[CoverageGap(**item) for item in json.loads(row["gaps"] or "[]")], sessions=sessions, ) async def presence_sessions( # noqa: PLR0913 pool: asyncpg.Pool, account_id: int, peer_id: int, page: Page, *, days: int, tz: str ) -> list[PresenceSession]: since, until = await period_bounds(pool, days, tz) rows = await pool.fetch( _SESSION_PAGE, account_id, peer_id, since, until, page.capped_limit, page.offset ) return [PresenceSession(**dict(row)) for row in rows]