Expand dashboard analytics and document production implementation
This commit is contained in:
@@ -5,6 +5,10 @@ import math
|
||||
import statistics
|
||||
from database import connect
|
||||
from metrics_api import engagement, monetization, gamepasses
|
||||
from player_records import record_page, player_inventory
|
||||
from prompt_metrics import prompt_analytics
|
||||
from health_reports import reports_for_game
|
||||
from data_quality import quality_log
|
||||
|
||||
|
||||
class ApiError(Exception):
|
||||
@@ -79,6 +83,16 @@ def segment_players(players, segment):
|
||||
def player_list(conn,gid,params):
|
||||
fields,templates=fields_templates(conn,gid)
|
||||
players=all_players(conn,gid)
|
||||
ranking=params.get('ranking',['level'])[0]
|
||||
if ranking not in {f['key'] for f in fields}: raise ApiError(400,'Unknown ranking.')
|
||||
boards=[]
|
||||
for key in dict.fromkeys(['robux','playtime','cash',ranking]):
|
||||
field=next((f for f in fields if f['key']==key),None)
|
||||
if not field: continue
|
||||
eligible=[p for p in players if type(p.get(key)) in (int,float)]
|
||||
leaders=sorted(eligible,key=lambda p:(-p[key],p['playerId']))[:5]
|
||||
boards.append({'key':key,'label':field['label'],'format':field['format'],
|
||||
'players':[{'playerId':p['playerId'],'name':p['name'],'robloxUserId':p['robloxUserId'],'value':p[key]} for p in leaders]})
|
||||
known_levels=[p["level"] for p in players if p.get("level") is not None]
|
||||
known_times=[p["playtime"] for p in players if p["playtime"] is not None]
|
||||
metrics={"playerCount":len(players),"activePlayers":sum(p["lastSeen"] is not None and p["lastSeen"]<=7*1440 for p in players),
|
||||
@@ -92,9 +106,9 @@ def player_list(conn,gid,params):
|
||||
if direction not in ("asc","desc"): raise ApiError(400,"Unknown direction.")
|
||||
known=sorted([p for p in players if p.get(key) is not None],key=lambda p:(p[key],p["playerId"]),reverse=direction=="desc")
|
||||
players=known+sorted([p for p in players if p.get(key) is None],key=lambda p:p["playerId"])
|
||||
size=integer(params,"page_size",50,100)
|
||||
size=integer(params,"page_size",20,20)
|
||||
page=min(integer(params,"page",1),max(1,math.ceil(len(players)/size)))
|
||||
return {"metrics":metrics,"fields":fields,"templates":templates,"playerRows":players[(page-1)*size:page*size],
|
||||
return {"metrics":metrics,"fields":fields,"templates":templates,"leaderboards":boards,"playerRows":players[(page-1)*size:page*size],
|
||||
"pagination":{"page":page,"pageSize":size,"total":len(players),"pages":max(1,math.ceil(len(players)/size))},
|
||||
"groupDefinitions":{"whales":"Top 20% of known positive Robux spenders","playtime":"Top 20% of known positive playtime","first":"First seen within 24 hours"}}
|
||||
|
||||
@@ -134,7 +148,11 @@ def retention(conn,gid,days):
|
||||
values=[round(100*sum(start+timedelta(days=d) in dates for dates in members)/len(members),1)
|
||||
if members and start+timedelta(days=d)<today else None for d in range(8)]
|
||||
grid.append({"name":label(start),"players":len(members),"values":values})
|
||||
return {"retention":rate(1),"day7":rate(7),"retentionGrid":{"columns":[f"Day {d}" for d in range(8)],"rows":grid},
|
||||
mature=[(d,m) for d,m in cohorts.items() if starts[0]<=d and d+timedelta(days=7)<today]
|
||||
denominator=sum(len(m) for d,m in mature)
|
||||
week=[round(100*sum(sum(d+timedelta(days=offset) in dates for dates in members) for d,members in mature)/denominator,2) if denominator else None for offset in range(1,8)]
|
||||
return {"retention":rate(1),"day7":rate(7),"weekRetention":{"labels":[f'Day {d}' for d in range(1,8)],
|
||||
"series":[{"name":"Exact-day retention","format":"percent","values":week}],"players":denominator},"retentionGrid":{"columns":[f"Day {d}" for d in range(8)],"rows":grid},
|
||||
"labels":[r["name"] for r in grid],"series":[{"name":"Day 1","values":[r["values"][1] for r in grid]},{"name":"Day 7","values":[r["values"][7] for r in grid]}]}
|
||||
|
||||
|
||||
@@ -189,7 +207,7 @@ def stage_funnel(runs,definition):
|
||||
if definition.get('end_event'): stages.append(('End',definition['end_event']))
|
||||
unknown=sum(r.get('stage_progress') is None for r in runs)
|
||||
survivors=[(r['stage_progress'],0) for r in runs if isinstance(r.get('stage_progress'),list)]
|
||||
count=len(runs); previous=count; result=[]
|
||||
count=len(survivors); previous=count; result=[]
|
||||
for index,(kind,event) in enumerate(stages):
|
||||
reached=[]
|
||||
for progress,cursor in survivors:
|
||||
@@ -197,7 +215,7 @@ def stage_funnel(runs,definition):
|
||||
except ValueError: pass
|
||||
survivors=reached
|
||||
current=count if index==0 else len(reached)
|
||||
known=index==0 or unknown==0
|
||||
known=True
|
||||
result.append({'label':kind,'event':event,'runs':current,
|
||||
'reachPercent':100*current/count if count and known else None,
|
||||
'dropoffPercent':100*(previous-current)/previous if index and previous and known else None,
|
||||
@@ -219,7 +237,7 @@ def activities(conn,gid,params,days):
|
||||
ids=[p["playerId"] for p in segment_players(all_players(conn,gid),segment)]
|
||||
clause="AND player_id=ANY(%s)"
|
||||
args.append(ids)
|
||||
runs=rows(conn,f"SELECT *,extract(epoch FROM ended_at-started_at)::double precision duration FROM activity_runs WHERE game_id=%s AND definition_id=%s AND started_at>=current_date-(%s-1) {clause} ORDER BY started_at",args)
|
||||
runs=rows(conn,f"SELECT *,extract(epoch FROM ended_at-started_at)::double precision duration FROM activity_runs WHERE game_id=%s AND definition_id=%s AND ended_at IS NOT NULL AND outcome IS NOT NULL AND started_at>=current_date-(%s-1) {clause} ORDER BY started_at",args)
|
||||
completed=[r for r in runs if r["duration"] is not None]
|
||||
times=[r["duration"] for r in completed]
|
||||
prompts=[r for r in runs if (r["prompt_count"] or 0)>0]
|
||||
@@ -292,19 +310,16 @@ def player_detail(conn,gid,pid,params):
|
||||
if not record: raise ApiError(404,"Player not found in this game.")
|
||||
fields,_=fields_templates(conn,gid)
|
||||
history=player_history(conn,gid,pid,fields,params)
|
||||
runs=rows(conn,"""SELECT d.name,a.started_at,a.ended_at,a.outcome,a.step_count,a.prompt_count,a.captured_values,
|
||||
extract(epoch FROM a.ended_at-a.started_at)::double precision duration FROM activity_runs a
|
||||
JOIN activity_definitions d ON d.game_id=a.game_id AND d.id=a.definition_id WHERE a.game_id=%s AND a.player_id=%s ORDER BY a.started_at DESC LIMIT 50""",(gid,pid))
|
||||
sales=rows(conn,"""SELECT s.receipt_id,s.player_id,p.roblox_user_id,c.name,c.roblox_product_id,s.unit_price_robux,s.quantity,
|
||||
(s.unit_price_robux*s.quantity)::double precision robux,s.purchased_at FROM product_sales s
|
||||
JOIN products c ON c.game_id=s.game_id AND c.id=s.product_id JOIN players p ON p.game_id=s.game_id AND p.id=s.player_id
|
||||
WHERE s.game_id=%s AND s.player_id=%s ORDER BY s.purchased_at DESC LIMIT 50""",(gid,pid))
|
||||
history.pop('snapshots',None)
|
||||
pages={kind:record_page(conn,gid,pid,kind,{}) for kind in ('snapshots','sessions','activities','events','purchases')}
|
||||
peers=all_players(conn,gid)
|
||||
groups=[title for key,title in [('whales','Whale'),('playtime','Top playtime'),('first','First-time')]
|
||||
if any(p['playerId']==pid for p in segment_players(peers,key))]
|
||||
return {"player":player_values(record),"fields":fields,"sessions":sessions(conn,gid,pid),"events":events(conn,gid,pid),"activities":runs,
|
||||
"sales":sales,"groups":groups,
|
||||
**history}
|
||||
return {"player":player_values(record),"fields":fields,"groups":groups,"recordPages":pages,
|
||||
"snapshots":pages['snapshots']['records'],"sessions":pages['sessions']['records'],"events":pages['events']['records'],
|
||||
"activities":pages['activities']['records'],"sales":pages['purchases']['records'],
|
||||
**player_inventory(conn,gid,pid),**history}
|
||||
|
||||
|
||||
|
||||
def dispatch(path,params):
|
||||
@@ -316,16 +331,20 @@ def dispatch(path,params):
|
||||
gid=int(parts[2]); root=game(conn,gid)
|
||||
base={"gameId":gid,"game":root}
|
||||
if len(parts)==3:
|
||||
return base|{"datasets":{k:f"/api/games/{gid}/{k}" for k in ('players','sessions','events','metrics','activities','overview','retention','engagement','monetization','gamepasses')}}
|
||||
return base|{"datasets":{k:f"/api/games/{gid}/{k}" for k in ('players','sessions','events','metrics','activities','overview','retention','engagement','monetization','gamepasses','prompts','reports','data-quality')}}
|
||||
days=integer(params,'days',14,90); route=parts[3]
|
||||
if route=='players':
|
||||
if len(parts)==6 and parts[4].isdigit(): return base|record_page(conn,gid,int(parts[4]),parts[5],params)
|
||||
if len(parts)==5 and parts[4].isdigit(): return base|player_detail(conn,gid,int(parts[4]),params)
|
||||
if len(parts)!=4: raise ApiError(404,'Unknown player route.')
|
||||
return base|player_list(conn,gid,params)
|
||||
if len(parts)!=4: raise ApiError(404,'Unknown dataset route.')
|
||||
if route=='activities': return base|activities(conn,gid,params,days)
|
||||
if route=='reports': return base|reports_for_game(conn,gid)
|
||||
if route=='data-quality': return base|quality_log(conn,gid,params)
|
||||
if route=='metrics': return base|root_metrics(conn,gid,days)
|
||||
if route=='engagement': return base|engagement(conn,gid,days)
|
||||
if route=='prompts': return base|prompt_analytics(conn,gid,days,params.get('source',['all'])[0])
|
||||
if route=='monetization': return base|monetization(conn,gid,days)
|
||||
if route=='gamepasses': return base|gamepasses(conn,gid)
|
||||
if route=='events': return base|event_analytics(conn,gid,days)
|
||||
@@ -338,7 +357,9 @@ def dispatch(path,params):
|
||||
data['engagement']=engagement(conn,gid,days,selected_ids)
|
||||
data['metrics'].update(data['engagement']['metrics'])
|
||||
data['segment']=params.get('segment',['all'])[0]
|
||||
if route=='overview': data['rootMetrics']=root_metrics(conn,gid,days)
|
||||
if route=='overview':
|
||||
data['rootMetrics']=root_metrics(conn,gid,days)
|
||||
data['healthReports']=reports_for_game(conn,gid)['reports']
|
||||
else:
|
||||
ret=retention(conn,gid,days); data['metrics']['day7']=ret['day7']
|
||||
data.update({k:v for k,v in ret.items() if k not in ('retention','day7')})
|
||||
|
||||
Reference in New Issue
Block a user