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