| @@ -181,19 +181,30 @@ class AuthManager: | |||
| g.db.update_auth(cid, token, expires, refresh) | |||
| return token | |||
| def _is_corp_member(self, token, char_id): | |||
| """"Return whether the given character is in the site's corp.""" | |||
| resp = self._eve.esi(token).v3.characters(char_id).get() | |||
| return resp.get("corporation_id") == self._config.get("corp.id") | |||
| def _check_access(self, token, char_id): | |||
| """"Check whether the given character is allowed to access this site. | |||
| If allowed, do nothing. If not, raise AccessDeniedError. | |||
| """ | |||
| resp = self._eve.esi(token).v3.characters(char_id).get() | |||
| if resp.get("corporation_id") != self._config.get("corp.id"): | |||
| if not self._is_corp_member(token, char_id): | |||
| self._debug("Access denied per corp membership for char id=%d " | |||
| "session id=%d", char_id, session["id"]) | |||
| g.db.drop_auth(char_id) | |||
| self._invalidate_session() | |||
| raise AccessDeniedError() | |||
| def _cache_token(self, cid, token): | |||
| """Cache the given token for this request.""" | |||
| if hasattr(g, "_cached_tokens"): | |||
| g._cached_tokens[cid] = token | |||
| else: | |||
| g._cached_tokens = {cid: token} | |||
| def _update_prop_cache(self, module, prop, value): | |||
| """Update the value of a character module property in the cache.""" | |||
| if hasattr(g, "_character_modprops"): | |||
| @@ -282,21 +293,29 @@ class AuthManager: | |||
| self._update_prop_cache(module, prop, value) | |||
| return True | |||
| def get_token(self): | |||
| """Return a valid token for the current character, or None. | |||
| def get_token(self, cid=None): | |||
| """Return a valid token for the given character. | |||
| If no character is given, we use the current session's character. If a | |||
| token couldn't be retrieved, return None. | |||
| Assuming this is called in a restricted route (following a True result | |||
| from is_authenticated), this function makes no API calls and should | |||
| always succeed. If it is called in other circumstances, it may fail and | |||
| return None. | |||
| Assuming we want the current character's token and this is called in a | |||
| restricted route (following a True result from is_authenticated), this | |||
| function makes no API calls and should always succeed. If it is called | |||
| in other circumstances, it may return None or raise EVEAPIError. | |||
| """ | |||
| cid = self.get_character_id() | |||
| if cid is None: | |||
| cid = self.get_character_id() | |||
| if not cid: | |||
| return None | |||
| if not hasattr(g, "_cached_token"): | |||
| g._cached_token = self._get_token(cid) | |||
| return g._cached_token | |||
| if hasattr(g, "_cached_tokens"): | |||
| if cid in g._cached_tokens: | |||
| return g._cached_tokens[cid] | |||
| token = self._get_token(cid) | |||
| self._cache_token(cid, token) | |||
| return token | |||
| def is_authenticated(self): | |||
| """Return whether the user has permission to access this site. | |||
| @@ -324,7 +343,7 @@ class AuthManager: | |||
| self._debug("Access granted for char id=%d session id=%d", cid, | |||
| session["id"]) | |||
| g.db.touch_session(session["id"]) | |||
| g._cached_token = token | |||
| self._cache_token(cid, token) | |||
| return True | |||
| def make_login_link(self): | |||
| @@ -382,3 +401,35 @@ class AuthManager: | |||
| if "id" in session: | |||
| self._debug("Logging out session id=%d", session["id"]) | |||
| self._invalidate_session() | |||
| def get_valid_characters(self): | |||
| """Iterate over all valid corp members that we have tokens for. | |||
| Each character is returned as a 2-tuple of (char_id, token). | |||
| This function may make a large number of API queries (up to three per | |||
| character in the corp), hence it is a generator. | |||
| """ | |||
| chars = g.db.get_authed_characters() | |||
| for cid, token, expires, refresh in chars: | |||
| seconds_til_expiry = (expires - datetime.utcnow()).total_seconds() | |||
| if seconds_til_expiry < self.EXPIRY_THRESHOLD: | |||
| result = self._fetch_new_token(refresh, refresh=True) | |||
| if not result: | |||
| self._debug("Couldn't refresh token for char id=%d", cid) | |||
| g.db.drop_auth(cid) | |||
| continue | |||
| token, expires, refresh, char_id, char_name = result | |||
| if char_id != cid: | |||
| self._debug("Refreshed token has incorrect char id=%d for " | |||
| "char id=%d", char_id, cid) | |||
| g.db.drop_auth(cid) | |||
| continue | |||
| g.db.put_character(cid, char_name) | |||
| g.db.update_auth(cid, token, expires, refresh) | |||
| if self._is_corp_member(token, cid): | |||
| yield cid, token | |||
| @@ -204,6 +204,19 @@ class Database: | |||
| with self._conn as conn: | |||
| conn.execute("DELETE FROM auth WHERE auth_character = ?", (cid,)) | |||
| def get_authed_characters(self): | |||
| """Return a list of characters with authentication info. | |||
| Each list item is a 4-tuple of (character_id, access_token, | |||
| token_expiry, refresh_token). | |||
| """ | |||
| query = """SELECT auth_character, auth_token, auth_token_expiry, | |||
| auth_refresh FROM auth""" | |||
| res = self._conn.execute(query).fetchall() | |||
| dtparse = lambda dt: datetime.strptime(dt, "%Y-%m-%d %H:%M:%S") | |||
| return [(cid, token, dtparse(expiry), refresh) | |||
| for (cid, token, expiry, refresh) in res] | |||
| def set_character_modprop(self, cid, module, prop, value): | |||
| """Add or update a character module property.""" | |||
| with self._conn as conn: | |||
| @@ -163,7 +163,7 @@ class EVESwaggerInterface: | |||
| self._data_source = "tranquility" | |||
| self._cache = _ESICache() | |||
| def __call__(self, token): | |||
| def __call__(self, token=None): | |||
| return _ESIQueryBuilder(self, token) | |||
| def _do(self, method, query, params, data, token, can_cache=False): | |||
| @@ -183,10 +183,9 @@ class EVESwaggerInterface: | |||
| if cached is not None: | |||
| return cached | |||
| headers = { | |||
| "Accept": "application/json", | |||
| "Authorization": "Bearer " + token | |||
| } | |||
| headers = {"Accept": "application/json"} | |||
| if token is not None: | |||
| headers["Authorization"] = "Bearer " + token | |||
| params = params.copy() if params else {} | |||
| params["datasource"] = self._data_source | |||
| url = self._base_url + query | |||
| @@ -198,7 +197,8 @@ class EVESwaggerInterface: | |||
| result = resp.json() if resp.content else None | |||
| except (requests.RequestException, ValueError) as exc: | |||
| self._logger.exception("ESI request failed") | |||
| if hasattr(exc, "response") and exc.response.status_code == 403: | |||
| if hasattr(exc, "response") and (exc.response and | |||
| exc.response.status_code == 403): | |||
| raise EVEAPIForbiddenError() | |||
| raise EVEAPIError() | |||
| @@ -113,6 +113,25 @@ class _Faction(_UniqueObject): | |||
| return self._data["name"] | |||
| class _Type(_UniqueObject): | |||
| """Represents any type, including ships and materials.""" | |||
| @property | |||
| def name(self): | |||
| """The item's name, as a string.""" | |||
| return self._data["name"] | |||
| @property | |||
| def group_id(self): | |||
| """The item's group ID, as an integer.""" | |||
| return self._data["group_id"] | |||
| @property | |||
| def market_group_id(self): | |||
| """The item's market group ID, as an integer, or None.""" | |||
| return self._data.get("market_group_id") | |||
| class _Killable(_UniqueObject): | |||
| """Represents a killable object, like a ship, structure, or fighter.""" | |||
| @@ -186,6 +205,17 @@ class _DummyFaction(_Faction): | |||
| }) | |||
| class _DummyType(_Type): | |||
| """Represents an unknown or invalid type.""" | |||
| def __init__(self, universe): | |||
| super().__init__(universe, -1, { | |||
| "name": "Unknown", | |||
| "group_id": -1, | |||
| "market_group_id": -1 | |||
| }) | |||
| class _DummyKillable(_Killable): | |||
| """Represents an unknown or invalid killable object.""" | |||
| @@ -209,6 +239,7 @@ class Universe: | |||
| self._constellations = {} | |||
| self._regions = {} | |||
| self._factions = {} | |||
| self._types = {} | |||
| self._killable_idx = {} | |||
| self._killable_tab = {} | |||
| @@ -237,11 +268,13 @@ class Universe: | |||
| self._factions = entities["factions"] | |||
| del entities | |||
| types = self._load_yaml(self._dir / "types.yml.gz") | |||
| self._killable_idx = {kid: cat for cat, kids in types.items() | |||
| self._types = self._load_yaml(self._dir / "types.yml.gz") | |||
| killables = self._load_yaml(self._dir / "killables.yml.gz") | |||
| self._killable_idx = {kid: cat for cat, kids in killables.items() | |||
| for kid in kids} | |||
| self._killable_tab = types | |||
| del types | |||
| self._killable_tab = killables | |||
| del killables | |||
| self._loaded = True | |||
| @@ -285,6 +318,16 @@ class Universe: | |||
| return _DummyFaction(self) | |||
| return _Faction(self, fid, self._factions[fid]) | |||
| def type(self, tid): | |||
| """Return a _Type with the given ID. | |||
| If the ID is invalid, return a dummy unknown object with ID -1. | |||
| """ | |||
| self._load() | |||
| if tid not in self._types: | |||
| return _DummyKillable(self) | |||
| return _Type(self, tid, self._types[tid]) | |||
| def killable(self, kid): | |||
| """Return a _Killable with the given ID. | |||
| @@ -181,3 +181,43 @@ class CampaignDB: | |||
| }, | |||
| "value": row[12] | |||
| } for row in res] | |||
| def update_items(self, campaign, data): | |||
| """Update all item details in the database for the given campaign. | |||
| The data should be a multi-layered dictionary. It maps operation names | |||
| to a dict that maps character IDs to a dict that maps type IDs to | |||
| integer counts. | |||
| """ | |||
| with self._conn as conn: | |||
| cur = conn.execute("BEGIN TRANSACTION") | |||
| query = "DELETE FROM oper_item WHERE oi_campaign = ?" | |||
| cur.execute(query, (campaign,)) | |||
| query = """INSERT INTO oper_item ( | |||
| oi_campaign, oi_operation, oi_character, oi_type, oi_count) | |||
| VALUES (?, ?, ?, ?, ?)""" | |||
| cur.executemany(query, [ | |||
| (campaign, operation, int(char_id), int(type_id), int(count)) | |||
| for operation, chars in data.items() | |||
| for char_id, types in chars.items() | |||
| for type_id, count in types.items()]) | |||
| def get_associated_items(self, campaign, operation, limit=5, offset=0): | |||
| """Return a list of items associated with a campaign/operation. | |||
| Items are returned as 2-tuples of (item_type, item_count), most recent | |||
| first, up to a limit. Use -1 for no limit. | |||
| """ | |||
| if not isinstance(limit, int): | |||
| raise ValueError(limit) | |||
| if not isinstance(offset, int): | |||
| raise ValueError(offset) | |||
| query = """SELECT oi_type, SUM(oi_count) as total_count | |||
| FROM oper_item | |||
| WHERE oi_campaign = ? AND oi_operation = ? | |||
| GROUP BY oi_type ORDER BY total_count DESC LIMIT {} OFFSET {}""" | |||
| qform = query.format(limit, offset) | |||
| res = self._conn.execute(qform, (campaign, operation)).fetchall() | |||
| return [(type_id, count or 0) for type_id, count in res] | |||
| @@ -62,8 +62,8 @@ def get_summary(cname, opname, limit=5): | |||
| kills = g.campaign_db.get_associated_kills(cname, opname, limit=limit) | |||
| return kills, "killboard_recent" | |||
| elif optype == "collection": | |||
| ... | |||
| return [], None | |||
| items = g.campaign_db.get_associated_items(cname, opname, limit=limit) | |||
| return items, "collection_items" | |||
| else: | |||
| raise RuntimeError("Unknown operation type: %s" % optype) | |||
| @@ -6,6 +6,7 @@ import textwrap | |||
| from flask import g | |||
| from .._provided import config, logger | |||
| from ...exceptions import EVEAPIForbiddenError | |||
| __all__ = ["update_operation"] | |||
| @@ -17,7 +18,7 @@ def _save_operation(cname, opname, primary, secondary, key=None): | |||
| g.campaign_db.set_overview(cname, opname, primary, secondary) | |||
| g.campaign_db.touch_operation(cname, opname, key=key) | |||
| def _build_filter(qualifiers): | |||
| def _build_filter(qualifiers, arg): | |||
| """Given a qualifiers string from the config, return a filter function. | |||
| This function is extremely sensitive since it executes arbitrary Python | |||
| @@ -26,7 +27,7 @@ def _build_filter(qualifiers): | |||
| filesystem. | |||
| """ | |||
| namespace = {"g": g} | |||
| body = "def _func(kill):\n" + textwrap.indent(qualifiers, " " * 4) | |||
| body = ("def _func(%s):\n" % arg) + textwrap.indent(qualifiers, " " * 4) | |||
| exec(body, namespace) | |||
| return namespace["_func"] | |||
| @@ -52,7 +53,7 @@ def _update_killboard_operations(cname, opnames, min_kill_id): | |||
| filters = [] | |||
| for opname in opnames: | |||
| qualif = operations[opname]["qualifiers"] | |||
| filters.append((_build_filter(qualif), opname)) | |||
| filters.append((_build_filter(qualif, "kill"), opname)) | |||
| args = ["kills", "corporationID", g.config.get("corp.id"), "no-items", | |||
| "no-attackers", "orderDirection", "asc"] | |||
| @@ -80,21 +81,64 @@ def _update_killboard_operations(cname, opnames, min_kill_id): | |||
| secondary = None | |||
| _save_operation(cname, opname, primary, secondary, key=max_kill_id) | |||
| def _save_collection_overview(cname, opnames, data): | |||
| """Save collection overview data to the database.""" | |||
| operations = config["campaigns"][cname]["operations"] | |||
| if any(operations[opname].get("isk", True) for opname in opnames): | |||
| pricelist = g.eve.esi().v1.markets.prices.get() | |||
| prices = {entry["type_id"]: entry["average_price"] | |||
| for entry in pricelist if "average_price" in entry} | |||
| else: | |||
| prices = {} | |||
| for opname in opnames: | |||
| primary = sum(sum(d.values()) for d in data[opname].values()) | |||
| show_isk = operations[opname].get("isk", True) | |||
| if show_isk: | |||
| secondary = sum(prices.get(typeid, 0.0) * count | |||
| for d in data[opname].values() | |||
| for typeid, count in d.items()) | |||
| else: | |||
| secondary = None | |||
| _save_operation(cname, opname, primary, secondary) | |||
| def _update_collection_operations(cname, opnames): | |||
| """Update all collection-type operations in the given campaign subset.""" | |||
| campaign = config["campaigns"][cname] | |||
| operations = config["campaigns"][cname]["operations"] | |||
| filters = [] | |||
| for opname in opnames: | |||
| operation = campaign["operations"][opname] | |||
| show_isk = operation.get("isk", True) | |||
| qualif = operations[opname]["qualifiers"] | |||
| filters.append((_build_filter(qualif, "asset"), opname)) | |||
| # store per-user counts; update for all users in corp who have fresh | |||
| # API keys and leave other data stale | |||
| ... | |||
| primary = __import__("random").randint(10, 99) | |||
| secondary = __import__("random").randint(10000000, 5000000000) / 100 \ | |||
| if show_isk else None | |||
| data = {opname: {} for opname in opnames} | |||
| _save_operation(cname, opname, primary, secondary) | |||
| for char_id, token in g.auth.get_valid_characters(): | |||
| logger.debug("Fetching assets for char id=%d campaign=%s " | |||
| "operations=%s", char_id, cname, ",".join(opnames)) | |||
| try: | |||
| assets = g.eve.esi(token).v1.characters(char_id).assets.get() | |||
| except EVEAPIForbiddenError: | |||
| logger.debug("Asset access denied for char id=%d", char_id) | |||
| continue | |||
| for opname in opnames: | |||
| data[opname][char_id] = {} | |||
| logger.debug("Evaluating %d assets for char id=%d", | |||
| len(assets), char_id) | |||
| for asset in assets: | |||
| for filt, opname in filters: | |||
| if filt(asset): | |||
| typeid = asset["type_id"] | |||
| count = 1 if asset["is_singleton"] else asset["quantity"] | |||
| char = data[opname][char_id] | |||
| if typeid in char: | |||
| char[typeid] += count | |||
| else: | |||
| char[typeid] = count | |||
| g.campaign_db.update_items(cname, data) | |||
| _save_collection_overview(cname, opnames, data) | |||
| def update_operation(cname, opname, new=False): | |||
| """Update a campaign/operation. Assumes a thread-exclusive lock is held.""" | |||
| @@ -48,7 +48,8 @@ campaigns: | |||
| # Report as "10 units" / "1 unit" of Tritanium | |||
| unit: unit|units | |||
| qualifiers: |- | |||
| return item_type == "Tritanium" # ... | |||
| type = g.eve.universe.type(asset["type_id"]) | |||
| return type.name == "Tritanium" | |||
| bar: | |||
| title: Save the Bar | |||
| operations: [] | |||
| @@ -56,7 +56,11 @@ DROP TABLE IF EXISTS oper_item; | |||
| CREATE TABLE oper_item ( | |||
| oi_campaign TEXT, | |||
| oi_operation TEXT | |||
| -- ... | |||
| -- UNIQUE (oi_campaign, oi_operation, ...) | |||
| oi_operation TEXT, | |||
| oi_character INTEGER, | |||
| oi_type INTEGER, | |||
| oi_count INTEGER, | |||
| UNIQUE (oi_campaign, oi_operation, oi_character, oi_type) | |||
| ); | |||
| CREATE INDEX oi_campaign_operation ON oper_item (oi_campaign, oi_operation); | |||
| @@ -66,23 +66,37 @@ def _load_typeids(sde_dir, groups): | |||
| assert data[_SOLAR_SYSTEM]["groupID"] == _SOLAR_SYSTEM | |||
| assert data[_SOLAR_SYSTEM]["name"]["en"] == "Solar System" | |||
| types = {"ships": {}, "structures": {}, "fighters": {}} | |||
| types = {} | |||
| killables = {"ships": {}, "structures": {}, "fighters": {}} | |||
| cat_conv = {_SHIP_CAT: "ships", _FIGHTER_CAT: "fighters"} | |||
| cat_conv.update({cid: "structures" for cid in _STRUCT_CATS}) | |||
| group_conv = {gid: cid for cid, gids in groups.items() for gid in gids} | |||
| for tid, type_ in data.items(): | |||
| name = type_["name"].get("en", "Unknown") | |||
| gid = type_["groupID"] | |||
| assert isinstance(tid, int) | |||
| assert isinstance(gid, int) | |||
| assert tid >= 0 | |||
| assert gid >= 0 | |||
| types[tid] = {"name": name, "group_id": gid} | |||
| if "marketGroupID" in type_: | |||
| mgid = type_["marketGroupID"] | |||
| assert isinstance(mgid, int) | |||
| assert mgid >= 0 | |||
| types[tid]["market_group_id"] = mgid | |||
| if gid in group_conv: | |||
| cid = group_conv[gid] | |||
| cname = cat_conv[cid] | |||
| name = type_["name"]["en"] | |||
| group = groups[cid][gid] | |||
| assert isinstance(tid, int) | |||
| types[cname][tid] = {"name": name, "group": group} | |||
| killables[cname][tid] = {"name": name, "group": group} | |||
| print("done.") | |||
| return types | |||
| return types, killables | |||
| def _load_ids(sde_dir): | |||
| print("Loading itemIDs... ", end="", flush=True) | |||
| @@ -248,28 +262,27 @@ def _load_factions(sde_dir): | |||
| def _dump_types(out_dir, types): | |||
| print("Dumping types... ", end="", flush=True) | |||
| _save_yaml(out_dir / "types.yml", types) | |||
| print("done.") | |||
| def _dump_killables(out_dir, killables): | |||
| print("Dumping killables... ", end="", flush=True) | |||
| _save_yaml(out_dir / "killables.yml", killables) | |||
| print("done.") | |||
| def _dump_galaxy(out_dir, galaxy): | |||
| print("Dumping galaxy... ", end="", flush=True) | |||
| _save_yaml(out_dir / "galaxy.yml", galaxy) | |||
| print("done.") | |||
| def _dump_entities(out_dir, factions): | |||
| print("Dumping entities... ", end="", flush=True) | |||
| entities = {"factions": factions} | |||
| _save_yaml(out_dir / "entities.yml", entities) | |||
| print("done.") | |||
| def _compress(out_dir): | |||
| targets = ["types", "galaxy", "entities"] | |||
| targets = ["types", "killables", "galaxy", "entities"] | |||
| for basename in targets: | |||
| print("Compressing %s... " % basename, end="", flush=True) | |||
| @@ -285,7 +298,7 @@ def _compress(out_dir): | |||
| def _cleanup(out_dir): | |||
| print("Cleaning up... ", end="", flush=True) | |||
| targets = ["types", "galaxy", "entities"] | |||
| targets = ["types", "killables", "galaxy", "entities"] | |||
| for basename in targets: | |||
| (out_dir / (basename + ".yml")).unlink() | |||
| @@ -299,9 +312,10 @@ def import_sde(sde_dir, out_dir): | |||
| _verify_categoryids(sde_dir) | |||
| groups = _load_groupids(sde_dir) | |||
| types = _load_typeids(sde_dir, groups) | |||
| types, killables = _load_typeids(sde_dir, groups) | |||
| _dump_types(out_dir, types) | |||
| del groups, types | |||
| _dump_killables(out_dir, killables) | |||
| del groups, types, killables | |||
| ids = _load_ids(sde_dir) | |||
| print("Counts: regions=%d, constellations=%d, systems=%d" % ( | |||
| @@ -17,35 +17,35 @@ | |||
| </a> | |||
| </td> | |||
| <td class="fluid extra"> | |||
| <a href="https://zkillboard.com/system/${system.id}/">${system.name}</a> <abbr title="${system.security}" class="${get_security_class(system.security)}">${format_security(system.security)}</abbr><br/> | |||
| <a href="https://zkillboard.com/region/${system.region.id}/">${system.region.name}</a> | |||
| <a href="https://zkillboard.com/system/${system.id}/">${system.name | h}</a> <abbr title="${system.security}" class="${get_security_class(system.security)}">${format_security(system.security)}</abbr><br/> | |||
| <a href="https://zkillboard.com/region/${system.region.id}/">${system.region.name | h}</a> | |||
| </td> | |||
| <td class="icon"> | |||
| <a href="https://zkillboard.com/kill/${kill['id']}/"> | |||
| <img title="Kill ${kill['id']}: ${killed.name}" alt="Kill ${kill['id']}: ${killed.name}" src="${g.eve.image.inventory(victim["ship_id"], 64)}"/> | |||
| <img title="Kill ${kill['id']}: ${killed.name | h}" alt="Kill ${kill['id']}: ${killed.name | h}" src="${g.eve.image.inventory(victim["ship_id"], 64)}"/> | |||
| </a> | |||
| </td> | |||
| <td class="icon extra"> | |||
| <a href="https://zkillboard.com/character/${victim['char_id']}/"> | |||
| <img title="${victim['char_name']}" alt="${victim['char_name']}" src="${g.eve.image.character(victim["char_id"], 128)}"/> | |||
| <img title="${victim['char_name'] | h}" alt="${victim['char_name'] | h}" src="${g.eve.image.character(victim["char_id"], 128)}"/> | |||
| </a> | |||
| </td> | |||
| <td class="icon${' extra' if victim["alliance_id"] and victim["faction_id"] else ''}"> | |||
| <a href="https://zkillboard.com/corporation/${victim['corp_id']}/"> | |||
| <img title="${victim['corp_name']}" alt="${victim['corp_name']}" src="${g.eve.image.corp(victim["corp_id"], 128)}"/> | |||
| <img title="${victim['corp_name'] | h}" alt="${victim['corp_name'] | h}" src="${g.eve.image.corp(victim["corp_id"], 128)}"/> | |||
| </a> | |||
| </td> | |||
| <td class="icon${'' if victim["alliance_id"] else ' extra'}"> | |||
| % if victim["alliance_id"]: | |||
| <a href="https://zkillboard.com/alliance/${victim['alliance_id']}/"> | |||
| <img title="${victim['alliance_name']}" alt="${victim['alliance_name']}" src="${g.eve.image.alliance(victim["alliance_id"], 128)}"/> | |||
| <img title="${victim['alliance_name'] | h}" alt="${victim['alliance_name'] | h}" src="${g.eve.image.alliance(victim["alliance_id"], 128)}"/> | |||
| </a> | |||
| % endif | |||
| </td> | |||
| <td class="icon${'' if victim["faction_id"] else ' extra'}"> | |||
| % if victim["faction_id"]: | |||
| <a href="https://zkillboard.com/faction/${victim['faction_id']}/"> | |||
| <img title="${victim['faction_name']}" alt="${victim['faction_name']}" src="${g.eve.image.faction(victim["faction_id"], 128)}"/> | |||
| <img title="${victim['faction_name'] | h}" alt="${victim['faction_name'] | h}" src="${g.eve.image.faction(victim["faction_id"], 128)}"/> | |||
| </a> | |||
| % endif | |||
| </td> | |||
| @@ -64,8 +64,17 @@ | |||
| </table> | |||
| </div> | |||
| </%def> | |||
| <%def name="_collection_items(summary)"> | |||
| <div class="head">XXX:</div> | |||
| <div class="contents"> | |||
| ${summary | h} | |||
| </div> | |||
| </%def> | |||
| <%def name="render_summary(renderer, summary)"><% | |||
| if renderer == "killboard_recent": | |||
| return _killboard_recent(summary) | |||
| if renderer == "collection_items": | |||
| return _collection_items(summary) | |||
| raise RuntimeError("Unknown renderer: %s" % renderer) | |||
| %></%def> | |||