* Add config/modules/. * Add hook-aware post-login homepage. * Add flexible character module property database table, plus AuthManager interface. * Add Config.modules. * Modules are imported dynamically with a _provided pseudo-module that contains their Flask blueprint and a config reference. * Add stubs for Map, Members, and Intel modules. * Implement starting code for Campaigns module. * Update sample config. * Update database schema with foreign key constraints (unused for now). * Restructure header HTML and desktop CSS slightly to cleanly display large navigation bars. * Dynamically generate navigation bar. * Properly align character options popup with portrait.master
@@ -3,8 +3,11 @@ __pycache__/ | |||
venv/ | |||
config/* | |||
config/modules/* | |||
data/* | |||
logs/ | |||
!config/config.yml.sample | |||
!config/*.sample | |||
!config/modules | |||
!config/modules/*.sample | |||
!data/schema.sql |
@@ -24,8 +24,8 @@ Database.path = str(basepath / "data" / "db.sqlite3") | |||
eve = EVE(config) | |||
auth = AuthManager(config, eve) | |||
catch_exceptions = make_error_catcher(app, "error.mako") | |||
route_restricted = make_route_restricter( | |||
app.catch_exceptions = make_error_catcher(app, "error.mako") | |||
app.route_restricted = make_route_restricter( | |||
auth, lambda: redirect(url_for("index"), 303)) | |||
MakoTemplates(app) | |||
@@ -38,21 +38,25 @@ def prepare_request(): | |||
g.auth = auth | |||
g.config = config | |||
g.eve = eve | |||
g.modules = config.modules | |||
g.version = calefaction.__version__ | |||
app.before_request(Database.pre_hook) | |||
app.teardown_appcontext(Database.post_hook) | |||
@app.route("/") | |||
@catch_exceptions | |||
@app.catch_exceptions | |||
def index(): | |||
success, _ = try_func(auth.is_authenticated) | |||
if success: | |||
return render_template("home.mako") | |||
module = config.get("modules.home") | |||
if module: | |||
return config.modules[module].home() | |||
return render_template("default_home.mako") | |||
return render_template("landing.mako") | |||
@app.route("/login", methods=["GET", "POST"]) | |||
@catch_exceptions | |||
@app.catch_exceptions | |||
def login(): | |||
code = request.args.get("code") | |||
state = request.args.get("state") | |||
@@ -65,7 +69,7 @@ def login(): | |||
return redirect(url_for("index"), 303) | |||
@app.route("/logout", methods=["GET", "POST"]) | |||
@catch_exceptions | |||
@app.catch_exceptions | |||
def logout(): | |||
if request.method == "GET": | |||
return render_template("logout.mako") | |||
@@ -75,8 +79,8 @@ def logout(): | |||
return redirect(url_for("index"), 303) | |||
@app.route("/settings/style/<style>", methods=["POST"]) | |||
@catch_exceptions | |||
@route_restricted | |||
@app.catch_exceptions | |||
@app.route_restricted | |||
def set_style(style): | |||
if not auth.set_character_style(style): | |||
abort(404) | |||
@@ -10,7 +10,7 @@ from .exceptions import AccessDeniedError | |||
__all__ = ["AuthManager"] | |||
_SCOPES = ["publicData", "characterAssetsRead"] # ... | |||
_SCOPES = [] # ... | |||
class AuthManager: | |||
"""Authentication manager. Handles user access and management.""" | |||
@@ -196,10 +196,20 @@ class AuthManager: | |||
self._invalidate_session() | |||
raise AccessDeniedError() | |||
def _update_prop_cache(self, module, prop, value): | |||
"""Update the value of a character module property in the cache.""" | |||
if hasattr(g, "_character_modprops"): | |||
propcache = g._character_modprops | |||
else: | |||
propcache = g._character_modprops = {module: {}} | |||
if module not in propcache: | |||
propcache[module] = {} | |||
propcache[module][prop] = value | |||
def get_character_id(self): | |||
"""Return the character ID associated with the current session. | |||
Returns None if the session is invalid or is not associated with a | |||
Return None if the session is invalid or is not associated with a | |||
character. | |||
""" | |||
if not self._check_session(): | |||
@@ -212,7 +222,7 @@ class AuthManager: | |||
def get_character_prop(self, prop): | |||
"""Look up a property for the current session's character. | |||
Returns None if the session is invalid, is not associated with a | |||
Return None if the session is invalid, is not associated with a | |||
character, or the property has no non-default value. | |||
""" | |||
cid = self.get_character_id() | |||
@@ -240,6 +250,40 @@ class AuthManager: | |||
delattr(g, "_character_props") | |||
return True | |||
def get_character_modprop(self, module, prop): | |||
"""Look up a module property for the current session's character. | |||
Return None if the session is invalid, is not associated with a | |||
character, or the property has no non-default value. | |||
""" | |||
cid = self.get_character_id() | |||
if not cid: | |||
return None | |||
if hasattr(g, "_character_modprops"): | |||
propcache = g._character_modprops | |||
if module in propcache and prop in propcache[module]: | |||
return propcache[module][prop] | |||
value = g.db.get_character_modprop(cid, module, prop) | |||
self._update_prop_cache(module, prop, value) | |||
return value | |||
def set_character_modprop(self, module, prop, value): | |||
"""Update a module property for the current session's character. | |||
Return whether successful. | |||
""" | |||
cid = self.get_character_id() | |||
if not cid: | |||
return False | |||
self._debug("Setting module %s property %s to %s for char id=%d", | |||
module, prop, value, cid) | |||
g.db.set_character_modprop(cid, module, prop, value) | |||
self._update_prop_cache(module, prop, value) | |||
return True | |||
def is_authenticated(self): | |||
"""Return whether the user has permission to access this site. | |||
@@ -2,14 +2,39 @@ | |||
import yaml | |||
from .module import Module | |||
__all__ = ["Config"] | |||
class _ModuleIndex(list): | |||
"""List class that supports attribute access to its elements by key.""" | |||
def __init__(self): | |||
super().__init__() | |||
self._index = {} | |||
def __getitem__(self, key): | |||
try: | |||
return super().__getitem__(key) | |||
except TypeError: | |||
return super().__getitem__(self._index[key]) | |||
def __getattr__(self, attr): | |||
return self[self._index[attr]] | |||
def append(self, key, value): | |||
self._index[key] = len(self) | |||
super().append(value) | |||
class Config: | |||
"""Stores application-wide configuration info.""" | |||
def __init__(self, confdir): | |||
self._dir = confdir | |||
self._filename = confdir / "config.yml" | |||
self._data = None | |||
self._modules = _ModuleIndex() | |||
self._load() | |||
def _load(self): | |||
@@ -17,6 +42,10 @@ class Config: | |||
with self._filename.open("rb") as fp: | |||
self._data = yaml.load(fp) | |||
self._modules = _ModuleIndex() | |||
for name in self.get("modules.enabled", []): | |||
self._modules.append(name, Module(self, name)) | |||
def get(self, key="", default=None): | |||
"""Acts like a dict lookup in the config file. | |||
@@ -31,12 +60,32 @@ class Config: | |||
return obj | |||
@property | |||
def modules(self): | |||
"""Return a list-like object (a _ModuleIndex) of loaded modules.""" | |||
return self._modules | |||
@property | |||
def scheme(self): | |||
"""Return the site's configured scheme, either "http" or "https".""" | |||
return "https" if self.get("site.https") else "http" | |||
def install(self, app): | |||
"""Install relevant config parameters into the application.""" | |||
"""Install relevant config into the application, including modules.""" | |||
app.config["SERVER_NAME"] = self.get("site.canonical") | |||
app.config["PREFERRED_URL_SCHEME"] = self.scheme | |||
app.secret_key = self.get("auth.session_key") | |||
for module in self.modules: | |||
module.install(app) | |||
def load_module_config(self, name): | |||
"""Load and return a module config file. | |||
Returns a YAML parse of {confdir}/modules/{name}.yml, or None. | |||
""" | |||
filename = self._dir / "modules" / (name + ".yml") | |||
try: | |||
with filename.open("rb") as fp: | |||
return yaml.load(fp) | |||
except FileNotFoundError: | |||
return None |
@@ -203,3 +203,17 @@ class Database: | |||
"""Drop any authentication info for the given character.""" | |||
with self._conn as conn: | |||
conn.execute("DELETE FROM auth WHERE auth_character = ?", (cid,)) | |||
def set_character_modprop(self, cid, module, prop, value): | |||
"""Add or update a character module property.""" | |||
with self._conn as conn: | |||
conn.execute("""INSERT OR REPLACE INTO character_prop | |||
(cprop_character, cprop_module, cprop_key, cprop_value) | |||
VALUES (?, ?, ?, ?)""", (cid, module, prop, value)) | |||
def get_character_modprop(self, cid, module, prop): | |||
"""Return the value of a character module property, or None.""" | |||
query = """SELECT cprop_value FROM character_prop | |||
WHERE cprop_character = ? AND cprop_module = ? AND cprop_key = ?""" | |||
res = self._conn.execute(query, (cid, module, prop)).fetchall() | |||
return res[0][0] if res else None |
@@ -0,0 +1,53 @@ | |||
# -*- coding: utf-8 -*- | |||
import importlib | |||
import sys | |||
from types import ModuleType | |||
from flask import Blueprint | |||
from . import baseLogger | |||
__all__ = ["Module"] | |||
class Module: | |||
"""Handles common operations for Calefaction's modular components.""" | |||
def __init__(self, config, name): | |||
self._config = config | |||
self._name = name | |||
self._logger = baseLogger.getChild("module").getChild(name) | |||
self._module = None | |||
self._blueprint = None | |||
def __getattr__(self, attr): | |||
return getattr(self._module, attr) | |||
def _import(self, app): | |||
"""Set up the environment for the module, then import it.""" | |||
base = "calefaction.modules" | |||
fullname = base + "." + self._name | |||
self._blueprint = bp = Blueprint(self._name, fullname) | |||
bp.rroute = lambda *a, **kw: (lambda f: bp.route(*a, **kw)( | |||
app.catch_exceptions(app.route_restricted(f)))) | |||
provided = ModuleType(base + "._provided") | |||
provided.__package__ = base | |||
provided.app = app | |||
provided.blueprint = bp | |||
provided.config = self._config.load_module_config(self._name) | |||
sys.modules[provided.__name__] = provided | |||
self._module = importlib.import_module(fullname) | |||
del sys.modules[provided.__name__] | |||
def install(self, app): | |||
"""Load this module and register it with the application.""" | |||
self._import(app) | |||
app.register_blueprint(self._blueprint) | |||
def navitem(self): | |||
"""Return a navigation bar HTML snippet for this module, or None.""" | |||
if hasattr(self._module, "navitem"): | |||
return self._module.navitem() |
@@ -0,0 +1,35 @@ | |||
# -*- coding: utf-8 -*- | |||
from flask import abort, g | |||
from flask_mako import render_template | |||
from ._provided import blueprint, config | |||
def get_current(): | |||
"""Return the name of the currently selected campaign, or None.""" | |||
if not config["enabled"]: | |||
return None | |||
setting = g.auth.get_character_modprop("campaigns", "current") | |||
if not setting or setting not in config["enabled"]: | |||
return config["enabled"][0] | |||
return setting | |||
def home(): | |||
return render_template("campaigns/campaign.mako", current=get_current()) | |||
def navitem(): | |||
current = get_current() | |||
if current: | |||
result = render_template("campaigns/navitem.mako", current=current) | |||
return result.decode("utf8") | |||
@blueprint.rroute("/campaign") | |||
def campaign(): | |||
return home() | |||
@blueprint.rroute("/settings/campaign/<campaign>", methods=["POST"]) | |||
def set_campaign(campaign): | |||
if campaign not in config["enabled"]: | |||
abort(404) | |||
g.auth.set_character_modprop("campaigns", "current", campaign) | |||
return "", 204 |
@@ -0,0 +1,6 @@ | |||
# -*- coding: utf-8 -*- | |||
# ... | |||
def navitem(): | |||
return "Intel" |
@@ -0,0 +1,6 @@ | |||
# -*- coding: utf-8 -*- | |||
# ... | |||
def navitem(): | |||
return "Map" |
@@ -0,0 +1,6 @@ | |||
# -*- coding: utf-8 -*- | |||
# ... | |||
def navitem(): | |||
return "Members" |
@@ -20,12 +20,15 @@ corp: | |||
modules: | |||
# Most site functionality comes from optional modules selected based on your | |||
# corp's needs. They are located in the calefaction/modules/ directory. The | |||
# corp's needs. They are located in the calefaction/modules/ directory. Their | |||
# order below determines how they appear in the navigation menu. | |||
# List of enabled modules: | |||
- campaigns | |||
- map | |||
- members | |||
enabled: | |||
- campaigns | |||
- map | |||
- members | |||
# Module to show on the home page after users log in: | |||
home: campaigns | |||
auth: | |||
# Secure session signing key. Never share with anyone. Can generate with | |||
@@ -44,6 +47,7 @@ auth: | |||
style: | |||
# Default stylesheet from static/styles/*.css: | |||
default: null | |||
# List of enabled stylesheets: | |||
enabled: | |||
- amarr | |||
- caldari | |||
@@ -0,0 +1,15 @@ | |||
# This is a sample config file for Calefaction's Campaigns module. | |||
# Copy this to campaigns.yml and modify it to set up the module. | |||
# You must restart the server after making any changes. | |||
# List of active campaigns, in your preferred order: | |||
enabled: | |||
- Foo | |||
- Bar | |||
campaigns: | |||
# ... | |||
Foo: | |||
a: b | |||
Bar: | |||
a: b |
@@ -1,14 +1,5 @@ | |||
-- Schema for Calefaction's internal database | |||
DROP TABLE IF EXISTS session; | |||
CREATE TABLE session ( | |||
session_id INTEGER PRIMARY KEY AUTOINCREMENT, | |||
session_character INTEGER DEFAULT NULL, | |||
session_created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, | |||
session_touched TIMESTAMP DEFAULT CURRENT_TIMESTAMP | |||
); | |||
DROP TABLE IF EXISTS character; | |||
CREATE TABLE character ( | |||
@@ -17,11 +8,36 @@ CREATE TABLE character ( | |||
character_style TEXT DEFAULT NULL | |||
); | |||
DROP TABLE IF EXISTS session; | |||
CREATE TABLE session ( | |||
session_id INTEGER PRIMARY KEY AUTOINCREMENT, | |||
session_character INTEGER DEFAULT NULL, | |||
session_created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, | |||
session_touched TIMESTAMP DEFAULT CURRENT_TIMESTAMP, | |||
FOREIGN KEY (session_character) REFERENCES character (character_id) | |||
ON DELETE SET NULL ON UPDATE CASCADE | |||
); | |||
DROP TABLE IF EXISTS auth; | |||
CREATE TABLE auth ( | |||
auth_character INTEGER PRIMARY KEY, | |||
auth_token BLOB, | |||
auth_refresh BLOB, | |||
auth_token_expiry TIMESTAMP | |||
auth_token_expiry TIMESTAMP, | |||
FOREIGN KEY (auth_character) REFERENCES character (character_id) | |||
ON DELETE CASCADE ON UPDATE CASCADE | |||
); | |||
DROP TABLE IF EXISTS character_prop; | |||
CREATE TABLE character_prop ( | |||
cprop_character INTEGER, | |||
cprop_module TEXT, | |||
cprop_key TEXT, | |||
cprop_value TEXT, | |||
UNIQUE (cprop_character, cprop_module, cprop_key), | |||
FOREIGN KEY (cprop_character) REFERENCES character (character_id) | |||
ON DELETE CASCADE ON UPDATE CASCADE | |||
); |
@@ -28,6 +28,10 @@ h2 { | |||
margin: 0.5em 0; | |||
} | |||
.understate { | |||
font-weight: normal; | |||
} | |||
#container { | |||
display: flex; | |||
flex: 1; | |||
@@ -66,10 +70,6 @@ header > div, footer > div { | |||
margin: 0 auto; | |||
} | |||
header > div > div { | |||
display: inline-block; | |||
} | |||
@media (min-width: 800px) { | |||
main, header > div, footer > div { | |||
max-width: 1000px; | |||
@@ -81,17 +81,30 @@ header > div > div { | |||
padding: 0.5em 1.5em; | |||
} | |||
header > div { | |||
display: table; | |||
} | |||
footer > div { | |||
padding: 0.5em 1.5em; | |||
} | |||
header > div > .left { | |||
margin: 0.5em 1.5em 0.5em 0; | |||
header > div > div { | |||
display: table-row; | |||
} | |||
header > div > div > div { | |||
display: table-cell; | |||
} | |||
header > div > .right { | |||
margin: 0.5em 0; | |||
float: right; | |||
header .left { | |||
padding: 0.5em 1.5em 0.5em 0; | |||
} | |||
header .right { | |||
padding: 0.5em 0; | |||
text-align: right; | |||
white-space: nowrap; | |||
} | |||
} | |||
@@ -105,19 +118,38 @@ header > div > div { | |||
padding: 0.5em 1em; | |||
} | |||
header > div > div { | |||
margin: 0.5em 1em; | |||
header > div > div > div { | |||
display: inline-block; | |||
} | |||
header > div > .left { | |||
margin-bottom: 0em; | |||
header .left { | |||
margin: 0.5em 1em 0em 1em; | |||
} | |||
header .right { | |||
margin: 0.25em 1em 0.5em 1em; | |||
} | |||
} | |||
header nav { | |||
display: inline-block; | |||
margin-left: 1.5em; | |||
vertical-align: middle; | |||
padding: 0.25em 0; | |||
} | |||
header nav > ul { | |||
display: inline-block; | |||
margin: 0; | |||
padding: 0; | |||
} | |||
header nav > ul > li { | |||
display: inline-block; | |||
} | |||
header nav > ul > li:not(:last-child):after { | |||
content: " \007c"; | |||
color: #777; | |||
} | |||
header .spacer { | |||
@@ -146,6 +178,7 @@ footer ul li:not(:last-child):after { | |||
#corp-masthead { | |||
color: #EAEAEA; | |||
margin-right: 1.5em; | |||
} | |||
#corp-masthead:hover { | |||
@@ -189,6 +222,11 @@ footer ul li:not(:last-child):after { | |||
} | |||
#character-portrait { | |||
position: relative; | |||
display: inline-block; | |||
} | |||
#character-portrait img { | |||
height: 32px; | |||
width: 32px; | |||
margin-right: 0.25em; | |||
@@ -15,7 +15,7 @@ $(function() { | |||
// Toggle character options on click: | |||
var charopts = $("#character-options"); | |||
charopts.hide(); | |||
$("#character-portrait").click(function() { | |||
$("#character-portrait img").click(function() { | |||
if (charopts.is(":visible")) { | |||
charopts.hide(); | |||
$(document).off("mouseup.charopts"); | |||
@@ -23,7 +23,7 @@ $(function() { | |||
charopts.show(); | |||
$(document).on("mouseup.charopts", function(e) { | |||
if (!overlaps(e.target, charopts) && | |||
!overlaps(e.target, $("#character-portrait"))) { | |||
!overlaps(e.target, $("#character-portrait img"))) { | |||
charopts.hide(); | |||
$(document).off("mouseup.charopts"); | |||
} | |||
@@ -23,18 +23,20 @@ | |||
<%block name="header"> | |||
<header class="styled-border"> | |||
<div> | |||
<div class="left"> | |||
<%block name="lefthead"> | |||
<a id="corp-masthead" title="Home" href="${url_for('index')}"> | |||
<img alt="Logo" src="${g.eve.image.corp(g.config.get('corp.id'), 256)}"/> | |||
<h1>${g.config.get("corp.name") | h}</h1> | |||
</a> | |||
</%block> | |||
</div> | |||
<div class="right"> | |||
<%block name="righthead"> | |||
<img class="spacer" alt="" src="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs="/> | |||
</%block> | |||
<div> | |||
<div class="left"> | |||
<%block name="lefthead"> | |||
<a id="corp-masthead" title="Home" href="${url_for('index')}"> | |||
<img alt="Logo" src="${g.eve.image.corp(g.config.get('corp.id'), 256)}"/> | |||
<h1>${g.config.get("corp.name") | h}</h1> | |||
</a> | |||
</%block> | |||
</div> | |||
<div class="right"> | |||
<%block name="righthead"> | |||
<img class="spacer" alt="" src="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs="/> | |||
</%block> | |||
</div> | |||
</div> | |||
</div> | |||
</header> | |||
@@ -2,28 +2,37 @@ | |||
<%block name="lefthead"> | |||
${parent.lefthead()} | |||
<nav> | |||
Campaign: XYZ | Map | Intel | Members... | |||
<ul> | |||
% for module in g.modules: | |||
<% navitem = module.navitem() %> | |||
% if navitem: | |||
<li>${navitem}</li> | |||
% endif | |||
% endfor | |||
</ul> | |||
</nav> | |||
</%block> | |||
<%block name="righthead"> | |||
<img id="character-portrait" class="styled-border" alt="Portrait" src="${g.eve.image.character(g.auth.get_character_id(), 256)}"/> | |||
<div id="character-options" class="styled-border"> | |||
<div id="style-options"> | |||
<% cur_style = g.auth.get_character_prop("style") or g.config.get("style.default") %> | |||
% for style in g.config.get("style.enabled"): | |||
<% | |||
stitle = style.title() | |||
url = url_for('staticv', filename='images/style/{}.png'.format(style)) | |||
%> | |||
<form action="${url_for('set_style', style=style)}" method="post"${' class="cur"' if style == cur_style else ''}> | |||
<input type="submit" title="${stitle}" value="${stitle}" data-style="${style}"${' disabled' if style == cur_style else ''} | |||
style="background-image: url('${url}')"> | |||
</form> | |||
% endfor | |||
<div id="character-portrait"> | |||
<img class="styled-border" alt="Portrait" src="${g.eve.image.character(g.auth.get_character_id(), 256)}"/> | |||
<div id="character-options" class="styled-border"> | |||
<div id="style-options"> | |||
<% cur_style = g.auth.get_character_prop("style") or g.config.get("style.default") %> | |||
% for style in g.config.get("style.enabled"): | |||
<% | |||
stitle = style.title() | |||
url = url_for('staticv', filename='images/style/{}.png'.format(style)) | |||
%> | |||
<form action="${url_for('set_style', style=style)}" method="post"${' class="cur"' if style == cur_style else ''}> | |||
<input type="submit" title="${stitle | h}" value="${stitle | h}" data-style="${style | h}"${' disabled' if style == cur_style else ''} | |||
style="background-image: url('${url}')"> | |||
</form> | |||
% endfor | |||
</div> | |||
</div> | |||
</div> | |||
<span id="character-summary"> | |||
${g.auth.get_character_prop("name")} | |||
${g.auth.get_character_prop("name") | h} | |||
<span class="sep">[</span><a id="logout" title="Log out" href="${url_for('logout')}">log out</a><span class="sep">]</span> | |||
</span> | |||
</%block> | |||
@@ -0,0 +1,7 @@ | |||
<%inherit file="../_default.mako"/> | |||
%if current: | |||
<h2><span class="understate">Campaign:</span> ${current} <!-- select ... --></h2> | |||
<p>Hello! ...</p> | |||
%else: | |||
<p>No campaigns currently.</p> | |||
%endif |
@@ -0,0 +1 @@ | |||
Campaign: <a href="${url_for('campaigns.campaign')}">${current}</a> |
@@ -0,0 +1,2 @@ | |||
<%inherit file="_default.mako"/> | |||
<p>Hi, ${g.auth.get_character_prop("name") | h}!</p> |
@@ -1,2 +0,0 @@ | |||
<%inherit file="_default.mako"/> | |||
<p>Hi, ${g.auth.get_character_prop("name")}!</p> |