From 5b7a78223ec9e42b3f7fcac9f68e3f586aa2ea57 Mon Sep 17 00:00:00 2001 From: Timothy Allen Date: Tue, 14 Aug 2018 10:54:02 +0200 Subject: [PATCH] Initial commit. --- aacstats.py | 127 +++++++++++++ aacstats.wsgi | 3 + load_spreadsheet.py | 435 +++++++++++++++++++++++++++++++++++++++++++ static/style.css | 32 ++++ templates/index.html | 122 ++++++++++++ 5 files changed, 719 insertions(+) create mode 100644 aacstats.py create mode 100644 aacstats.wsgi create mode 100755 load_spreadsheet.py create mode 100644 static/style.css create mode 100644 templates/index.html diff --git a/aacstats.py b/aacstats.py new file mode 100644 index 0000000..0088178 --- /dev/null +++ b/aacstats.py @@ -0,0 +1,127 @@ +from flask import Flask, request, render_template, url_for +import math +import MySQLdb +import MySQLdb.cursors +import datetime as dt +app = Flask(__name__) + + +@app.route('/') +def index(): + start = int(request.args.get('start', '0')) + limit = int(request.args.get('limit', '10')) + results = read_db(start, limit) + total = int(total_entries()) + return render_template('index.html', ltype='index', request=request, start=start, limit=limit, results=results, total=total) + +@app.route('/race//') +def race(start=0, year=None, title=None): + start = int(request.args.get('start', '0')) + limit = int(request.args.get('limit', '10')) + total = int(total_entries(event=title, year=year)) + results = read_db(start, limit, event=title, year=year) + return render_template('index.html', ltype='race', title=title, year=year, request=request, start=start, limit=limit, results=results, total=total) + +@app.route('/person/<title>') +@app.route('/person/<title>/<year>') +def person(start=0, title=None, year=None): + start = int(request.args.get('start', '0')) + limit = int(request.args.get('limit', '10')) + total = int(total_entries(person=title, year=year)) + results = read_db(start, limit, person=title, year=year) + return render_template('index.html', ltype='person', title=title, year=year, request=request, start=start, limit=limit, results=results, total=total) + +@app.route('/licence/<year>/<title>') +def licence(start=0, year=dt.datetime.now().year, title=None): + start = int(request.args.get('start', '0')) + limit = int(request.args.get('limit', '10')) + total = int(total_entries(licence=title, year=year)) + results = read_db(start, limit, licence=title, year=year) + return render_template('index.html', ltype='licence', title=title, year=year, request=request, start=start, limit=limit, results=results, total=total) + + +def read_db(start=0, limit=10, person=None, licence=None, event=None, year=None): + db = MySQLdb.connect(user = 'aac', passwd = 'saOAcCWHg4LaoSSA', db = 'AAC', cursorclass = MySQLdb.cursors.DictCursor) + c = db.cursor() + where = 'club LIKE "AAC"' + if person: + where += ' AND CONCAT_WS(" ", name, surname) LIKE "{}"'.format(person) + if event: + where += ' AND event LIKE "{}"'.format(event) + if licence: + where += ' AND licence LIKE "{}"'.format(licence) + if year: + firstdate = dt.datetime.min + lastdate = dt.datetime.max + firstdate = firstdate.replace(year=int(year)) + lastdate = lastdate.replace(year=int(year)) + where += ' AND date > "{}" AND date < "{}"'.format(firstdate, lastdate) + sql = 'SELECT * FROM `results` WHERE {} ORDER BY date DESC, event, position LIMIT {},{};'.format(where, start, limit) + c.execute(sql) + results = c.fetchall() + return results + +@app.template_filter('total_entries') +def total_entries(person=None, licence=None, event=None, year=None): + db = MySQLdb.connect(user = 'aac', passwd = 'saOAcCWHg4LaoSSA', db = 'AAC', cursorclass = MySQLdb.cursors.DictCursor) + c = db.cursor() + where = 'club LIKE "AAC"' + if person: + where += ' AND CONCAT_WS(" ", name, surname) LIKE "{}"'.format(person) + if event: + where += ' AND event LIKE "{}"'.format(event) + if licence: + where += ' AND licence LIKE "{}"'.format(licence) + if year: + firstdate = dt.datetime.min + lastdate = dt.datetime.max + firstdate = firstdate.replace(year=int(year)) + lastdate = lastdate.replace(year=int(year)) + where += ' AND date > "{}" AND date < "{}"'.format(firstdate, lastdate) + sql = 'SELECT COUNT(*) FROM `results` WHERE {}'.format(where) + c.execute(sql) + for x in c.fetchone().values(): + return x + +@app.template_filter('pace') +def pace(time): + return (dt.datetime(1,1,1) + time).strftime('%M:%S') + +@app.template_filter('clean_date') +def clean_date(time): + if time.month == 1 and time.day == 1: + return time.strftime('%Y') + return time.strftime('%Y-%m-%d') + +@app.template_filter('year') +def year(time): + return time.strftime('%Y') + +@app.template_filter('ordinal') +def ordinal(n): + return "%d%s" % (n,"tsnrhtdd"[(math.floor(n/10)%10!=1)*(n%10<4)*n%10::4]) + +if __name__ == '__main__': + app.run(debug=True) + + +# most race KMs in the year, by distance +# fastest race pace over the year (time / KMs) +# individual KMs, and race results (race, position, time) by name +# - and by race number and year +# by race +# - and by gender + +# default: list of races (sorted by recent) +# tabs: list of [races (sorted by recent), people (sorted by total kms for the year), licences for the year (sorted by number of races), podiums/winners (people sorted by total_position/total_races), ] +# click to expand by [race (all AAC members by position), person (races by recent), person (races by pace)] +# SEARCH + +# /?sort={distance,pace}&sex={m,f} +# /race/2018/HEWAT ETC?sort={position,pace}&sex={m,f} +# /person/Timothy Allen?sort={pace,date} +# /person/Timothy Allen/2018 +# /license/2018/4356 +# /license/2018/4356/2018 +# +# TODO LIMIT/pagination diff --git a/aacstats.wsgi b/aacstats.wsgi new file mode 100644 index 0000000..527904d --- /dev/null +++ b/aacstats.wsgi @@ -0,0 +1,3 @@ +import sys +sys.path.insert(0, '/var/www/node/aac') +from aacstats import app as application diff --git a/load_spreadsheet.py b/load_spreadsheet.py new file mode 100755 index 0000000..d724296 --- /dev/null +++ b/load_spreadsheet.py @@ -0,0 +1,435 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +''' Utility to load WPA results sheets into a MySQL database.''' +__author__ = 'Timothy Allen' +__email__ = 'tim@allen.org.za' +__license__ = 'MIT' + +import bs4 +import urllib.request +import urllib.parse +import json +import mailbox +import email +import mimetypes +import csv +import xlrd +import MySQLdb +import argparse +import datetime as dt +import dateutil.parser as dtp +import logging +import uuid +import os +import re +import sys +import tempfile +import pprint + +# Set up MySQL database, if not done +# Read Excel/ODS/CSV database into MySQL +# Check MIME attachments in email for spreadsheet, and load that *** +# Then display the data in a (separate) web application + +# The user is in /etc/dovecot/users +# Password is zi6ohYae0OeYie8eegei (not that you'll ever need it) +MAILDIR = '/var/mail/virtual/aac/Maildir' + +log = logging.getLogger(__file__) +pp = pprint.PrettyPrinter(indent=4) + +def main(): + if sys.version_info < (3, 2): + raise Exception( + 'Unsupported Python version, please use at least Python 3.2') + + args = parse_arguments() + rows = [] + + if args.scrape_web: + spreadsheets = [] + uniqurl = [] + wpa = 'http://www.wpa.org.za/Events/DynamicEvents.asmx/BuildEventDisplay' + for year in range(2016, dt.datetime.now().year + 1): + log.debug("Finding results for %s" % year); + args = {"WPAExtra":"True","TimeColumn":"True","entityid":"674417","selectedyear":year,"selectedmonth":0,"commissionid":"0","selectedstate":"0","categoryid":0,"themeid":"46"} + data = bytes(json.dumps(args).encode('utf8')) + req = urllib.request.Request(wpa, data=data, headers={'content-type': 'application/json'}) + with urllib.request.urlopen(req) as response: + data = json.loads(response.read().decode('utf8')) + page, *_ = data.values() # get the first value + soup = bs4.BeautifulSoup(page, 'html.parser') + for event in soup.find_all('tr'): + raceinfo = dict() + link = event.find('a', href=re.compile('.xls$')) + name = event.find('td', class_=re.compile('EventHeadline')) + date = event.find('td', class_=re.compile('EventDate')) + dist = event.find('td', class_=re.compile('EventDist'), string=re.compile('^\s*[\d+\.]\s*(KM)?\s*$')) + if link is not None and name is not None: + if not link['href'] in uniqurl: + uniqurl.append(link['href']) + raceinfo['url'] = link['href'] + raceinfo['event'] = name.string + raceinfo['date'] = dtp.parse(date.string, dayfirst=True) + raceinfo['distance'] = None + if dist is not None: + raceinfo['distance'] = dist.string + spreadsheets.append(raceinfo) + for race in spreadsheets: + url = race['url'] + with urllib.request.urlopen(url) as response, tempfile.TemporaryDirectory() as tmpdir: + log.info("Loading data from URL %s" % race['url']) + data = response.read() + urlparts = urllib.parse.urlparse(url) + filename = os.path.basename(urlparts.path) + filepath = os.path.join(tmpdir, filename) + with open(filepath, 'wb') as fp: + fp.write(data) + try: + rows = read_spreadsheet(filepath, src=url, eventname=race['event'], eventdate=race['date'], eventdistance=race['distance']) + except: + log.warning("ERROR: Unable to load data from URL %s" % url) + raise + else: + load_into_db(rows) + + + elif args.input_file: + rows = read_spreadsheet(args.input_file, src=args.input_file) + log.info("Loading data from file %s" % args.input_file) + load_into_db(rows) + + else: + for message in mailbox.Maildir(MAILDIR): + counter = 1 + for part in message.walk(): + if part.get_content_maintype() == 'multipart': + continue + filename = part.get_filename() + ext = mimetypes.guess_extension(part.get_content_type()) + if not filename: + if not ext: + ext = '.xls' # attempt to decode as a spreadsheet + filename = 'part-%03d%s' % (counter, ext) + counter += 1 + if re.search('.xl(b|s)x?$', filename, flags=re.IGNORECASE) is not None: + with tempfile.TemporaryDirectory() as tmpdir: + filepath = os.path.join(tmpdir, filename) + with open(filepath, 'wb') as fp: + fp.write(part.get_payload(decode=True)) + log.info("Loading data from file %s" % filename) + try: + rows = read_spreadsheet(filepath, src=message['from']) + load_into_db(rows) + except: + log.info("Unable to load data from file %s" % filename) + pass + + return + + +def load_into_db(rows): + ''' + CREATE TABLE `results` ( + `result_key` int(11) NOT NULL AUTO_INCREMENT, + `date` datetime DEFAULT NULL, + `distance` float(10) DEFAULT NULL, + `event` varchar(100) COLLATE utf8_unicode_ci NOT NULL, + `eventuuid` varchar(36) COLLATE utf8_unicode_ci NOT NULL, + `position` int(5) NOT NULL, + `time` time NOT NULL, + `name` varchar(75) COLLATE utf8_unicode_ci DEFAULT NULL, + `surname` varchar(75) COLLATE utf8_unicode_ci DEFAULT NULL, + `licence` varchar(20) COLLATE utf8_unicode_ci DEFAULT NULL, + `club` varchar(40) COLLATE utf8_unicode_ci DEFAULT NULL, + `age` int(3) DEFAULT NULL, + `sex` varchar(10) COLLATE utf8_unicode_ci DEFAULT NULL, + `category` varchar(15) COLLATE utf8_unicode_ci DEFAULT NULL, + `sexposition` int(5) DEFAULT NULL, + `catposition` int(5) DEFAULT NULL, + `source` varchar(200) COLLATE utf8_unicode_ci DEFAULT NULL, + PRIMARY KEY (`result_key`) + ) ENGINE=InnoDB CHARSET=utf8 COLLATE=utf8_unicode_ci; + ''' + if rows is None or len(rows) < 1: + log.warning("No data found in spreadsheet") + else: + db = MySQLdb.connect(user='aac', passwd='saOAcCWHg4LaoSSA', db='AAC', use_unicode=True, charset="utf8") + c = db.cursor() + + ''' Check for duplicate values by DATE and POSITION and RACE and EVENT ''' + sql = 'SELECT COUNT(*) FROM `results` WHERE source LIKE %s' + c.execute(sql, (rows[0].get('source'),)) + log.debug(c._last_executed) + if (c.fetchone()[0] > 0): + log.info("Spreadsheet data already loaded") + return + + sql = 'SELECT COUNT(*) FROM `results` WHERE date=%s AND position=%s AND distance LIKE %s AND event LIKE %s' + c.execute(sql, (rows[0].get('date'), rows[0].get('position'), rows[0].get('distance'), rows[0].get('event'),)) + log.debug(c._last_executed) + if (c.fetchone()[0] > 0): + log.info("Spreadsheet data already loaded") + return + + for r in rows: + fields = ', '.join(r.keys()) + values = ', '.join(['%s'] * len(r)) # placeholder values + sql = 'INSERT into `results` ( %s ) VALUES ( %s )' % (fields, values) + try: + c.execute(sql, r.values()) + except : + e = sys.exc_info()[0] + log.debug("ERROR: %s" % e) + log.debug("Last query was: %s" % c._last_executed) + raise + #pass + + db.commit() + + return + +def read_spreadsheet(spreadsheet, src=None, eventname=None, eventdate=None, eventdistance=None): + rows = [] + filename = os.path.basename(spreadsheet) + if re.search('.xlsx?$', spreadsheet, flags=re.IGNORECASE) is not None: + ''' The eventuuid should be unique for this event, but are not derived from the name, and cannot be used to detect duplicate events ''' + eventuuid = uuid.uuid4() + book = xlrd.open_workbook(spreadsheet) + for sheetname in book.sheet_names(): + sheet = book.sheet_by_name(sheetname) + log.debug("Processing sheet %s" % sheetname) + + ''' Look for the header in the first 15 rows, searching from the top ''' + fields = [] + for row in range(0, 15): + try: + if re.search('((pos\w*|no\w*|num\w*|surname|name|time|club)\s*){2,}', ' '.join(str(x) for x in (sheet.row_values(row))), flags=re.IGNORECASE) is not None: + fields = sheet.row_values(row) + log.debug("Spreadsheet fields: %s" % ', '.join(str(x) for x in fields)) + break + except: + ''' Probably a blank sheet, let's skip ''' + continue + ''' Translate field names, and delete unwanted fields ''' + position_idx = None + time_idx = None + for i in range(len(fields)): + if re.search('^\s*pos', str(fields[i]), flags=re.IGNORECASE) is not None: + fields[i] = 'position' + ''' Store the index of this field for later processing ''' + position_idx = i + elif re.search('^\s*(time|h:?m:?s?)', str(fields[i]), flags=re.IGNORECASE) is not None: + fields[i] = 'time' + ''' Store the index of this field for later processing ''' + time_idx = i + elif re.search('^\s*cat\S*\s*pos(\.|\w+)?\s*$', str(fields[i]), flags=re.IGNORECASE) is not None: + fields[i] = 'catposition' + elif re.search('^\s*(sex|gender)\s*pos(\.|\w*)\s*$', str(fields[i]), flags=re.IGNORECASE) is not None: + fields[i] = 'sexposition' + elif re.search('^\s*(sur|last\s*)name', str(fields[i]), flags=re.IGNORECASE) is not None: + fields[i] = 'surname' + elif re.search('^\s*name', str(fields[i]), flags=re.IGNORECASE) is not None: + fields[i] = 'name' + elif re.search('^\s*club(\.|\w*)\s*$', str(fields[i]), flags=re.IGNORECASE) is not None: + fields[i] = 'club' + elif re.search('^\s*age(\.|\w*)\s*$', str(fields[i]), flags=re.IGNORECASE) is not None: + fields[i] = 'age' + elif re.search('^\s*(sex|gender|m.?f\b|male|female)(\.|\w*)\s*$', str(fields[i]), flags=re.IGNORECASE) is not None: + fields[i] = 'sex' + elif re.search('^\s*cat(\.|\w*)\s*$', str(fields[i]), flags=re.IGNORECASE) is not None: + fields[i] = 'category' + elif re.search('^\s*(lic|no|num)(\.|\S*)\s*\S*\s*$', str(fields[i]), flags=re.IGNORECASE) is not None: + fields[i] = 'licence' + elif re.search('^\s*(race)?date', str(fields[i]), flags=re.IGNORECASE) is not None: + fields[i] = 'date' + elif re.search('^\s*(race)?dist(ance)?\s*$', str(fields[i]), flags=re.IGNORECASE) is not None: + fields[i] = 'distance' + elif re.search('^\s*(race)?(event|name)\s*$', str(fields[i]), flags=re.IGNORECASE) is not None: + fields[i] = 'event' + pass + + ''' If there isn't a position field or a time field, we don't want this sheet ''' + if position_idx is None or time_idx is None: + continue + + ''' Look for the date in the file name, and then look the first 15 rows and override it ''' + if eventdate is None: + filedate = re.search('(20\d{2})', str(filename), flags=re.IGNORECASE) + if filedate is not None: + eventdate = filedate.group(1) + for row in range(0, 15): + if re.search('(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\w*\s+\d{4}', str(sheet.cell(row, 0).value), flags=re.IGNORECASE) is not None: + eventdate = sheet.cell(row,0).value + break + log.info("Race date: %s" % eventdate.strftime('%Y-%m-%d')) + + ''' Look for the race distance in the sheet name, or in the filename ''' + distance = eventdistance + filedistance = re.search('([\d\.]+)\s*KM', filename, flags=re.IGNORECASE) + if filedistance is not None: + distance = filedistance.group(1) + eventnamedistance = re.search('([\d\.]+)\s*KM', eventname, flags=re.IGNORECASE) + if eventnamedistance is not None: + distance = eventnamedistance.group(1) + sheetdistance = re.search('([\d\.]+\s*KM)', sheetname, flags=re.IGNORECASE) + if sheetdistance is not None: + distance = sheetdistance.group(1) + sheetdistance = re.search('(helper|marshal)', sheetname, flags=re.IGNORECASE) + if sheetdistance is not None: + distance = sheetname + log.info("Race distance: %s" % distance) + + if eventname is None: + ''' Use the filename as the event name :-( ''' + eventname, *_ = os.path.splitext(filename) + eventname = str(eventname) + ''' Clean up common patterns ''' + eventname = re.sub('[\-_]', ' ', eventname, flags=re.IGNORECASE) + eventname = re.sub('results?(\s*book)?', ' ', eventname, flags=re.IGNORECASE) + eventname = re.sub('export', ' ', eventname, flags=re.IGNORECASE) + eventname = re.sub('excel', ' ', eventname, flags=re.IGNORECASE) + eventname = re.sub('\(\d\)', ' ', eventname, flags=re.IGNORECASE) + eventname = re.sub('\d{0,4}20\d{2}\d{0,4}', ' ', eventname, flags=re.IGNORECASE) + eventname = re.sub('\s\s+', ' ', eventname, flags=re.IGNORECASE) + eventname = re.sub('(^\s*|\s*$)', '', eventname, flags=re.IGNORECASE) + log.info("Event name: %s" % eventname) + + for row in range(sheet.nrows): + ''' TODO: don't assume that the position is the first cell ''' + if re.search('(^\s*$|[A-Za-z])', str(sheet.cell(row, position_idx).value), flags=re.IGNORECASE) is None: + item = dict() + data = [] + for col in range(sheet.ncols): + data.append(sheet.cell(row, col).value) + item = dict(zip(fields, data)) + ''' If the time has been modified by Excel, unmodify it ''' + if 'time' in item and isinstance(item['time'], float): + try: + item['time'] = xlrd.xldate_as_tuple(sheet.cell(row, time_idx).value, book.datemode) + except: + ''' Skip this row if the date can't be parsed, as it's probably wrong anyway (41 hours or something silly) ''' + continue + item['date'] = eventdate + item['event'] = eventname + item['eventuuid'] = eventuuid + item['distance'] = distance + item['source'] = src + rows.append(item) + + rows = clean_data(rows) + if len(rows) > 0: + log.debug("Sample output: %s" % pp.pformat(rows[0])) + return rows + + +def clean_data(input_rows): + rows = [] + for ir in input_rows: + r = dict() + ''' Fix date ''' + date = ir.get('date') + if isinstance(date, str): + today = dt.datetime.now() + year = dt.datetime.combine(dt.date(year=today.year, month=1, day=1), dt.time(hour=0, minute=0, second=0)) + date = dtp.parse(date, default=year) + r['date'] = date + + ''' Check time ''' + time = ir['time'] + ''' Deal with various formats that xlrd might give us. Note that floats should already be converted to tuples ''' + if isinstance(time, tuple): + time = dt.datetime.combine(dt.date(year=1900, month=1, day=1), dt.time(hour=time[3], minute=time[4], second=time[5])) + elif isinstance(time, str): + try: + time = dt.datetime.strptime(time, '%H:%M:%S') + except: + try: + time = dt.datetime.strptime(time, '%M:%S') + except: + continue + r['time'] = time.time() + + ''' Fix distance ''' + length = re.search('([\d\.]+)\s*km', str(ir.get('distance')), flags=re.IGNORECASE) + if length is not None: + r['distance'] = length.group(1) + + ''' Fix sex ''' + if 'sex' in ir and re.search('^\sF', str(ir.get('sex')), flags=re.IGNORECASE) is not None: + r['sex'] = 'F' + else: + r['sex'] = 'M' + + ''' Fix club ''' + if re.search('^\s*(AAC\b|Atlantic\s*Athletic)', str(ir.get('club')), flags=re.IGNORECASE) is not None: + r['club'] = 'AAC' + + ''' Should be an int ''' + for key in ( 'position', 'sexposition', 'catposition', 'age', ): + val = ir.get(key) + if val is not None: + try: + r[key] = int(val) + except: + pass + + ''' Should be a float ''' + for key in ( 'distance', ): + val = ir.get(key) + if val is not None: + try: + r[key] = float(val) + except: + pass + else: + r[key] = 0 + + ''' Should be a string ''' + for key in ( 'event', 'name', 'surname', 'licence', 'club', 'category', 'sex', ): + val = ir.get(key) + if val is not None: + try: + r[key] = str(val) + except: + pass + + ''' Leave alone ''' + for key in ( 'event', 'eventuuid', 'source', ): + r[key] = ir.get(key) + + rows.append(r) + return rows + + +def parse_arguments(): + parser = argparse.ArgumentParser(description='Load a spreadsheet containing WPA results into a database') + parser.add_argument( + '--web', '-w', action='store_true', required=False, dest='scrape_web', + help='Scrape WPA website') + parser.add_argument( + '--input', '-i', action='store', required=False, type=str, dest='input_file', + help='Manually select the spreadsheet to be imported') + parser.add_argument( + '--verbose', '-v', action='count', required=False, dest='verbose', + help='Print more information') + args = parser.parse_args() + + if args.input_file: + if not os.path.exists(args.input_file) or not os.access(args.input_file, os.R_OK): + raise + + logging.basicConfig() + if args.verbose is not None and args.verbose == 1: + log.setLevel(logging.INFO) + elif args.verbose is not None and args.verbose >= 2: + log.setLevel(logging.DEBUG) + else: + log.setLevel(logging.WARNING) + + return args + +if __name__ == "__main__": + main() + +# vim: set expandtab shiftwidth=2 softtabstop=2 tw=0 : diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..33295ea --- /dev/null +++ b/static/style.css @@ -0,0 +1,32 @@ +body { + margin: 0 auto; + max-width: 800px; + font-family: 'Roboto Condensed', sans-serif; + font-size: 11pt; +} +table { + border-collapse: collapse; + width: 100%; +} +table th { + text-align: left; + font-size: 10pt; +} +table tr td { + padding-right: 10px; + border-bottom: 1px solid grey; +} +table tr td:last-child { + padding: 0; +} +table tr td.nowrap { + white-space: nowrap; +} +nav.nextprev { + text-align: center; + margin-top: 10px; +} +nav.nextprev span.prev { +} +nav.nextprev span.next { +} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..7cd5cd8 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,122 @@ +<!DOCTYPE html> +<html lang="en-ZA"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width"> + <title>AAC Statistics + + + +{%- set ns = namespace() -%} + +{%- set ns.limit = limit -%} +{%- if ns.limit == 0 or ns.limit == 10 -%} + {%- set ns.limit = None -%} +{%- endif -%} + +{%- set ns.year = year -%} +{%- if ns.year == 0 -%} + {%- set ns.year = None -%} +{%- endif -%} + + + +
+

AAC Statistics {% if title %}: {{ title }}{% endif %}

+{%- if results -%} + + + + + {% if ltype != 'person' %} + + + {% endif %} + + + {% if ltype != 'event' %} + + {% endif %} + + + + + + {%- for row in results -%} + {%- set person='{} {}'.format(row.name, row.surname) -%} + {%- if distance %}{# set total_km += row.distance #}{% endif -%} + + + {%- if ltype != 'person' -%} + + + {%- endif -%} + + + {%- if ltype != 'event' -%} + + {%- endif -%} + + + + {%- endfor -%} + +
PositionNameLicenceTimeAverage PaceEventDateNotes
{{ row.position }}{{ person }}{{ row.licence }}{{ row.time }}{% if row.distance is number %}{{ (row.time / row.distance) | pace }} min/KM{% endif %}{{ row.event }} ({{ row.distance }} KM){{ row.date | clean_date }} + {% if row.sex and row.sexposition and row.sexposition <= 100 %}{{ row.sexposition | ordinal }} {{ row.sex.lower() }}{% endif %} + {% if row.sexposition and row.catposition %}/{% endif %} + {% if row.catposition and row.catposition <= 100 %}{{ row.catposition | ordinal }} in category{% endif %} +
+{%- endif -%} + +
+ +