@@ -10,4 +10,4 @@ logs/ | |||
!config/*.sample | |||
!config/modules | |||
!config/modules/*.sample | |||
!data/schema.sql | |||
!data/schema*.sql |
@@ -37,6 +37,7 @@ class Module: | |||
provided.app = app | |||
provided.blueprint = bp | |||
provided.config = self._config.load_module_config(self._name) | |||
provided.logger = self._logger | |||
sys.modules[provided.__name__] = provided | |||
self._module = importlib.import_module(fullname) | |||
@@ -1,5 +1,5 @@ | |||
# -*- coding: utf-8 -*- | |||
from .getters import get_current, get_count, get_summary, get_unit | |||
from .getters import get_current, get_overview, get_summary, get_unit | |||
from .routes import home, navitem | |||
from .._provided import config |
@@ -0,0 +1,87 @@ | |||
# -*- coding: utf-8 -*- | |||
from datetime import datetime | |||
import sqlite3 | |||
from flask import g | |||
from werkzeug.local import LocalProxy | |||
__all__ = ["CampaignDB"] | |||
class CampaignDB: | |||
"""Database manager for internal storage for the Campaigns module.""" | |||
path = None | |||
def __init__(self): | |||
if self.path is None: | |||
raise RuntimeError("CampaignDB.path not set") | |||
self._conn = sqlite3.connect(self.path) | |||
@classmethod | |||
def _get(cls): | |||
"""Return the current database, or allocate a new one if necessary.""" | |||
if not hasattr(g, "_campaign_db"): | |||
g._campaign_db = cls() | |||
return g._campaign_db | |||
@classmethod | |||
def pre_hook(cls): | |||
"""Hook to be called before a request context. | |||
Sets up the g.campaign_db proxy. | |||
""" | |||
g.campaign_db = LocalProxy(cls._get) | |||
@classmethod | |||
def post_hook(cls, exc): | |||
"""Hook to be called when tearing down an application context. | |||
Closes the database if necessary. | |||
""" | |||
if hasattr(g, "_campaign_db"): | |||
g._campaign_db.close() | |||
def close(self): | |||
"""Close the database connection.""" | |||
return self._conn.close() | |||
def check_operation(self, campaign, operation): | |||
"""Return the last updated timestamp for the given operation. | |||
Return None if the given operation was never updated. | |||
""" | |||
query = """SELECT lu_date FROM last_updated | |||
WHERE lu_campaign = ? AND lu_operation = ?""" | |||
res = self._conn.execute(query, (campaign, operation)).fetchall() | |||
if not res: | |||
return None | |||
return datetime.strptime(res[0][0], "%Y-%m-%d %H:%M:%S") | |||
def add_operation(self, campaign, operation): | |||
"""Insert a new operation into the database as just updated.""" | |||
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 | |||
WHERE lu_campaign = ? AND lu_operation = ?""", ( | |||
campaign, operation)) | |||
def set_overview(self, campaign, operation, primary, secondary=None): | |||
"""Set overview information for this operation.""" | |||
with self._conn as conn: | |||
conn.execute("""INSERT OR REPLACE INTO overview | |||
(ov_campaign, ov_operation, ov_primary, ov_secondary) | |||
VALUES (?, ?, ?, ?)""", ( | |||
campaign, operation, primary, secondary)) | |||
def get_overview(self, campaign, operation): | |||
"""Return a 2-tuple of overview information for this operation.""" | |||
query = """SELECT ov_primary, ov_secondary FROM overview | |||
WHERE ov_campaign = ? AND ov_operation = ?""" | |||
res = self._conn.execute(query, (campaign, operation)).fetchall() | |||
return res[0] if res else (0, None) |
@@ -1,10 +1,36 @@ | |||
# -*- coding: utf-8 -*- | |||
from datetime import datetime, timedelta | |||
from pathlib import Path | |||
from threading import Lock | |||
from flask import g | |||
from .._provided import config | |||
from .database import CampaignDB | |||
from .._provided import app, config, logger | |||
from ...database import Database as MainDB | |||
__all__ = ["get_current", "get_overview", "get_summary", "get_unit"] | |||
CampaignDB.path = str(Path(MainDB.path).parent / "db_campaigns.sqlite3") | |||
__all__ = ["get_current", "get_count", "get_summary", "get_unit"] | |||
app.before_request(CampaignDB.pre_hook) | |||
app.teardown_appcontext(CampaignDB.post_hook) | |||
_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 = 42 | |||
secondary = 63 | |||
g.campaign_db.set_overview(cname, opname, primary, secondary) | |||
def get_current(): | |||
"""Return the name of the currently selected campaign, or None.""" | |||
@@ -15,24 +41,39 @@ def get_current(): | |||
return config["enabled"][0] | |||
return setting | |||
def get_count(cname, opname): | |||
"""Return the primary operation count for the given campaign/operation.""" | |||
key = cname + "." + opname | |||
operation = config["campaigns"][cname]["operations"][opname] | |||
optype = operation["type"] | |||
qualifiers = operation["qualifiers"] | |||
def get_overview(cname, opname): | |||
"""Return overview information for the given campaign/operation. | |||
... | |||
import random | |||
return [random.randint(0, 500), random.randint(10000, 500000), random.randint(10000000, 50000000000)][random.randint(0, 2)] | |||
The overview is a 2-tuple of (primary_count, secondary_count). The latter | |||
may be None, in which case it should not be displayed. | |||
Updates the database if necessary, so this can take some time. | |||
""" | |||
with _lock: | |||
last_updated = g.campaign_db.check_operation(cname, opname) | |||
if last_updated is None: | |||
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 > timedelta(seconds=60 * 60): | |||
logger.debug("Updating campaign=%s operation=%s", cname, opname) | |||
_update_operation(cname, opname, new=False) | |||
g.campaign_db.touch_operation(cname, opname) | |||
else: | |||
logger.debug("Using cache for campaign=%s operation=%s", | |||
cname, opname) | |||
return g.campaign_db.get_overview(cname, opname) | |||
def get_summary(name, opname, limit=5): | |||
"""Return a sample fraction of results for the given campaign/operation.""" | |||
... | |||
return [] | |||
def get_unit(operation, num): | |||
def get_unit(operation, num, primary=True): | |||
"""Return the correct form of the unit tracked by the given operation.""" | |||
if not primary: | |||
return "ISK" | |||
types = { | |||
"killboard": "ship|ships", | |||
"collection": "item|items" | |||
@@ -5,7 +5,7 @@ from collections import namedtuple | |||
from flask import g | |||
from flask_mako import render_template | |||
from ._provided import blueprint | |||
from ._provided import blueprint, logger | |||
from ..exceptions import EVEAPIForbiddenError | |||
SCOPES = {"esi-corporations.read_corporation_membership.v1"} | |||
@@ -29,6 +29,7 @@ def get_members(): | |||
return [] | |||
corp_id = g.config.get("corp.id") | |||
logger.debug("Fetching member list for corp id=%d", corp_id) | |||
try: | |||
ceo_id = g.eve.esi(token).v2.corporations(corp_id).get()["ceo_id"] | |||
cids_r = g.eve.esi(token).v2.corporations(corp_id).members.get() | |||
@@ -27,18 +27,26 @@ campaigns: | |||
operations: | |||
# Will track the number of Foo frigates killed by the corp: | |||
frigates: | |||
# Full operation name: | |||
title: "Operation: Kill Foo Frigates" | |||
# Data source (here, retrieve data from ZKillboard): | |||
type: killboard | |||
# Show total ISK killed (defaults to true): | |||
isk: true | |||
# Python function to filter kills: | |||
qualifiers: |- | |||
return ((victim_corp == "Foo Corporation") and | |||
(victim_ship_class == "Frigate")) | |||
return (kill["victim"]["corporationName"] == "Foo Corporation" and | |||
kill["victim"]["shipTypeID"] in ...) | |||
# Will track possession of Tritanium by all corp members: | |||
titan: | |||
title: Let's Build a Titan | |||
# Here, retrieve data from EVE's Assets API: | |||
type: collection | |||
isk: false | |||
# Report as "10 units" / "1 unit" of Tritanium | |||
unit: unit|units | |||
qualifiers: |- | |||
return item_type == "Tritanium" | |||
return item_type == "Tritanium" # ... | |||
bar: | |||
title: Save the Bar | |||
operations: [] |
@@ -0,0 +1,20 @@ | |||
-- Schema for Calefaction's Campaign module's internal database | |||
DROP TABLE IF EXISTS last_updated; | |||
CREATE TABLE last_updated ( | |||
lu_campaign TEXT, | |||
lu_operation TEXT, | |||
lu_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, | |||
UNIQUE (lu_campaign, lu_operation) | |||
); | |||
DROP TABLE IF EXISTS overview; | |||
CREATE TABLE overview ( | |||
ov_campaign TEXT, | |||
ov_operation TEXT, | |||
ov_primary INTEGER DEFAULT 0, | |||
ov_secondary INTEGER DEFAULT NULL, | |||
UNIQUE (ov_campaign, ov_operation) | |||
); |
@@ -376,20 +376,20 @@ h2 .disabled::after { | |||
justify-content: space-between; | |||
} | |||
.operation .overview .num { | |||
.operation .overview .primary { | |||
line-height: 60px; | |||
height: 60px; | |||
} | |||
.operation .overview .num.big { | |||
.operation .overview .primary.big { | |||
font-size: 300%; | |||
} | |||
.operation .overview .num.medium { | |||
.operation .overview .primary.medium { | |||
font-size: 200%; | |||
} | |||
.operation .overview .num.small { | |||
.operation .overview .primary.small { | |||
font-size: 150%; | |||
} | |||
@@ -14,23 +14,27 @@ | |||
% for opname in section: | |||
<% | |||
operation = campaign["operations"][opname] | |||
num = mod.get_count(name, opname) | |||
primary, secondary = mod.get_overview(name, opname) | |||
summary = mod.get_summary(name, opname, limit=5) | |||
klass = "big" if num < 1000 else "medium" if num < 1000000 else "small" | |||
klass = "big" if primary < 1000 else "medium" if primary < 1000000 else "small" | |||
%> | |||
<div class="operation"> | |||
<h3> | |||
<a href="${url_for('campaigns.operation', cname=name, opname=opname)}">${operation["title"] | h}</a> | |||
</h3> | |||
<div class="overview"> | |||
<div class="num ${klass}">${"{:,}".format(num)}</div> | |||
<div class="unit">${mod.get_unit(operation, num)}</div> | |||
<div class="primary ${klass}">${"{:,}".format(primary)}</div> | |||
<div class="unit">${mod.get_unit(operation, primary)}</div> | |||
% if secondary is not None: | |||
<div class="secondary">${"{:,}".format(secondary)}</div> | |||
<div class="unit">${mod.get_unit(operation, secondary, primary=False)}</div> | |||
% endif | |||
</div> | |||
% if summary: | |||
<ul class="summary"> | |||
% for item in summary: | |||
<li>${item}</li> | |||
% endfor | |||
% for item in summary: | |||
<li>${item}</li> | |||
% endfor | |||
</ul> | |||
% endif | |||
</div> | |||