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