@@ -141,12 +141,23 @@ class CampaignDB: | |||||
res = self._conn.execute(query, (campaign, operation)).fetchall() | res = self._conn.execute(query, (campaign, operation)).fetchall() | ||||
return tuple(res[0]) | return tuple(res[0]) | ||||
def get_associated_kills(self, campaign, operation, limit=5, offset=0): | |||||
def get_associated_kills(self, campaign, operation, sort="new", limit=-1, | |||||
offset=0): | |||||
"""Return a list of kills associated with a campaign/operation. | """Return a list of kills associated with a campaign/operation. | ||||
Kills are returned as dictionaries most recent first, up to a limit. | |||||
Use -1 for no limit. | |||||
Kills are returned as dictionaries, up to a limit. Use -1 for no limit. | |||||
The sort should be "new" for most recent first, "old" for most recent | |||||
last, or "value" for most valuable first. | |||||
""" | """ | ||||
sortkeys = { | |||||
"new": "ok_killid DESC", | |||||
"old": "ok_killid ASC", | |||||
"value": "kill_value DESC, ok_killid DESC" | |||||
} | |||||
if sort in sortkeys: | |||||
sortkey = sortkeys[sort] | |||||
else: | |||||
raise ValueError(sort) | |||||
if not isinstance(limit, int): | if not isinstance(limit, int): | ||||
raise ValueError(limit) | raise ValueError(limit) | ||||
if not isinstance(offset, int): | if not isinstance(offset, int): | ||||
@@ -160,8 +171,8 @@ class CampaignDB: | |||||
FROM oper_kill | FROM oper_kill | ||||
JOIN kill ON ok_killid = kill_id | JOIN kill ON ok_killid = kill_id | ||||
WHERE ok_campaign = ? AND ok_operation = ? | WHERE ok_campaign = ? AND ok_operation = ? | ||||
ORDER BY ok_killid DESC LIMIT {} OFFSET {}""" | |||||
qform = query.format(limit, offset) | |||||
ORDER BY {} LIMIT {} OFFSET {}""" | |||||
qform = query.format(sortkey, limit, offset) | |||||
res = self._conn.execute(qform, (campaign, operation)).fetchall() | res = self._conn.execute(qform, (campaign, operation)).fetchall() | ||||
return [{ | return [{ | ||||
@@ -205,21 +216,34 @@ class CampaignDB: | |||||
for char_id, types in chars.items() | for char_id, types in chars.items() | ||||
for type_id, (count, value) in types.items()]) | for type_id, (count, value) in types.items()]) | ||||
def get_associated_items(self, campaign, operation, limit=5, offset=0): | |||||
def get_associated_items(self, campaign, operation, sort="value", limit=-1, | |||||
offset=0): | |||||
"""Return a list of items associated with a campaign/operation. | """Return a list of items associated with a campaign/operation. | ||||
Items are returned as 2-tuples of (item_type, item_count), most | |||||
valuable first, up to a limit. Use -1 for no limit. | |||||
Items are returned as 2-tuples of (item_type, item_count), up to a | |||||
limit. Use -1 for no limit. The sort should be "value" for most | |||||
valuable first (individual item value * quantity), "quantity" for | |||||
greatest quantity first, "price" for most valuable items first. | |||||
""" | """ | ||||
sortkeys = { | |||||
"value": "total_value DESC", | |||||
"quantity": "total_count DESC", | |||||
"price": "(total_value / total_count) DESC" | |||||
} | |||||
if sort in sortkeys: | |||||
sortkey = sortkeys[sort] | |||||
else: | |||||
raise ValueError(sort) | |||||
if not isinstance(limit, int): | if not isinstance(limit, int): | ||||
raise ValueError(limit) | raise ValueError(limit) | ||||
if not isinstance(offset, int): | if not isinstance(offset, int): | ||||
raise ValueError(offset) | raise ValueError(offset) | ||||
query = """SELECT oi_type, SUM(oi_count), TOTAL(oi_value) as total_val | |||||
query = """SELECT oi_type, SUM(oi_count) AS total_count, | |||||
TOTAL(oi_value) as total_value | |||||
FROM oper_item | FROM oper_item | ||||
WHERE oi_campaign = ? AND oi_operation = ? | WHERE oi_campaign = ? AND oi_operation = ? | ||||
GROUP BY oi_type ORDER BY total_val DESC LIMIT {} OFFSET {}""" | |||||
qform = query.format(limit, offset) | |||||
GROUP BY oi_type ORDER BY {} LIMIT {} OFFSET {}""" | |||||
qform = query.format(sortkey, limit, offset) | |||||
res = self._conn.execute(qform, (campaign, operation)).fetchall() | res = self._conn.execute(qform, (campaign, operation)).fetchall() | ||||
return [(type_id, count or 0, value) for type_id, count, value in res] | return [(type_id, count or 0, value) for type_id, count, value in res] |
@@ -55,18 +55,25 @@ def get_overview(cname, opname): | |||||
"operation=%s", age, cname, opname) | "operation=%s", age, cname, opname) | ||||
return g.campaign_db.get_overview(cname, opname) | return g.campaign_db.get_overview(cname, opname) | ||||
def get_summary(cname, opname, limit=5): | |||||
"""Return a sample fraction of results for the given campaign/operation.""" | |||||
def get_summary(cname, opname, sortby=None, limit=5): | |||||
"""Return some details for the given campaign/operation.""" | |||||
optype = config["campaigns"][cname]["operations"][opname]["type"] | optype = config["campaigns"][cname]["operations"][opname]["type"] | ||||
if optype == "killboard": | if optype == "killboard": | ||||
kills = g.campaign_db.get_associated_kills(cname, opname, limit=limit) | |||||
return kills, "killboard_recent" | |||||
sorts = ["new", "old", "value"] | |||||
renderer = "killboard_recent" | |||||
func = g.campaign_db.get_associated_kills | |||||
elif optype == "collection": | elif optype == "collection": | ||||
items = g.campaign_db.get_associated_items(cname, opname, limit=limit) | |||||
return items, "collection_items" | |||||
sorts = ["value", "quantity", "price"] | |||||
renderer = "collection_items" | |||||
func = g.campaign_db.get_associated_items | |||||
else: | else: | ||||
raise RuntimeError("Unknown operation type: %s" % optype) | raise RuntimeError("Unknown operation type: %s" % optype) | ||||
if sortby not in sorts: | |||||
sortby = sorts[0] | |||||
data = func(cname, opname, sort=sortby, limit=limit) | |||||
return data, renderer | |||||
def get_unit(operation, num, primary=True): | 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: | if not primary: | ||||
@@ -52,10 +52,11 @@ def operation(cname, opname): | |||||
if opname not in campaign["operations"]: | if opname not in campaign["operations"]: | ||||
abort(404) | abort(404) | ||||
operation = campaign["operations"][opname] | operation = campaign["operations"][opname] | ||||
sortby = request.args.get("sort") | |||||
enabled = cname in config["enabled"] and opname in campaign["enabled"] | enabled = cname in config["enabled"] and opname in campaign["enabled"] | ||||
return render_template("campaigns/operation.mako", | return render_template("campaigns/operation.mako", | ||||
cname=cname, campaign=campaign, opname=opname, | cname=cname, campaign=campaign, opname=opname, | ||||
operation=operation, enabled=enabled) | |||||
operation=operation, sortby=sortby, enabled=enabled) | |||||
@blueprint.rroute("/settings/campaign", methods=["POST"]) | @blueprint.rroute("/settings/campaign", methods=["POST"]) | ||||
def set_campaign(): | def set_campaign(): | ||||
@@ -218,7 +218,36 @@ h2 .disabled-info { | |||||
.operation.detail .detail-list li:not(:last-child)::after { | .operation.detail .detail-list li:not(:last-child)::after { | ||||
margin-left: 0.25em; | margin-left: 0.25em; | ||||
content: "/"; | content: "/"; | ||||
color: #AAA; | |||||
color: #777; | |||||
} | |||||
.change-sort { | |||||
display: inline-block; | |||||
margin: 0; | |||||
padding: 0; | |||||
} | |||||
.change-sort li { | |||||
display: inline-block; | |||||
} | |||||
.change-sort .cur { | |||||
font-weight: bold; | |||||
} | |||||
.change-sort::before { | |||||
content: "["; | |||||
color: #777; | |||||
} | |||||
.change-sort::after { | |||||
content: "]"; | |||||
color: #777; | |||||
} | |||||
.change-sort li:not(:last-child)::after { | |||||
content: " |"; | |||||
color: #777; | |||||
} | } | ||||
.last-updated { | .last-updated { | ||||
@@ -27,7 +27,7 @@ | |||||
<% | <% | ||||
mod = g.config.modules.campaigns | mod = g.config.modules.campaigns | ||||
primary, secondary = mod.get_overview(cname, opname) | primary, secondary = mod.get_overview(cname, opname) | ||||
summary, renderer = mod.get_summary(cname, opname, limit=-1) | |||||
summary, renderer = mod.get_summary(cname, opname, sortby=sortby, limit=-1) | |||||
klass = "big" if primary < 1000 else "medium" if primary < 1000000 else "small" | klass = "big" if primary < 1000 else "medium" if primary < 1000000 else "small" | ||||
punit = mod.get_unit(operation, primary) | punit = mod.get_unit(operation, primary) | ||||
sunit = mod.get_unit(operation, secondary, primary=False) | sunit = mod.get_unit(operation, secondary, primary=False) | ||||
@@ -48,7 +48,7 @@ | |||||
</div> | </div> | ||||
% if summary: | % if summary: | ||||
<div class="summary"> | <div class="summary"> | ||||
${render_summary(renderer, summary, detail=True)} | |||||
${render_summary(renderer, summary, detail=True, sortby=sortby)} | |||||
</div> | </div> | ||||
% endif | % endif | ||||
</div> | </div> | ||||
@@ -95,9 +95,32 @@ | |||||
</td> | </td> | ||||
</tr> | </tr> | ||||
</%def> | </%def> | ||||
<%def name="_killboard_recent(summary, detail)"> | |||||
<%def name="_build_sort_changer(keys, descriptors, sortby)"> | |||||
% if keys: | |||||
<ul class="change-sort"> | |||||
% for key in keys: | |||||
% if key == sortby: | |||||
<li class="cur">${descriptors[key]}</li> | |||||
% else: | |||||
<li><a href="${url_for('.operation', cname=cname, opname=opname, sort=key)}">${descriptors[key]}</a></li> | |||||
% endif | |||||
% endfor | |||||
</ul> | |||||
% endif | |||||
</%def> | |||||
<%def name="_killboard_recent(summary, detail, sortby)"> | |||||
% if detail: | % if detail: | ||||
<% | |||||
descriptors = { | |||||
"new": "most recent first", | |||||
"old": "most recent last", | |||||
"value": "most valuable first" | |||||
} | |||||
if sortby not in descriptors: | |||||
sortby = "new" | |||||
%> | |||||
<h3 class="head">Kills:</h3> | <h3 class="head">Kills:</h3> | ||||
${_build_sort_changer(["new", "old", "value"], descriptors, sortby)} | |||||
% else: | % else: | ||||
<div class="head">Most recent kills:</div> | <div class="head">Most recent kills:</div> | ||||
% endif | % endif | ||||
@@ -113,9 +136,19 @@ | |||||
</table> | </table> | ||||
</div> | </div> | ||||
</%def> | </%def> | ||||
<%def name="_collection_items(summary, detail)"> | |||||
<%def name="_collection_items(summary, detail, sortby)"> | |||||
% if detail: | % if detail: | ||||
<% | |||||
descriptors = { | |||||
"value": "most valuable first", | |||||
"quantity": "greatest quantity first", | |||||
"price": "most expensive first" | |||||
} | |||||
if sortby not in descriptors: | |||||
sortby = "value" | |||||
%> | |||||
<h3 class="head">Items:</h3> | <h3 class="head">Items:</h3> | ||||
${_build_sort_changer(["value", "quantity", "price"], descriptors, sortby)} | |||||
% else: | % else: | ||||
<div class="head">Top items:</div> | <div class="head">Top items:</div> | ||||
% endif | % endif | ||||
@@ -128,10 +161,10 @@ | |||||
</div> | </div> | ||||
</%def> | </%def> | ||||
<%def name="render_summary(renderer, summary, detail=False)"><% | |||||
<%def name="render_summary(renderer, summary, detail=False, sortby=None)"><% | |||||
if renderer == "killboard_recent": | if renderer == "killboard_recent": | ||||
return _killboard_recent(summary, detail) | |||||
return _killboard_recent(summary, detail, sortby) | |||||
if renderer == "collection_items": | if renderer == "collection_items": | ||||
return _collection_items(summary, detail) | |||||
return _collection_items(summary, detail, sortby) | |||||
raise RuntimeError("Unknown renderer: %s" % renderer) | raise RuntimeError("Unknown renderer: %s" % renderer) | ||||
%></%def> | %></%def> |