]> de.git.xonotic.org Git - xonotic/xonstat.git/blob - xonstat/views/submission.py
Fix partitioned table error. Change to new registry view.
[xonotic/xonstat.git] / xonstat / views / submission.py
1 import datetime\r
2 import logging\r
3 import pyramid.httpexceptions\r
4 import re\r
5 import time\r
6 from pyramid.response import Response\r
7 from sqlalchemy import Sequence\r
8 from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound\r
9 from xonstat.d0_blind_id import d0_blind_id_verify\r
10 from xonstat.models import *\r
11 from xonstat.util import strip_colors\r
12 \r
13 log = logging.getLogger(__name__)\r
14 \r
15 def is_supported_gametype(gametype):\r
16     """Whether a gametype is supported or not"""\r
17     flg_supported = True\r
18 \r
19     if gametype == 'cts' or gametype == 'ca' or gametype == 'lms':\r
20         flg_supported = False\r
21 \r
22     return flg_supported\r
23 \r
24 \r
25 def verify_request(request):\r
26     try:\r
27         (idfp, status) = d0_blind_id_verify(\r
28                 sig=request.headers['X-D0-Blind-Id-Detached-Signature'],\r
29                 querystring='',\r
30                 postdata=request.body)\r
31 \r
32         log.debug('\nidfp: {0}\nstatus: {1}'.format(idfp, status))\r
33     except: \r
34         idfp = None\r
35         status = None\r
36 \r
37     return (idfp, status)\r
38 \r
39 \r
40 def has_minimum_real_players(settings, player_events):\r
41     """\r
42     Determines if the collection of player events has enough "real" players\r
43     to store in the database. The minimum setting comes from the config file\r
44     under the setting xonstat.minimum_real_players.\r
45     """\r
46     flg_has_min_real_players = True\r
47 \r
48     try: \r
49         minimum_required_players = int(\r
50                 settings['xonstat.minimum_required_players'])\r
51     except:\r
52         minimum_required_players = 2\r
53 \r
54     real_players = 0\r
55     for events in player_events:\r
56         if is_real_player(events):\r
57             real_players += 1\r
58 \r
59     #TODO: put this into a config setting in the ini file?\r
60     if real_players < minimum_required_players:\r
61         flg_has_min_real_players = False\r
62 \r
63     return flg_has_min_real_players\r
64 \r
65 \r
66 def has_required_metadata(metadata):\r
67     """\r
68     Determines if a give set of metadata has enough data to create a game,\r
69     server, and map with.\r
70     """\r
71     flg_has_req_metadata = True\r
72 \r
73     if 'T' not in metadata or\\r
74         'G' not in metadata or\\r
75         'M' not in metadata or\\r
76         'S' not in metadata:\r
77             flg_has_req_metadata = False\r
78 \r
79     return flg_has_req_metadata\r
80 \r
81     \r
82 def is_real_player(events):\r
83     """\r
84     Determines if a given set of player events correspond with a player who\r
85     \r
86     1) is not a bot (P event does not look like a bot)\r
87     2) played in the game (matches 1)\r
88     3) was present at the end of the game (scoreboardvalid 1)\r
89 \r
90     Returns True if the player meets the above conditions, and false otherwise.\r
91     """\r
92     flg_is_real = False\r
93 \r
94     if not events['P'].startswith('bot'):\r
95         # removing 'joins' here due to bug, but it should be here\r
96         if 'matches' in events and 'scoreboardvalid' in events:\r
97             flg_is_real = True\r
98 \r
99     return flg_is_real\r
100 \r
101 \r
102 def register_new_nick(session, player, new_nick):\r
103     """\r
104     Change the player record's nick to the newly found nick. Store the old\r
105     nick in the player_nicks table for that player.\r
106 \r
107     session - SQLAlchemy database session factory\r
108     player - player record whose nick is changing\r
109     new_nick - the new nickname\r
110     """\r
111     # see if that nick already exists\r
112     stripped_nick = strip_colors(player.nick)\r
113     try:\r
114         player_nick = session.query(PlayerNick).filter_by(\r
115                 player_id=player.player_id, stripped_nick=stripped_nick).one()\r
116     except NoResultFound, e:\r
117             # player_id/stripped_nick not found, create one\r
118         # but we don't store "Anonymous Player #N"\r
119         if not re.search('^Anonymous Player #\d+$', player.nick):\r
120             player_nick = PlayerNick()\r
121             player_nick.player_id = player.player_id\r
122             player_nick.stripped_nick = stripped_nick\r
123             player_nick.nick = player.nick\r
124             session.add(player_nick)\r
125 \r
126     # We change to the new nick regardless\r
127     player.nick = new_nick\r
128     session.add(player)\r
129 \r
130 \r
131 def get_or_create_server(session=None, name=None, hashkey=None):\r
132     """\r
133     Find a server by name or create one if not found. Parameters:\r
134 \r
135     session - SQLAlchemy database session factory\r
136     name - server name of the server to be found or created\r
137     hashkey - server hashkey\r
138     """\r
139     try:\r
140         # find one by that name, if it exists\r
141         server = session.query(Server).filter_by(name=name).one()\r
142 \r
143         # store new hashkey\r
144         if server.hashkey != hashkey:\r
145             server.hashkey = hashkey\r
146             session.add(server)\r
147 \r
148         log.debug("Found existing server {0}".format(server.server_id))\r
149 \r
150     except MultipleResultsFound, e:\r
151         # multiple found, so also filter by hashkey\r
152         server = session.query(Server).filter_by(name=name).\\r
153                 filter_by(hashkey=hashkey).one()\r
154         log.debug("Found existing server {0}".format(server.server_id))\r
155 \r
156     except NoResultFound, e:\r
157         # not found, create one\r
158         server = Server(name=name, hashkey=hashkey)\r
159         session.add(server)\r
160         session.flush()\r
161         log.debug("Created server {0} with hashkey {1}".format(\r
162             server.server_id, server.hashkey))\r
163 \r
164     return server\r
165 \r
166 \r
167 def get_or_create_map(session=None, name=None):\r
168     """\r
169     Find a map by name or create one if not found. Parameters:\r
170 \r
171     session - SQLAlchemy database session factory\r
172     name - map name of the map to be found or created\r
173     """\r
174     try:\r
175         # find one by the name, if it exists\r
176         gmap = session.query(Map).filter_by(name=name).one()\r
177         log.debug("Found map id {0}: {1}".format(gmap.map_id, \r
178             gmap.name))\r
179     except NoResultFound, e:\r
180         gmap = Map(name=name)\r
181         session.add(gmap)\r
182         session.flush()\r
183         log.debug("Created map id {0}: {1}".format(gmap.map_id,\r
184             gmap.name))\r
185     except MultipleResultsFound, e:\r
186         # multiple found, so use the first one but warn\r
187         log.debug(e)\r
188         gmaps = session.query(Map).filter_by(name=name).order_by(\r
189                 Map.map_id).all()\r
190         gmap = gmaps[0]\r
191         log.debug("Found map id {0}: {1} but found \\r
192                 multiple".format(gmap.map_id, gmap.name))\r
193 \r
194     return gmap\r
195 \r
196 \r
197 def create_game(session=None, start_dt=None, game_type_cd=None, \r
198         server_id=None, map_id=None, winner=None):\r
199     """\r
200     Creates a game. Parameters:\r
201 \r
202     session - SQLAlchemy database session factory\r
203     start_dt - when the game started (datetime object)\r
204     game_type_cd - the game type of the game being played\r
205     server_id - server identifier of the server hosting the game\r
206     map_id - map on which the game was played\r
207     winner - the team id of the team that won\r
208     """\r
209     seq = Sequence('games_game_id_seq')\r
210     game_id = session.execute(seq)\r
211     game = Game(game_id=game_id, start_dt=start_dt, game_type_cd=game_type_cd,\r
212                 server_id=server_id, map_id=map_id, winner=winner)\r
213     session.add(game)\r
214     log.debug("Created game id {0} on server {1}, map {2} at \\r
215             {3}".format(game.game_id, \r
216                 server_id, map_id, start_dt))\r
217 \r
218     return game\r
219 \r
220 \r
221 def get_or_create_player(session=None, hashkey=None, nick=None):\r
222     """\r
223     Finds a player by hashkey or creates a new one (along with a\r
224     corresponding hashkey entry. Parameters:\r
225 \r
226     session - SQLAlchemy database session factory\r
227     hashkey - hashkey of the player to be found or created\r
228     nick - nick of the player (in case of a first time create)\r
229     """\r
230     # if we have a bot\r
231     if re.search('^bot#\d+$', hashkey):\r
232         player = session.query(Player).filter_by(player_id=1).one()\r
233     # if we have an untracked player\r
234     elif re.search('^player#\d+$', hashkey):\r
235         player = session.query(Player).filter_by(player_id=2).one()\r
236     # else it is a tracked player\r
237     else:\r
238         # see if the player is already in the database\r
239         # if not, create one and the hashkey along with it\r
240         try:\r
241             hashkey = session.query(Hashkey).filter_by(\r
242                     hashkey=hashkey).one()\r
243             player = session.query(Player).filter_by(\r
244                     player_id=hashkey.player_id).one()\r
245             log.debug("Found existing player {0} with hashkey {1}".format(\r
246                 player.player_id, hashkey.hashkey))\r
247         except:\r
248             player = Player()\r
249             session.add(player)\r
250             session.flush()\r
251 \r
252             # if nick is given to us, use it. If not, use "Anonymous Player"\r
253             # with a suffix added for uniqueness.\r
254             if nick:\r
255                 player.nick = nick[:128]\r
256             else:\r
257                 player.nick = "Anonymous Player #{0}".format(player.player_id)\r
258 \r
259             hashkey = Hashkey(player_id=player.player_id, hashkey=hashkey)\r
260             session.add(hashkey)\r
261             log.debug("Created player {0} ({2}) with hashkey {1}".format(\r
262                 player.player_id, hashkey.hashkey, player.nick.encode('utf-8')))\r
263 \r
264     return player\r
265 \r
266 def create_player_game_stat(session=None, player=None, \r
267         game=None, player_events=None):\r
268     """\r
269     Creates game statistics for a given player in a given game. Parameters:\r
270 \r
271     session - SQLAlchemy session factory\r
272     player - Player record of the player who owns the stats\r
273     game - Game record for the game to which the stats pertain\r
274     player_events - dictionary for the actual stats that need to be transformed\r
275     """\r
276 \r
277     # in here setup default values (e.g. if game type is CTF then\r
278     # set kills=0, score=0, captures=0, pickups=0, fckills=0, etc\r
279     # TODO: use game's create date here instead of now()\r
280     seq = Sequence('player_game_stats_player_game_stat_id_seq')\r
281     pgstat_id = session.execute(seq)\r
282     pgstat = PlayerGameStat(player_game_stat_id=pgstat_id, \r
283             create_dt=datetime.datetime.now())\r
284 \r
285     # set player id from player record\r
286     pgstat.player_id = player.player_id\r
287 \r
288     #set game id from game record\r
289     pgstat.game_id = game.game_id\r
290 \r
291     # all games have a score\r
292     pgstat.score = 0\r
293 \r
294     if game.game_type_cd == 'dm':\r
295         pgstat.kills = 0\r
296         pgstat.deaths = 0\r
297         pgstat.suicides = 0\r
298     elif game.game_type_cd == 'ctf':\r
299         pgstat.kills = 0\r
300         pgstat.captures = 0\r
301         pgstat.pickups = 0\r
302         pgstat.drops = 0\r
303         pgstat.returns = 0\r
304         pgstat.carrier_frags = 0\r
305 \r
306     for (key,value) in player_events.items():\r
307         if key == 'n': pgstat.nick = value[:128]\r
308         if key == 't': pgstat.team = value\r
309         if key == 'rank': pgstat.rank = value\r
310         if key == 'alivetime': \r
311             pgstat.alivetime = datetime.timedelta(seconds=int(round(float(value))))\r
312         if key == 'scoreboard-drops': pgstat.drops = value\r
313         if key == 'scoreboard-returns': pgstat.returns = value\r
314         if key == 'scoreboard-fckills': pgstat.carrier_frags = value\r
315         if key == 'scoreboard-pickups': pgstat.pickups = value\r
316         if key == 'scoreboard-caps': pgstat.captures = value\r
317         if key == 'scoreboard-score': pgstat.score = value\r
318         if key == 'scoreboard-deaths': pgstat.deaths = value\r
319         if key == 'scoreboard-kills': pgstat.kills = value\r
320         if key == 'scoreboard-suicides': pgstat.suicides = value\r
321 \r
322     # check to see if we had a name, and if \r
323     # not use the name from the player id\r
324     if pgstat.nick == None:\r
325         pgstat.nick = player.nick\r
326 \r
327     # if the nick we end up with is different from the one in the\r
328     # player record, change the nick to reflect the new value\r
329     if pgstat.nick != player.nick and player.player_id > 2:\r
330         register_new_nick(session, player, pgstat.nick)\r
331 \r
332     # if the player is ranked #1 and it is a team game, set the game's winner\r
333     # to be the team of that player\r
334     # FIXME: this is a hack, should be using the 'W' field (not present)\r
335     if pgstat.rank == '1' and pgstat.team:\r
336         game.winner = pgstat.team\r
337         session.add(game)\r
338 \r
339     session.add(pgstat)\r
340 \r
341     return pgstat\r
342 \r
343 \r
344 def create_player_weapon_stats(session=None, player=None, \r
345         game=None, pgstat=None, player_events=None):\r
346     """\r
347     Creates accuracy records for each weapon used by a given player in a\r
348     given game. Parameters:\r
349 \r
350     session - SQLAlchemy session factory object\r
351     player - Player record who owns the weapon stats\r
352     game - Game record in which the stats were created\r
353     pgstat - Corresponding PlayerGameStat record for these weapon stats\r
354     player_events - dictionary containing the raw weapon values that need to be\r
355         transformed\r
356     """\r
357     pwstats = []\r
358 \r
359     for (key,value) in player_events.items():\r
360         matched = re.search("acc-(.*?)-cnt-fired", key)\r
361         if matched:\r
362             weapon_cd = matched.group(1)\r
363             seq = Sequence('player_weapon_stats_player_weapon_stats_id_seq')\r
364             pwstat_id = session.execute(seq)\r
365             pwstat = PlayerWeaponStat()\r
366             pwstat.player_weapon_stats_id = pwstat_id\r
367             pwstat.player_id = player.player_id\r
368             pwstat.game_id = game.game_id\r
369             pwstat.player_game_stat_id = pgstat.player_game_stat_id\r
370             pwstat.weapon_cd = weapon_cd\r
371 \r
372             if 'n' in player_events:\r
373                 pwstat.nick = player_events['n']\r
374             else:\r
375                 pwstat.nick = player_events['P']\r
376 \r
377             if 'acc-' + weapon_cd + '-cnt-fired' in player_events:\r
378                 pwstat.fired = int(round(float(\r
379                         player_events['acc-' + weapon_cd + '-cnt-fired'])))\r
380             if 'acc-' + weapon_cd + '-fired' in player_events:\r
381                 pwstat.max = int(round(float(\r
382                         player_events['acc-' + weapon_cd + '-fired'])))\r
383             if 'acc-' + weapon_cd + '-cnt-hit' in player_events:\r
384                 pwstat.hit = int(round(float(\r
385                         player_events['acc-' + weapon_cd + '-cnt-hit'])))\r
386             if 'acc-' + weapon_cd + '-hit' in player_events:\r
387                 pwstat.actual = int(round(float(\r
388                         player_events['acc-' + weapon_cd + '-hit'])))\r
389             if 'acc-' + weapon_cd + '-frags' in player_events:\r
390                 pwstat.frags = int(round(float(\r
391                         player_events['acc-' + weapon_cd + '-frags'])))\r
392 \r
393             log.debug(pwstat)\r
394             session.add(pwstat)\r
395             log.debug(pwstat)\r
396             pwstats.append(pwstat)\r
397 \r
398     return pwstats\r
399 \r
400 \r
401 def parse_body(request):\r
402     """\r
403     Parses the POST request body for a stats submission\r
404     """\r
405     # storage vars for the request body\r
406     game_meta = {}\r
407     player_events = {}\r
408     current_team = None\r
409     players = []\r
410     \r
411     log.debug("----- BEGIN REQUEST BODY -----")\r
412     log.debug(request.body)\r
413     log.debug("----- END REQUEST BODY -----")\r
414 \r
415     for line in request.body.split('\n'):\r
416         try:\r
417             (key, value) = line.strip().split(' ', 1)\r
418 \r
419             # Server (S) and Nick (n) fields can have international characters.\r
420             # We encode these as UTF-8.\r
421             if key in 'S' 'n':\r
422                 value = unicode(value, 'utf-8')\r
423     \r
424             if key in 'V' 'T' 'G' 'M' 'S' 'C' 'R' 'W':\r
425                 game_meta[key] = value\r
426 \r
427             if key == 'P':\r
428                 # if we were working on a player record already, append\r
429                 # it and work on a new one (only set team info)\r
430                 if len(player_events) != 0:\r
431                     players.append(player_events)\r
432                     player_events = {}\r
433     \r
434                 player_events[key] = value\r
435 \r
436             if key == 'e':\r
437                 (subkey, subvalue) = value.split(' ', 1)\r
438                 player_events[subkey] = subvalue\r
439             if key == 'n':\r
440                 player_events[key] = value\r
441             if key == 't':\r
442                 player_events[key] = value\r
443         except:\r
444             # no key/value pair - move on to the next line\r
445             pass\r
446     \r
447     # add the last player we were working on\r
448     if len(player_events) > 0:\r
449         players.append(player_events)\r
450 \r
451     return (game_meta, players)\r
452 \r
453 \r
454 def create_player_stats(session=None, player=None, game=None, \r
455         player_events=None):\r
456     """\r
457     Creates player game and weapon stats according to what type of player\r
458     """\r
459     pgstat = create_player_game_stat(session=session, \r
460         player=player, game=game, player_events=player_events)\r
461 \r
462     #TODO: put this into a config setting in the ini file?\r
463     if not re.search('^bot#\d+$', player_events['P']):\r
464         create_player_weapon_stats(session=session, \r
465             player=player, game=game, pgstat=pgstat,\r
466             player_events=player_events)\r
467     \r
468 \r
469 def stats_submit(request):\r
470     """\r
471     Entry handler for POST stats submissions.\r
472     """\r
473     try:\r
474         session = DBSession()\r
475 \r
476         (idfp, status) = verify_request(request)\r
477         if not idfp:\r
478             raise pyramid.httpexceptions.HTTPUnauthorized\r
479      \r
480         (game_meta, players) = parse_body(request)  \r
481      \r
482         if not has_required_metadata(game_meta):\r
483             log.debug("Required game meta fields missing. "\\r
484                     "Can't continue.")\r
485             raise pyramid.exceptions.HTTPUnprocessableEntity\r
486      \r
487         if not is_supported_gametype(game_meta['G']):\r
488             raise pyramid.httpexceptions.HTTPOk\r
489      \r
490         if not has_minimum_real_players(request.registry.settings, players):\r
491             log.debug("The number of real players is below the minimum. " + \r
492                 "Stats will be ignored.")\r
493             raise pyramid.httpexceptions.HTTPOk\r
494      \r
495         server = get_or_create_server(session=session, hashkey=idfp, \r
496                 name=game_meta['S'])\r
497      \r
498         gmap = get_or_create_map(session=session, name=game_meta['M'])\r
499         log.debug(gmap)\r
500      \r
501         game = create_game(session=session, \r
502                 start_dt=datetime.datetime(\r
503                     *time.gmtime(float(game_meta['T']))[:6]), \r
504                 server_id=server.server_id, game_type_cd=game_meta['G'], \r
505                    map_id=gmap.map_id)\r
506         log.debug(gmap)\r
507      \r
508         # find or create a record for each player\r
509         # and add stats for each if they were present at the end\r
510         # of the game\r
511         for player_events in players:\r
512             if 'n' in player_events:\r
513                 nick = player_events['n']\r
514             else:\r
515                 nick = None\r
516  \r
517             if 'matches' in player_events and 'scoreboardvalid' \\r
518                 in player_events:\r
519                 player = get_or_create_player(session=session, \r
520                     hashkey=player_events['P'], nick=nick)\r
521                 log.debug('Creating stats for %s' % player_events['P'])\r
522                 create_player_stats(session=session, player=player, game=game, \r
523                         player_events=player_events)\r
524      \r
525         session.commit()\r
526         log.debug('Success! Stats recorded.')\r
527         return Response('200 OK')\r
528     except Exception as e:\r
529         session.rollback()\r
530         raise e\r