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