@@ -10,4 +10,4 @@ logs/ | |||||
!config/*.sample | !config/*.sample | ||||
!config/modules | !config/modules | ||||
!config/modules/*.sample | !config/modules/*.sample | ||||
!data/schema.sql | |||||
!data/schema*.sql |
@@ -37,6 +37,7 @@ class Module: | |||||
provided.app = app | provided.app = app | ||||
provided.blueprint = bp | provided.blueprint = bp | ||||
provided.config = self._config.load_module_config(self._name) | provided.config = self._config.load_module_config(self._name) | ||||
provided.logger = self._logger | |||||
sys.modules[provided.__name__] = provided | sys.modules[provided.__name__] = provided | ||||
self._module = importlib.import_module(fullname) | self._module = importlib.import_module(fullname) | ||||
@@ -1,5 +1,5 @@ | |||||
# -*- coding: utf-8 -*- | # -*- 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 .routes import home, navitem | ||||
from .._provided import config | 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 -*- | # -*- coding: utf-8 -*- | ||||
from datetime import datetime, timedelta | |||||
from pathlib import Path | |||||
from threading import Lock | |||||
from flask import g | 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(): | def get_current(): | ||||
"""Return the name of the currently selected campaign, or None.""" | """Return the name of the currently selected campaign, or None.""" | ||||
@@ -15,24 +41,39 @@ def get_current(): | |||||
return config["enabled"][0] | return config["enabled"][0] | ||||
return setting | 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): | def get_summary(name, opname, limit=5): | ||||
"""Return a sample fraction of results for the given campaign/operation.""" | """Return a sample fraction of results for the given campaign/operation.""" | ||||
... | ... | ||||
return [] | 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.""" | """Return the correct form of the unit tracked by the given operation.""" | ||||
if not primary: | |||||
return "ISK" | |||||
types = { | types = { | ||||
"killboard": "ship|ships", | "killboard": "ship|ships", | ||||
"collection": "item|items" | "collection": "item|items" | ||||
@@ -5,7 +5,7 @@ from collections import namedtuple | |||||
from flask import g | from flask import g | ||||
from flask_mako import render_template | from flask_mako import render_template | ||||
from ._provided import blueprint | |||||
from ._provided import blueprint, logger | |||||
from ..exceptions import EVEAPIForbiddenError | from ..exceptions import EVEAPIForbiddenError | ||||
SCOPES = {"esi-corporations.read_corporation_membership.v1"} | SCOPES = {"esi-corporations.read_corporation_membership.v1"} | ||||
@@ -29,6 +29,7 @@ def get_members(): | |||||
return [] | return [] | ||||
corp_id = g.config.get("corp.id") | corp_id = g.config.get("corp.id") | ||||
logger.debug("Fetching member list for corp id=%d", corp_id) | |||||
try: | try: | ||||
ceo_id = g.eve.esi(token).v2.corporations(corp_id).get()["ceo_id"] | 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() | cids_r = g.eve.esi(token).v2.corporations(corp_id).members.get() | ||||
@@ -27,18 +27,26 @@ campaigns: | |||||
operations: | operations: | ||||
# Will track the number of Foo frigates killed by the corp: | # Will track the number of Foo frigates killed by the corp: | ||||
frigates: | frigates: | ||||
# Full operation name: | |||||
title: "Operation: Kill Foo Frigates" | title: "Operation: Kill Foo Frigates" | ||||
# Data source (here, retrieve data from ZKillboard): | |||||
type: killboard | type: killboard | ||||
# Show total ISK killed (defaults to true): | |||||
isk: true | |||||
# Python function to filter kills: | |||||
qualifiers: |- | 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: | # Will track possession of Tritanium by all corp members: | ||||
titan: | titan: | ||||
title: Let's Build a Titan | title: Let's Build a Titan | ||||
# Here, retrieve data from EVE's Assets API: | |||||
type: collection | type: collection | ||||
isk: false | |||||
# Report as "10 units" / "1 unit" of Tritanium | |||||
unit: unit|units | unit: unit|units | ||||
qualifiers: |- | qualifiers: |- | ||||
return item_type == "Tritanium" | |||||
return item_type == "Tritanium" # ... | |||||
bar: | bar: | ||||
title: Save the Bar | title: Save the Bar | ||||
operations: [] | 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; | justify-content: space-between; | ||||
} | } | ||||
.operation .overview .num { | |||||
.operation .overview .primary { | |||||
line-height: 60px; | line-height: 60px; | ||||
height: 60px; | height: 60px; | ||||
} | } | ||||
.operation .overview .num.big { | |||||
.operation .overview .primary.big { | |||||
font-size: 300%; | font-size: 300%; | ||||
} | } | ||||
.operation .overview .num.medium { | |||||
.operation .overview .primary.medium { | |||||
font-size: 200%; | font-size: 200%; | ||||
} | } | ||||
.operation .overview .num.small { | |||||
.operation .overview .primary.small { | |||||
font-size: 150%; | font-size: 150%; | ||||
} | } | ||||
@@ -14,23 +14,27 @@ | |||||
% for opname in section: | % for opname in section: | ||||
<% | <% | ||||
operation = campaign["operations"][opname] | 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) | 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"> | <div class="operation"> | ||||
<h3> | <h3> | ||||
<a href="${url_for('campaigns.operation', cname=name, opname=opname)}">${operation["title"] | h}</a> | <a href="${url_for('campaigns.operation', cname=name, opname=opname)}">${operation["title"] | h}</a> | ||||
</h3> | </h3> | ||||
<div class="overview"> | <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> | </div> | ||||
% if summary: | % if summary: | ||||
<ul class="summary"> | <ul class="summary"> | ||||
% for item in summary: | |||||
<li>${item}</li> | |||||
% endfor | |||||
% for item in summary: | |||||
<li>${item}</li> | |||||
% endfor | |||||
</ul> | </ul> | ||||
% endif | % endif | ||||
</div> | </div> | ||||