@@ -8,6 +8,7 @@ from .clock import Clock | |||||
from .esi import EVESwaggerInterface | from .esi import EVESwaggerInterface | ||||
from .image import ImageServer | from .image import ImageServer | ||||
from .sso import SSOManager | from .sso import SSOManager | ||||
from .zkill import ZKillboard | |||||
from .. import __release__, baseLogger | from .. import __release__, baseLogger | ||||
__all__ = ["EVE"] | __all__ = ["EVE"] | ||||
@@ -25,6 +26,7 @@ class EVE: | |||||
self._esi = EVESwaggerInterface(session, logger.getChild("esi")) | self._esi = EVESwaggerInterface(session, logger.getChild("esi")) | ||||
self._image = ImageServer() | self._image = ImageServer() | ||||
self._sso = SSOManager(session, logger.getChild("sso")) | self._sso = SSOManager(session, logger.getChild("sso")) | ||||
self._zkill = ZKillboard(session, logger.getChild("zkill")) | |||||
@staticmethod | @staticmethod | ||||
def _get_user_agent(contact): | def _get_user_agent(contact): | ||||
@@ -54,3 +56,8 @@ class EVE: | |||||
def sso(self): | def sso(self): | ||||
"""The Single Sign-On API module.""" | """The Single Sign-On API module.""" | ||||
return self._sso | return self._sso | ||||
@property | |||||
def zkill(self): | |||||
"""The zKillboard API module.""" | |||||
return self._zkill |
@@ -13,7 +13,6 @@ class SSOManager: | |||||
def __init__(self, session, logger): | def __init__(self, session, logger): | ||||
self._session = session | self._session = session | ||||
self._logger = logger | self._logger = logger | ||||
self._debug = logger.debug | self._debug = logger.debug | ||||
@@ -0,0 +1,61 @@ | |||||
# -*- coding: utf-8 -*- | |||||
import time | |||||
import requests | |||||
from ..exceptions import ZKillboardError | |||||
__all__ = ["ZKillboard"] | |||||
class ZKillboard: | |||||
"""EVE API module for zKillboard.""" | |||||
_MAX_RATE = 0.5 | |||||
def __init__(self, session, logger): | |||||
self._session = session | |||||
self._logger = logger | |||||
self._debug = logger.debug | |||||
self._base_url = "https://zkillboard.com/api" | |||||
self._last_query = 0 | |||||
def query(self, *args): | |||||
"""Make an API query using the given arguments.""" | |||||
query = "/" + "".join(str(arg) + "/" for arg in args) | |||||
url = self._base_url + query | |||||
delta = self._MAX_RATE - (time.time() - self._last_query) | |||||
if delta > 0: | |||||
self._debug("[GET] [wait %.2fs] %s", delta, query) | |||||
time.sleep(delta) | |||||
else: | |||||
self._debug("[GET] %s", query) | |||||
try: | |||||
resp = self._session.get(url, timeout=10) | |||||
resp.raise_for_status() | |||||
result = resp.json() if resp.content else None | |||||
except (requests.RequestException, ValueError): | |||||
self._logger.exception("zKillboard API query failed") | |||||
raise ZKillboardError() | |||||
self._last_query = time.time() | |||||
return result | |||||
def iter_killmails(self, *args): | |||||
"""Return an iterator over killmails using the given API arguments. | |||||
Automagically follows pagination as far as possible. (Be careful.) | |||||
""" | |||||
page = 1 | |||||
while True: | |||||
if page > 1: | |||||
result = self.query(*args, "page", page) | |||||
else: | |||||
result = self.query(*args) | |||||
if result: | |||||
yield from result | |||||
page += 1 | |||||
else: | |||||
break |
@@ -7,6 +7,7 @@ This module contains exceptions for Calefaction. | |||||
+-- AccessDeniedError | +-- AccessDeniedError | ||||
+-- EVEAPIError | +-- EVEAPIError | ||||
+-- EVEAPIForbiddenError | +-- EVEAPIForbiddenError | ||||
+-- ZKillboardError | |||||
""" | """ | ||||
class CalefactionError(RuntimeError): | class CalefactionError(RuntimeError): | ||||
@@ -24,3 +25,7 @@ class EVEAPIError(CalefactionError): | |||||
class EVEAPIForbiddenError(EVEAPIError): | class EVEAPIForbiddenError(EVEAPIError): | ||||
"""We tried to make an API request that we don't have permission for.""" | """We tried to make an API request that we don't have permission for.""" | ||||
pass | pass | ||||
class ZKillboardError(EVEAPIError): | |||||
"""Represents an error while using zKillboard's API.""" | |||||
pass |
@@ -46,30 +46,29 @@ class CampaignDB: | |||||
return self._conn.close() | return self._conn.close() | ||||
def check_operation(self, campaign, operation): | def check_operation(self, campaign, operation): | ||||
"""Return the last updated timestamp for the given operation. | |||||
"""Return the last updated timestamp and key for the given operation. | |||||
Return None if the given operation was never updated. | |||||
Return (None, None) if the given operation was never updated. | |||||
""" | """ | ||||
query = """SELECT lu_date FROM last_updated | |||||
query = """SELECT lu_date, lu_key FROM last_updated | |||||
WHERE lu_campaign = ? AND lu_operation = ?""" | WHERE lu_campaign = ? AND lu_operation = ?""" | ||||
res = self._conn.execute(query, (campaign, operation)).fetchall() | res = self._conn.execute(query, (campaign, operation)).fetchall() | ||||
if not res: | if not res: | ||||
return None | |||||
return datetime.strptime(res[0][0], "%Y-%m-%d %H:%M:%S") | |||||
return None, None | |||||
return datetime.strptime(res[0][0], "%Y-%m-%d %H:%M:%S"), res[0][1] | |||||
def add_operation(self, campaign, operation): | |||||
"""Insert a new operation into the database as just updated.""" | |||||
def touch_operation(self, campaign, operation, key=None): | |||||
"""Mark the given operation as just updated, or add it.""" | |||||
with self._conn as conn: | with self._conn as conn: | ||||
conn.execute("""INSERT INTO last_updated | |||||
(lu_campaign, lu_operation) VALUES (?, ?)""", ( | |||||
campaign, operation)) | |||||
def touch_operation(self, campaign, operation): | |||||
"""Mark the given operation as just updated.""" | |||||
with self._conn as conn: | |||||
conn.execute("""UPDATE last_updated SET lu_date = CURRENT_TIMESTAMP | |||||
cur = conn.execute("BEGIN TRANSACTION") | |||||
cur.execute("""UPDATE last_updated | |||||
SET lu_date = CURRENT_TIMESTAMP, lu_key = ? | |||||
WHERE lu_campaign = ? AND lu_operation = ?""", ( | WHERE lu_campaign = ? AND lu_operation = ?""", ( | ||||
campaign, operation)) | |||||
key, campaign, operation)) | |||||
if cur.rowcount == 0: | |||||
cur.execute("""INSERT INTO last_updated | |||||
(lu_campaign, lu_operation, lu_key) VALUES (?, ?, ?)""", ( | |||||
campaign, operation, key)) | |||||
def set_overview(self, campaign, operation, primary, secondary=None): | def set_overview(self, campaign, operation, primary, secondary=None): | ||||
"""Set overview information for this operation.""" | """Set overview information for this operation.""" | ||||
@@ -84,4 +83,55 @@ class CampaignDB: | |||||
query = """SELECT ov_primary, ov_secondary FROM overview | query = """SELECT ov_primary, ov_secondary FROM overview | ||||
WHERE ov_campaign = ? AND ov_operation = ?""" | WHERE ov_campaign = ? AND ov_operation = ?""" | ||||
res = self._conn.execute(query, (campaign, operation)).fetchall() | res = self._conn.execute(query, (campaign, operation)).fetchall() | ||||
return res[0] if res else (0, None) | |||||
return tuple(res[0]) if res else (0, None) | |||||
def has_kill(self, kill_id): | |||||
"""Return whether the database has a killmail with the given ID.""" | |||||
query = "SELECT 1 FROM kill WHERE kill_id = ?" | |||||
res = self._conn.execute(query, (kill_id,)).fetchall() | |||||
return bool(res) | |||||
def add_kill(self, kill): | |||||
"""Insert a killmail into the database.""" | |||||
try: | |||||
datetime.strptime(kill["killTime"], "%Y-%m-%d %H:%M:%S") | |||||
except ValueError: | |||||
raise RuntimeError("Invalid kill_date=%s for kill_id=%d" % ( | |||||
kill["killTime"], kill["killID"])) | |||||
query = """INSERT OR REPLACE INTO kill ( | |||||
kill_id, kill_date, kill_system, kill_victim_shipid, | |||||
kill_victim_charid, kill_victim_corpid, kill_victim_allianceid, | |||||
kill_victim_factionid, kill_value) | |||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""" | |||||
args = ( | |||||
kill["killID"], kill["killTime"], kill["solarSystemID"], | |||||
kill["victim"]["shipTypeID"], kill["victim"]["characterID"], | |||||
kill["victim"]["corporationID"], kill["victim"]["allianceID"], | |||||
kill["victim"]["factionID"], kill["zkb"]["totalValue"]) | |||||
with self._conn as conn: | |||||
conn.execute(query, args) | |||||
def get_kill_associations(self, campaign, kill_id): | |||||
"""Return a list of operations associated with a campaign and kill.""" | |||||
query = """SELECT ok_operation FROM oper_kill | |||||
WHERE ok_campaign = ? AND ok_killid = ?""" | |||||
res = self._conn.execute(query, (campaign, kill_id)).fetchall() | |||||
return [row[0] for row in res] | |||||
def associate_kill(self, campaign, kill_id, operations): | |||||
"""Associate a killmail with a set of campaign/operations.""" | |||||
query = """INSERT OR IGNORE INTO oper_kill | |||||
(ok_campaign, ok_operation, ok_killid) VALUES (?, ?, ?)""" | |||||
arglist = [(campaign, op, kill_id) for op in operations] | |||||
with self._conn as conn: | |||||
conn.executemany(query, arglist) | |||||
def count_kills(self, campaign, operation): | |||||
"""Return the number of matching kills and the total kill value.""" | |||||
query = """SELECT COUNT(*), TOTAL(kill_value) | |||||
FROM oper_kill | |||||
JOIN kill ON ok_killid = kill_id | |||||
WHERE ok_campaign = ? AND ok_operation = ?""" | |||||
res = self._conn.execute(query, (campaign, operation)).fetchall() | |||||
return tuple(res[0]) |
@@ -1,12 +1,13 @@ | |||||
# -*- coding: utf-8 -*- | # -*- coding: utf-8 -*- | ||||
from datetime import datetime, timedelta | |||||
from datetime import datetime | |||||
from pathlib import Path | from pathlib import Path | ||||
from threading import Lock | from threading import Lock | ||||
from flask import g | from flask import g | ||||
from .database import CampaignDB | from .database import CampaignDB | ||||
from .update import update_operation | |||||
from .._provided import app, config, logger | from .._provided import app, config, logger | ||||
from ...database import Database as MainDB | from ...database import Database as MainDB | ||||
@@ -21,19 +22,6 @@ app.teardown_appcontext(CampaignDB.post_hook) | |||||
_lock = Lock() | _lock = Lock() | ||||
def _update_operation(cname, opname, new): | |||||
"""Update a campaign/operation.""" | |||||
... | |||||
operation = config["campaigns"][cname]["operations"][opname] | |||||
optype = operation["type"] | |||||
qualifiers = operation["qualifiers"] | |||||
show_isk = operation.get("isk", True) | |||||
primary = __import__("random").randint(10, 99) | |||||
secondary = __import__("random").randint(100000, 50000000) | |||||
g.campaign_db.set_overview(cname, opname, primary, secondary) | |||||
def get_current(): | def get_current(): | ||||
"""Return the name of the currently selected campaign, or None.""" | """Return the name of the currently selected campaign, or None.""" | ||||
if not config["enabled"]: | if not config["enabled"]: | ||||
@@ -51,20 +39,20 @@ def get_overview(cname, opname): | |||||
Updates the database if necessary, so this can take some time. | Updates the database if necessary, so this can take some time. | ||||
""" | """ | ||||
maxdelta = timedelta(seconds=_MAX_STALENESS) | |||||
with _lock: | with _lock: | ||||
last_updated = g.campaign_db.check_operation(cname, opname) | |||||
last_updated, _ = g.campaign_db.check_operation(cname, opname) | |||||
if last_updated is None: | if last_updated is None: | ||||
logger.debug("Adding campaign=%s operation=%s", cname, opname) | logger.debug("Adding campaign=%s operation=%s", cname, opname) | ||||
_update_operation(cname, opname, new=True) | |||||
g.campaign_db.add_operation(cname, opname) | |||||
elif datetime.utcnow() - last_updated > maxdelta: | |||||
logger.debug("Updating campaign=%s operation=%s", cname, opname) | |||||
_update_operation(cname, opname, new=False) | |||||
g.campaign_db.touch_operation(cname, opname) | |||||
update_operation(cname, opname, new=True) | |||||
else: | else: | ||||
logger.debug("Using cache for campaign=%s operation=%s", | |||||
cname, opname) | |||||
age = (datetime.utcnow() - last_updated).total_seconds() | |||||
if age > _MAX_STALENESS: | |||||
logger.debug("Updating (stale cache age=%d) campaign=%s " | |||||
"operation=%s", age, cname, opname) | |||||
update_operation(cname, opname, new=False) | |||||
else: | |||||
logger.debug("Using cache (age=%d) for campaign=%s " | |||||
"operation=%s", age, cname, opname) | |||||
return g.campaign_db.get_overview(cname, opname) | return g.campaign_db.get_overview(cname, opname) | ||||
def get_summary(name, opname, limit=5): | def get_summary(name, opname, limit=5): | ||||
@@ -0,0 +1,119 @@ | |||||
# -*- coding: utf-8 -*- | |||||
import sys | |||||
import textwrap | |||||
from flask import g | |||||
from .._provided import config, logger | |||||
__all__ = ["update_operation"] | |||||
def _save_operation(cname, opname, primary, secondary, key=None): | |||||
"""Save the given campaign/operation overview info in the database.""" | |||||
secstr = "" if secondary is None else (" secondary=%d" % secondary) | |||||
logger.debug("Setting overview primary=%d%s campaign=%s operation=%s", | |||||
primary, secstr, cname, opname) | |||||
g.campaign_db.set_overview(cname, opname, primary, secondary) | |||||
g.campaign_db.touch_operation(cname, opname, key=key) | |||||
def _build_filter(qualifiers): | |||||
"""Given a qualifiers string from the config, return a filter function. | |||||
This function is extremely sensitive since it executes arbitrary Python | |||||
code. It should never be run with a user-provided argument! We trust the | |||||
contents of a config file because it originates from a known place on the | |||||
filesystem. | |||||
""" | |||||
namespace = {} | |||||
body = "def _func(kill):\n" + textwrap.indent(qualifiers, " " * 4) | |||||
exec(body, namespace) | |||||
return namespace["_func"] | |||||
def _store_kill(cname, opnames, kill): | |||||
"""Store the given kill and its associations into the database.""" | |||||
kid = kill["killID"] | |||||
if g.campaign_db.has_kill(kid): | |||||
current = g.campaign_db.get_kill_associations(cname, kid) | |||||
opnames -= set(current) | |||||
if opnames: | |||||
logger.debug("Adding operations=%s to kill id=%d campaign=%s", | |||||
",".join(opnames), kid, cname) | |||||
else: | |||||
logger.debug("Adding kill id=%d campaign=%s operations=%s", kid, cname, | |||||
",".join(opnames)) | |||||
g.campaign_db.add_kill(kill) | |||||
g.campaign_db.associate_kill(cname, kid, opnames) | |||||
def _update_killboard_operations(cname, opnames, min_kill_id): | |||||
"""Update all killboard-type operations in the given campaign subset.""" | |||||
operations = config["campaigns"][cname]["operations"] | |||||
filters = [] | |||||
for opname in opnames: | |||||
qualif = operations[opname]["qualifiers"] | |||||
filters.append((_build_filter(qualif), opname)) | |||||
args = ["kills", "corporationID", g.config.get("corp.id"), "no-items", | |||||
"no-attackers", "orderDirection", "asc"] | |||||
if min_kill_id > 0: | |||||
args += ["afterKillID", min_kill_id] | |||||
max_kill_id = min_kill_id | |||||
for kill in g.eve.zkill.iter_killmails(*args): | |||||
kid = kill["killID"] | |||||
ktime = kill["killTime"] | |||||
logger.debug("Evaluating kill date=%s id=%d for campaign=%s " | |||||
"operations=%s", ktime, kid, cname, ",".join(opnames)) | |||||
max_kill_id = max(max_kill_id, kid) | |||||
ops = set() | |||||
for filt, opname in filters: | |||||
if filt(kill): | |||||
ops.add(opname) | |||||
if ops: | |||||
_store_kill(cname, ops, kill) | |||||
for opname in opnames: | |||||
primary, secondary = g.campaign_db.count_kills(cname, opname) | |||||
show_isk = operations[opname].get("isk", True) | |||||
if not show_isk: | |||||
secondary = None | |||||
_save_operation(cname, opname, primary, secondary, key=max_kill_id) | |||||
def _update_collection_operations(cname, opnames): | |||||
"""Update all collection-type operations in the given campaign subset.""" | |||||
campaign = config["campaigns"][cname] | |||||
for opname in opnames: | |||||
operation = campaign["operations"][opname] | |||||
show_isk = operation.get("isk", True) | |||||
... | |||||
primary = __import__("random").randint(10, 99) | |||||
secondary = __import__("random").randint(10000000, 5000000000) / 100 \ | |||||
if show_isk else None | |||||
_save_operation(cname, opname, primary, secondary) | |||||
def update_operation(cname, opname, new=False): | |||||
"""Update a campaign/operation. Assumes a thread-exclusive lock is held.""" | |||||
campaign = config["campaigns"][cname] | |||||
operations = campaign["operations"] | |||||
optype = operations[opname]["type"] | |||||
opnames = [opn for opn in campaign["enabled"] | |||||
if operations[opn]["type"] == optype] | |||||
if optype == "killboard": | |||||
opsubset = [] | |||||
min_key = 0 if new else sys.maxsize | |||||
for opname in opnames: | |||||
last_updated, key = g.campaign_db.check_operation(cname, opname) | |||||
if new and last_updated is None: | |||||
opsubset.append(opname) | |||||
elif not new and last_updated is not None: | |||||
min_key = min(min_key, key) | |||||
opsubset.append(opname) | |||||
_update_killboard_operations(cname, opsubset, min_key) | |||||
elif optype == "collection": | |||||
_update_collection_operations(cname, opnames) | |||||
else: | |||||
raise RuntimeError("Unknown operation type: %s" % optype) |
@@ -29,7 +29,7 @@ campaigns: | |||||
frigates: | frigates: | ||||
# Full operation name: | # Full operation name: | ||||
title: "Operation: Kill Foo Frigates" | title: "Operation: Kill Foo Frigates" | ||||
# Data source (here, retrieve data from ZKillboard): | |||||
# Data source (here, retrieve data from zKillboard): | |||||
type: killboard | type: killboard | ||||
# Show total ISK killed (defaults to true): | # Show total ISK killed (defaults to true): | ||||
isk: true | isk: true | ||||
@@ -6,6 +6,7 @@ CREATE TABLE last_updated ( | |||||
lu_campaign TEXT, | lu_campaign TEXT, | ||||
lu_operation TEXT, | lu_operation TEXT, | ||||
lu_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, | lu_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, | ||||
lu_key INTEGER DEFAULT NULL, | |||||
UNIQUE (lu_campaign, lu_operation) | UNIQUE (lu_campaign, lu_operation) | ||||
); | ); | ||||
@@ -15,6 +16,43 @@ CREATE TABLE overview ( | |||||
ov_campaign TEXT, | ov_campaign TEXT, | ||||
ov_operation TEXT, | ov_operation TEXT, | ||||
ov_primary INTEGER DEFAULT 0, | ov_primary INTEGER DEFAULT 0, | ||||
ov_secondary INTEGER DEFAULT NULL, | |||||
ov_secondary REAL DEFAULT NULL, | |||||
UNIQUE (ov_campaign, ov_operation) | UNIQUE (ov_campaign, ov_operation) | ||||
); | ); | ||||
DROP TABLE IF EXISTS kill; | |||||
CREATE TABLE kill ( | |||||
kill_id INTEGER PRIMARY KEY, | |||||
kill_date TIMESTAMP, | |||||
kill_system INTEGER, | |||||
kill_victim_shipid INTEGER, | |||||
kill_victim_charid INTEGER, | |||||
kill_victim_corpid INTEGER, | |||||
kill_victim_allianceid INTEGER, | |||||
kill_victim_factionid INTEGER, | |||||
kill_value REAL | |||||
); | |||||
DROP TABLE IF EXISTS oper_kill; | |||||
CREATE TABLE oper_kill ( | |||||
ok_campaign TEXT, | |||||
ok_operation TEXT, | |||||
ok_killid INTEGER, | |||||
UNIQUE (ok_campaign, ok_operation, ok_killid), | |||||
FOREIGN KEY (ok_killid) REFERENCES kill (kill_id) | |||||
ON DELETE CASCADE ON UPDATE CASCADE | |||||
); | |||||
CREATE INDEX ok_campaign_operation ON oper_kill (ok_campaign, ok_operation); | |||||
CREATE INDEX ok_campaign_killid ON oper_kill (ok_campaign, ok_killid); | |||||
DROP TABLE IF EXISTS oper_item; | |||||
CREATE TABLE oper_item ( | |||||
oi_campaign TEXT, | |||||
oi_operation TEXT | |||||
-- ... | |||||
-- UNIQUE (oi_campaign, oi_operation, ...) | |||||
); |
@@ -29,7 +29,7 @@ | |||||
</div> | </div> | ||||
% if secondary is not None: | % if secondary is not None: | ||||
<div class="secondary"> | <div class="secondary"> | ||||
<span class="num">${"{:,}".format(secondary)}</span> | |||||
<span class="num">${"{:,.2f}".format(secondary)}</span> | |||||
<span class="unit">${mod.get_unit(operation, secondary, primary=False)}</span> | <span class="unit">${mod.get_unit(operation, secondary, primary=False)}</span> | ||||
</div> | </div> | ||||
% endif | % endif | ||||