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