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