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