Browse Source

Add structure and supporting code for modules; add Campaigns module.

* 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
Ben Kurtovic 4 years ago
parent
commit
dfa695bb83
21 changed files with 385 additions and 73 deletions
  1. +4
    -1
      .gitignore
  2. +12
    -8
      app.py
  3. +47
    -3
      calefaction/auth.py
  4. +50
    -1
      calefaction/config.py
  5. +14
    -0
      calefaction/database.py
  6. +53
    -0
      calefaction/module.py
  7. +35
    -0
      calefaction/modules/campaigns.py
  8. +6
    -0
      calefaction/modules/intel.py
  9. +6
    -0
      calefaction/modules/map.py
  10. +6
    -0
      calefaction/modules/members.py
  11. +8
    -4
      config/config.yml.sample
  12. +15
    -0
      config/modules/campaigns.yml.sample
  13. +26
    -10
      data/schema.sql
  14. +52
    -14
      static/main.css
  15. +2
    -2
      static/main.js
  16. +14
    -12
      templates/_base.mako
  17. +25
    -16
      templates/_default.mako
  18. +7
    -0
      templates/campaigns/campaign.mako
  19. +1
    -0
      templates/campaigns/navitem.mako
  20. +2
    -0
      templates/default_home.mako
  21. +0
    -2
      templates/home.mako

+ 4
- 1
.gitignore View File

@@ -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

+ 12
- 8
app.py View File

@@ -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)


+ 47
- 3
calefaction/auth.py View File

@@ -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.



+ 50
- 1
calefaction/config.py View File

@@ -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

+ 14
- 0
calefaction/database.py View File

@@ -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

+ 53
- 0
calefaction/module.py View File

@@ -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()

+ 35
- 0
calefaction/modules/campaigns.py View File

@@ -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

+ 6
- 0
calefaction/modules/intel.py View File

@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-

# ...

def navitem():
return "Intel"

+ 6
- 0
calefaction/modules/map.py View File

@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-

# ...

def navitem():
return "Map"

+ 6
- 0
calefaction/modules/members.py View File

@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-

# ...

def navitem():
return "Members"

+ 8
- 4
config/config.yml.sample View File

@@ -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


+ 15
- 0
config/modules/campaigns.yml.sample View File

@@ -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

+ 26
- 10
data/schema.sql View File

@@ -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
);

+ 52
- 14
static/main.css View File

@@ -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;


+ 2
- 2
static/main.js View File

@@ -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");
}


+ 14
- 12
templates/_base.mako View File

@@ -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>


+ 25
- 16
templates/_default.mako View File

@@ -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>


+ 7
- 0
templates/campaigns/campaign.mako View File

@@ -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

+ 1
- 0
templates/campaigns/navitem.mako View File

@@ -0,0 +1 @@
Campaign: <a href="${url_for('campaigns.campaign')}">${current}</a>

+ 2
- 0
templates/default_home.mako View File

@@ -0,0 +1,2 @@
<%inherit file="_default.mako"/>
<p>Hi, ${g.auth.get_character_prop("name") | h}!</p>

+ 0
- 2
templates/home.mako View File

@@ -1,2 +0,0 @@
<%inherit file="_default.mako"/>
<p>Hi, ${g.auth.get_character_prop("name")}!</p>

Loading…
Cancel
Save