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