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