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