]> de.git.xonotic.org Git - xonotic/xonstat.git/blob - xonstat/views/submission.py
Fix indentation issues. Whoops.
[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 = player.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     player.stripped_nick = strip_colors(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     seq = Sequence('games_game_id_seq')\r
211     game_id = session.execute(seq)\r
212     game = Game(game_id=game_id, start_dt=start_dt, game_type_cd=game_type_cd,\r
213                 server_id=server_id, map_id=map_id, winner=winner)\r
214     session.add(game)\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             player.stripped_nick = strip_colors(nick[:128])\r
258         else:\r
259             player.nick = "Anonymous Player #{0}".format(player.player_id)\r
260             player.stripped_nick = player.nick\r
261 \r
262         hashkey = Hashkey(player_id=player.player_id, hashkey=hashkey)\r
263         session.add(hashkey)\r
264         log.debug("Created player {0} ({2}) with hashkey {1}".format(\r
265             player.player_id, hashkey.hashkey, player.nick.encode('utf-8')))\r
266 \r
267     return player\r
268 \r
269 def create_player_game_stat(session=None, player=None, \r
270         game=None, player_events=None):\r
271     """\r
272     Creates game statistics for a given player in a given game. Parameters:\r
273 \r
274     session - SQLAlchemy session factory\r
275     player - Player record of the player who owns the stats\r
276     game - Game record for the game to which the stats pertain\r
277     player_events - dictionary for the actual stats that need to be transformed\r
278     """\r
279 \r
280     # in here setup default values (e.g. if game type is CTF then\r
281     # set kills=0, score=0, captures=0, pickups=0, fckills=0, etc\r
282     # TODO: use game's create date here instead of now()\r
283     seq = Sequence('player_game_stats_player_game_stat_id_seq')\r
284     pgstat_id = session.execute(seq)\r
285     pgstat = PlayerGameStat(player_game_stat_id=pgstat_id, \r
286             create_dt=datetime.datetime.utcnow())\r
287 \r
288     # set player id from player record\r
289     pgstat.player_id = player.player_id\r
290 \r
291     #set game id from game record\r
292     pgstat.game_id = game.game_id\r
293 \r
294     # all games have a score\r
295     pgstat.score = 0\r
296 \r
297     if game.game_type_cd == 'dm':\r
298         pgstat.kills = 0\r
299         pgstat.deaths = 0\r
300         pgstat.suicides = 0\r
301     elif game.game_type_cd == 'ctf':\r
302         pgstat.kills = 0\r
303         pgstat.captures = 0\r
304         pgstat.pickups = 0\r
305         pgstat.drops = 0\r
306         pgstat.returns = 0\r
307         pgstat.carrier_frags = 0\r
308 \r
309     for (key,value) in player_events.items():\r
310         if key == 'n': pgstat.nick = value[:128]\r
311         if key == 't': pgstat.team = value\r
312         if key == 'rank': pgstat.rank = value\r
313         if key == 'alivetime': \r
314             pgstat.alivetime = datetime.timedelta(seconds=int(round(float(value))))\r
315         if key == 'scoreboard-drops': pgstat.drops = value\r
316         if key == 'scoreboard-returns': pgstat.returns = value\r
317         if key == 'scoreboard-fckills': pgstat.carrier_frags = value\r
318         if key == 'scoreboard-pickups': pgstat.pickups = value\r
319         if key == 'scoreboard-caps': pgstat.captures = value\r
320         if key == 'scoreboard-score': pgstat.score = value\r
321         if key == 'scoreboard-deaths': pgstat.deaths = value\r
322         if key == 'scoreboard-kills': pgstat.kills = value\r
323         if key == 'scoreboard-suicides': pgstat.suicides = value\r
324 \r
325     # check to see if we had a name, and if \r
326     # not use the name from the player id\r
327     if pgstat.nick == None:\r
328         pgstat.nick = player.nick\r
329 \r
330     # if the nick we end up with is different from the one in the\r
331     # player record, change the nick to reflect the new value\r
332     if pgstat.nick != player.nick and player.player_id > 2:\r
333         register_new_nick(session, player, pgstat.nick)\r
334 \r
335     # if the player is ranked #1 and it is a team game, set the game's winner\r
336     # to be the team of that player\r
337     # FIXME: this is a hack, should be using the 'W' field (not present)\r
338     if pgstat.rank == '1' and pgstat.team:\r
339         game.winner = pgstat.team\r
340         session.add(game)\r
341 \r
342     session.add(pgstat)\r
343 \r
344     return pgstat\r
345 \r
346 \r
347 def create_player_weapon_stats(session=None, player=None, \r
348         game=None, pgstat=None, player_events=None):\r
349     """\r
350     Creates accuracy records for each weapon used by a given player in a\r
351     given game. Parameters:\r
352 \r
353     session - SQLAlchemy session factory object\r
354     player - Player record who owns the weapon stats\r
355     game - Game record in which the stats were created\r
356     pgstat - Corresponding PlayerGameStat record for these weapon stats\r
357     player_events - dictionary containing the raw weapon values that need to be\r
358         transformed\r
359     """\r
360     pwstats = []\r
361 \r
362     for (key,value) in player_events.items():\r
363         matched = re.search("acc-(.*?)-cnt-fired", key)\r
364         if matched:\r
365             weapon_cd = matched.group(1)\r
366             seq = Sequence('player_weapon_stats_player_weapon_stats_id_seq')\r
367             pwstat_id = session.execute(seq)\r
368             pwstat = PlayerWeaponStat()\r
369             pwstat.player_weapon_stats_id = pwstat_id\r
370             pwstat.player_id = player.player_id\r
371             pwstat.game_id = game.game_id\r
372             pwstat.player_game_stat_id = pgstat.player_game_stat_id\r
373             pwstat.weapon_cd = weapon_cd\r
374 \r
375             if 'n' in player_events:\r
376                 pwstat.nick = player_events['n']\r
377             else:\r
378                 pwstat.nick = player_events['P']\r
379 \r
380             if 'acc-' + weapon_cd + '-cnt-fired' in player_events:\r
381                 pwstat.fired = int(round(float(\r
382                         player_events['acc-' + weapon_cd + '-cnt-fired'])))\r
383             if 'acc-' + weapon_cd + '-fired' in player_events:\r
384                 pwstat.max = int(round(float(\r
385                         player_events['acc-' + weapon_cd + '-fired'])))\r
386             if 'acc-' + weapon_cd + '-cnt-hit' in player_events:\r
387                 pwstat.hit = int(round(float(\r
388                         player_events['acc-' + weapon_cd + '-cnt-hit'])))\r
389             if 'acc-' + weapon_cd + '-hit' in player_events:\r
390                 pwstat.actual = int(round(float(\r
391                         player_events['acc-' + weapon_cd + '-hit'])))\r
392             if 'acc-' + weapon_cd + '-frags' in player_events:\r
393                 pwstat.frags = int(round(float(\r
394                         player_events['acc-' + weapon_cd + '-frags'])))\r
395 \r
396             log.debug(pwstat)\r
397             session.add(pwstat)\r
398             log.debug(pwstat)\r
399             pwstats.append(pwstat)\r
400 \r
401     return pwstats\r
402 \r
403 \r
404 def parse_body(request):\r
405     """\r
406     Parses the POST request body for a stats submission\r
407     """\r
408     # storage vars for the request body\r
409     game_meta = {}\r
410     player_events = {}\r
411     current_team = None\r
412     players = []\r
413     \r
414     log.debug("----- BEGIN REQUEST BODY -----")\r
415     log.debug(request.body)\r
416     log.debug("----- END REQUEST BODY -----")\r
417 \r
418     for line in request.body.split('\n'):\r
419         try:\r
420             (key, value) = line.strip().split(' ', 1)\r
421 \r
422             # Server (S) and Nick (n) fields can have international characters.\r
423             # We encode these as UTF-8.\r
424             if key in 'S' 'n':\r
425                 value = unicode(value, 'utf-8')\r
426     \r
427             if key in 'V' 'T' 'G' 'M' 'S' 'C' 'R' 'W':\r
428                 game_meta[key] = value\r
429 \r
430             if key == 'P':\r
431                 # if we were working on a player record already, append\r
432                 # it and work on a new one (only set team info)\r
433                 if len(player_events) != 0:\r
434                     players.append(player_events)\r
435                     player_events = {}\r
436     \r
437                 player_events[key] = value\r
438 \r
439             if key == 'e':\r
440                 (subkey, subvalue) = value.split(' ', 1)\r
441                 player_events[subkey] = subvalue\r
442             if key == 'n':\r
443                 player_events[key] = value\r
444             if key == 't':\r
445                 player_events[key] = value\r
446         except:\r
447             # no key/value pair - move on to the next line\r
448             pass\r
449     \r
450     # add the last player we were working on\r
451     if len(player_events) > 0:\r
452         players.append(player_events)\r
453 \r
454     return (game_meta, players)\r
455 \r
456 \r
457 def create_player_stats(session=None, player=None, game=None, \r
458         player_events=None):\r
459     """\r
460     Creates player game and weapon stats according to what type of player\r
461     """\r
462     pgstat = create_player_game_stat(session=session, \r
463         player=player, game=game, player_events=player_events)\r
464 \r
465     #TODO: put this into a config setting in the ini file?\r
466     if not re.search('^bot#\d+$', player_events['P']):\r
467         create_player_weapon_stats(session=session, \r
468             player=player, game=game, pgstat=pgstat,\r
469             player_events=player_events)\r
470     \r
471 \r
472 def stats_submit(request):\r
473     """\r
474     Entry handler for POST stats submissions.\r
475     """\r
476     try:\r
477         session = DBSession()\r
478 \r
479         (idfp, status) = verify_request(request)\r
480         if not idfp:\r
481             raise pyramid.httpexceptions.HTTPUnauthorized\r
482      \r
483         (game_meta, players) = parse_body(request)  \r
484      \r
485         if not has_required_metadata(game_meta):\r
486             log.debug("Required game meta fields missing. "\\r
487                     "Can't continue.")\r
488             raise pyramid.exceptions.HTTPUnprocessableEntity\r
489      \r
490         if not is_supported_gametype(game_meta['G']):\r
491             raise pyramid.httpexceptions.HTTPOk\r
492      \r
493         if not has_minimum_real_players(request.registry.settings, players):\r
494             log.debug("The number of real players is below the minimum. " + \r
495                 "Stats will be ignored.")\r
496             raise pyramid.httpexceptions.HTTPOk\r
497      \r
498         server = get_or_create_server(session=session, hashkey=idfp, \r
499                 name=game_meta['S'])\r
500      \r
501         gmap = get_or_create_map(session=session, name=game_meta['M'])\r
502         log.debug(gmap)\r
503      \r
504         game = create_game(session=session, \r
505                 start_dt=datetime.datetime(\r
506                     *time.gmtime(float(game_meta['T']))[:6]), \r
507                 server_id=server.server_id, game_type_cd=game_meta['G'], \r
508                    map_id=gmap.map_id)\r
509         log.debug(gmap)\r
510      \r
511         # find or create a record for each player\r
512         # and add stats for each if they were present at the end\r
513         # of the game\r
514         for player_events in players:\r
515             if 'n' in player_events:\r
516                 nick = player_events['n']\r
517             else:\r
518                 nick = None\r
519  \r
520             if 'matches' in player_events and 'scoreboardvalid' \\r
521                 in player_events:\r
522                 player = get_or_create_player(session=session, \r
523                     hashkey=player_events['P'], nick=nick)\r
524                 log.debug('Creating stats for %s' % player_events['P'])\r
525                 create_player_stats(session=session, player=player, game=game, \r
526                         player_events=player_events)\r
527      \r
528         session.commit()\r
529         log.debug('Success! Stats recorded.')\r
530         return Response('200 OK')\r
531     except Exception as e:\r
532         session.rollback()\r
533         raise e\r