Mercurial > piecrust2
changeset 587:d4a01a023998
admin: Add "FoodTruck" admin panel from the side experiment project.
line wrap: on
line diff
--- a/.hgignore Sun Jan 10 10:51:11 2016 -0800 +++ b/.hgignore Sat Jan 16 14:24:35 2016 -0800 @@ -1,16 +1,18 @@ syntax: glob *.pyc *.egg-info +__pycache__ venv tags +bower_components +node_modules build/lib util/messages/_cache util/messages/_counter dist docs/_cache docs/_counter -docs/bower_components -docs/node_modules +foodtruck/static piecrust.egg-info piecrust/__version__.py .ropeproject
--- a/MANIFEST.in Sun Jan 10 10:51:11 2016 -0800 +++ b/MANIFEST.in Sat Jan 16 14:24:35 2016 -0800 @@ -3,6 +3,8 @@ include LICENSE.rst include requirements.txt include dev-requirements.txt +recursive-include foodtruck *.py *.html +recursive-include foodtruck/static * recursive-include piecrust *.py mime.types recursive-include piecrust/resources * recursive-include tests *.py
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/foodtruck.py Sat Jan 16 14:24:35 2016 -0800 @@ -0,0 +1,6 @@ +from foodtruck.main import main + + +if __name__ == '__main__': + main() +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/foodtruck/config.py Sat Jan 16 14:24:35 2016 -0800 @@ -0,0 +1,67 @@ +import os.path +import copy +import logging +import yaml +from piecrust.configuration import ( + Configuration, ConfigurationError, ConfigurationLoader, + merge_dicts) + + +logger = logging.getLogger(__name__) + + +def get_foodtruck_config(): + cfg_path = os.path.join(os.getcwd(), 'foodtruck.yml') + return FoodTruckConfiguration(cfg_path) + + +class FoodTruckConfigNotFoundError(Exception): + pass + + +class FoodTruckConfiguration(Configuration): + def __init__(self, cfg_path): + super(FoodTruckConfiguration, self).__init__() + self.cfg_path = cfg_path + + def _load(self): + try: + with open(self.cfg_path, 'r', encoding='utf-8') as fp: + values = yaml.load( + fp.read(), + Loader=ConfigurationLoader) + + self._values = self._validateAll(values) + except OSError: + raise FoodTruckConfigNotFoundError() + except Exception as ex: + raise ConfigurationError( + "Error loading configuration from: %s" % + self.cfg_path) from ex + + def _validateAll(self, values): + if values is None: + values = {} + + values = merge_dicts(copy.deepcopy(default_configuration), values) + + return values + + def save(self): + with open(self.cfg_path, 'w', encoding='utf8') as fp: + self.cfg.write(fp) + + +default_configuration = { + 'triggers': { + 'bake': 'chef bake' + }, + 'scm': { + 'type': 'hg' + }, + 'security': { + 'username': '', + 'password': '' + } + } +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/foodtruck/main.py Sat Jan 16 14:24:35 2016 -0800 @@ -0,0 +1,54 @@ +import sys +import logging +import argparse +from .web import app + + +logger = logging.getLogger(__name__) + + +def main(): + parser = argparse.ArgumentParser( + description="FoodTruck command line utility") + parser.add_argument( + '--debug', + help="Show debug information", + action='store_true') + parser.add_argument( + '--version', + help="Print version and exit", + action='store_true') + + args = parser.parse_args() + if args.version: + try: + from .__version__ import version + except ImportError: + print("Can't find version information.") + args.exit(1) + print("FoodTruck %s" % version) + args.exit(0) + + root_logger = logging.getLogger() + root_logger.setLevel(logging.INFO) + if args.debug: + root_logger.setLevel(logging.DEBUG) + + log_handler = logging.StreamHandler(sys.stdout) + if args.debug: + log_handler.setLevel(logging.DEBUG) + else: + log_handler.setLevel(logging.INFO) + root_logger.addHandler(log_handler) + + try: + app.run(debug=args.debug, threaded=True) + except SystemExit: + # This is needed for Werkzeug's code reloader to be able to correctly + # shutdown the child process in order to restart it (otherwise, SSE + # generators will keep it alive). + from .views import baking + logger.debug("Shutting down SSE generators from main...") + baking.server_shutdown = True + raise +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/foodtruck/scm/base.py Sat Jan 16 14:24:35 2016 -0800 @@ -0,0 +1,18 @@ + + +class RepoStatus(object): + def __init__(self): + self.new_files = [] + self.edited_files = [] + + +class SourceControl(object): + def __init__(self, root_dir): + self.root_dir = root_dir + + def getStatus(self): + raise NotImplementedError() + + def commit(self, paths, author, message): + raise NotImplementedError() +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/foodtruck/scm/mercurial.py Sat Jan 16 14:24:35 2016 -0800 @@ -0,0 +1,71 @@ +import os +import logging +import tempfile +import subprocess +from .base import SourceControl, RepoStatus + + +logger = logging.getLogger(__name__) + + +def _s(strs): + """ Convert a byte array to string using UTF8 encoding. """ + if strs is None: + return None + assert isinstance(strs, bytes) + return strs.decode('utf8') + + +class MercurialSourceControl(SourceControl): + def __init__(self, root_dir): + super(MercurialSourceControl, self).__init__(root_dir) + self.hg = 'hg' + + def getStatus(self): + res = RepoStatus() + st_out = self._run('status') + for line in st_out.split('\n'): + if len(line) == 0: + continue + if line[0] == '?' or line[0] == 'A': + res.new_files.append(line[2:]) + elif line[0] == 'M': + res.edited_files.append(line[2:]) + return res + + def commit(self, paths, author, message): + if not message: + raise ValueError("No commit message specified.") + + # Check if any of those paths needs to be added. + st_out = self._run('status', *paths) + add_paths = [] + for line in st_out.splitlines(): + if line[0] == '?': + add_paths.append(line[2:]) + if len(add_paths) > 0: + self._run('add', *paths) + + # Create a temp file with the commit message. + f, temp = tempfile.mkstemp() + with os.fdopen(f, 'w') as fd: + fd.write(message) + + # Commit and clean up the temp file. + try: + commit_args = list(paths) + ['-l', temp] + if author: + commit_args += ['-u', author] + self._run('commit', *commit_args) + finally: + os.remove(temp) + + def _run(self, cmd, *args, **kwargs): + exe = [self.hg, '-R', self.root_dir] + exe.append(cmd) + exe += args + logger.debug("Running Mercurial: " + str(exe)) + out = subprocess.check_output(exe) + encoded_out = _s(out) + return encoded_out +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/foodtruck/sites.py Sat Jan 16 14:24:35 2016 -0800 @@ -0,0 +1,136 @@ +import os +import os.path +import shlex +import logging +import threading +import subprocess +from piecrust.app import PieCrust + + +logger = logging.getLogger(__name__) + + +class UnauthorizedSiteAccessError(Exception): + pass + + +class Site(object): + def __init__(self, name, root_dir, config): + self.name = name + self.root_dir = root_dir + self.config = config + self._piecrust_app = None + self._scm = None + self._bake_thread = None + logger.debug("Creating site object for %s" % self.name) + + @property + def piecrust_app(self): + if self._piecrust_app is None: + s = PieCrust(self.root_dir) + s.config.set('site/root', '/site/%s/' % self.name) + self._piecrust_app = s + return self._piecrust_app + + @property + def scm(self): + if self._scm is None: + scm_type = self.config.get('scm/type') + if scm_type == 'hg': + from .scm.mercurial import MercurialSourceControl + self._scm = MercurialSourceControl(self.root_dir) + else: + raise NotImplementedError() + return self._scm + + @property + def is_bake_running(self): + return self._bake_thread is not None and self._bake_thread.is_alive() + + @property + def bake_thread(self): + return self._bake_thread + + def bake(self): + bake_cmd = self.config.get('triggers/bake') + bake_args = shlex.split(bake_cmd) + + logger.debug("Running bake: %s" % bake_args) + proc = subprocess.Popen(bake_args, cwd=self.root_dir, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + + pid_file_path = os.path.join(self.root_dir, 'foodtruck_bake.pid') + with open(pid_file_path, 'w') as fp: + fp.write(str(proc.pid)) + + logger.debug("Running bake monitor for PID %d" % proc.pid) + self._bake_thread = _BakeThread(self.name, self.root_dir, proc, + self._onBakeEnd) + self._bake_thread.start() + + def _onBakeEnd(self): + os.unlink(os.path.join(self.root_dir, 'foodtruck_bake.pid')) + self._bake_thread = None + + +class _BakeThread(threading.Thread): + def __init__(self, sitename, siteroot, proc, callback): + super(_BakeThread, self).__init__( + name='%s_bake' % sitename, daemon=True) + self.sitename = sitename + self.siteroot = siteroot + self.proc = proc + self.callback = callback + + log_file_path = os.path.join(self.siteroot, 'foodtruck_bake.log') + self.log_fp = open(log_file_path, 'w', encoding='utf8') + + def run(self): + for line in self.proc.stdout: + self.log_fp.write(line.decode('utf8')) + for line in self.proc.stderr: + self.log_fp.write(line.decode('utf8')) + self.proc.communicate() + if self.proc.returncode != 0: + self.log_fp.write("Error, bake process returned code %d" % + self.proc.returncode) + self.log_fp.close() + + logger.debug("Bake ended for %s." % self.sitename) + self.callback() + + +class FoodTruckSites(): + def __init__(self, config, current_site=None): + self._sites = {} + self._site_dirs = {} + self.config = config + self.current_site = current_site + + def get_root_dir(self, name=None): + name = name or self.current_site + s = self._site_dirs.get(name) + if s: + return s + + scfg = self.config.get('sites/%s' % name) + root_dir = scfg.get('path') + if root_dir is None: + raise Exception("Site '%s' has no path defined." % name) + if not os.path.isdir(root_dir): + raise Exception("Site '%s' has an invalid path." % name) + self._site_dirs[name] = root_dir + return root_dir + + def get(self, name=None): + name = name or self.current_site + s = self._sites.get(name) + if s: + return s + + root_dir = self.get_root_dir(name) + s = Site(name, root_dir, self.config) + self._sites[name] = s + return s +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/foodtruck/templates/create_page.html Sat Jan 16 14:24:35 2016 -0800 @@ -0,0 +1,35 @@ +{% set title = 'Create' %} + +{% extends 'layouts/default.html' %} + +{% block content %} +<form action="{{url_postback}}" method="POST" class="ft-create-form"> +<div class="container"> + {% for field in fields %} + <div class="row"> + <div class="col-md-10 col-md-offset-1"> + <div class="form-group"> + <div class="input-group input-group-lg"> + <span class="input-group-addon" id="meta-{{field.name}}">{{field.display_name}}</span> + <input type="text" class="form-control" placeholder="{{field.value}}" aria-describedby="meta-{{field.name}}" name="meta-{{field.name}}" /> + </div> + </div> + </div> + </div> + + {% endfor %} + + <input type="hidden" name="source_name" value="{{source_name}}" /> + + <div class="row"> + <div class="col-md-6 col-md-offset-1"> + <a class="btn btn-danger" href="{{url_cancel}}">Cancel</a> + </div> + <div class="col-md-4"> + <button type="submit" name="do_save" class="btn btn-primary btn-lg pull-right">Create and Edit</button> + </div> + </div> +</div> +</form> +{% endblock %} +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/foodtruck/templates/dashboard.html Sat Jan 16 14:24:35 2016 -0800 @@ -0,0 +1,60 @@ +{% extends 'layouts/default.html' %} + +{% block content %} +<div class="container"> + <div class="row"> + <div class="col-md-6"> + <h2><span class="icon ion-stats-bars"></span> Site Summary</h2> + {% for s in sources %} + <div class="ft-summary-source"> + <a href="{{s.list_url}}">{{s.page_count}} {{s.name}}</a> + </div> + {% endfor %} + + <h2><span class="icon ion-pinpoint"></span> Preview Site</h2> + <a href="{{url_preview}}">Preview {{site_name}}</a> + </div> + <div class="col-md-6"> + <h2><span class="icon ion-erlenmeyer-flask"></span> Work in Progress</h2> + {% if new_pages %} + <p>New pages</p> + <ul> + {% for p in new_pages %} + <li><a href="{{p.url}}">{{p.title}}</a><br/> + {%if p.text%}<pre>{{p.text}}</pre>{%endif%}</li> + {% endfor %} + </ul> + {% endif %} + {% if edited_pages %} + <p>Edited pages</p> + <ul> + {% for p in edited_pages %} + <li><a href="{{p.url}}">{{p.title}}</a><br/> + {%if p.text%}<pre>{{p.text}}</pre>{%endif%}</li> + {% endfor %} + </ul> + {% endif %} + {% if not new_pages and not edited_pages %} + <p>No work in progress.</p> + {% endif %} + + <h2><span class="icon"></span> Switch Site</h2> + <form action="{{url_switch}}" method="POST"> + {% for site in sites %} + <p><button type="submit" name="site_name" value="{{site.name}}" class="btn btn-link">{{site.display_name}}</a></p> + {% endfor %} + </form> + </div> + </div> + <div class="row"> + <div class="col-md-12"> + <h2><span class="icon ion-radio-waves"></span> Publishing</h2> + <form action="{{url_bake}}" method="POST"> + <button name="do_bake" type="submit" class="btn btn-primary btn-lg"> + <span class="icon ion-upload"></span> Bake!</button> + </form> + </div> + </div> +</div> +{% endblock %} +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/foodtruck/templates/edit_page.html Sat Jan 16 14:24:35 2016 -0800 @@ -0,0 +1,39 @@ +{% set title = 'Write' %} + +{% extends 'layouts/default.html' %} + +{% block content %} +<form action="{{url_postback}}" method="POST" class="ft-write-form" id="ft-write-form"> +<div class="container"> + <div class="row"> + <div class="col-md-10 col-md-offset-1"> + <div class="form-group"> + <textarea name="page_text" class="form-control" placeholder="Post contents..." rows="20">{{page_text}}</textarea> + </div> + </div> + </div> + + <input type="hidden" name="is_dos_nl" value="{{is_dos_nl}}" /> + + <div class="row"> + <div class="col-md-8 col-md-offset-1"> + <button type="submit" formtarget="_blank" name="do_preview" class="btn btn-info">Preview</button> + <a class="btn btn-danger" href="{{url_cancel}}">Cancel</a> + </div> + <div class="col-md-2"> + <div class="btn-group"> + <button type="button" name="do_save" class="btn btn-primary">Save</button> + <button type="button" class="btn btn-primary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"> + <span class="caret"></span> + <span class="sr-only">Toggle Dropdown</span> + </button> + <ul class="dropdown-menu"> + <li><button type="submit" name="do_save_and_commit" class="btn btn-link">Save and Commit</button></li> + </ul> + </div> + </div> + </div> +</div> +</form> +{% endblock %} +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/foodtruck/templates/install.html Sat Jan 16 14:24:35 2016 -0800 @@ -0,0 +1,8 @@ +{% extends 'layouts/default.html' %} + +{% block content %} +<div class="container"> +Please install this shit. +</div> +{% endblock %} +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/foodtruck/templates/layouts/default.html Sat Jan 16 14:24:35 2016 -0800 @@ -0,0 +1,14 @@ +{% set wrapper_classes = 'ft-nav-container' %} + +{% extends 'layouts/master.html' %} + +{% block after_content %} +<nav class="ft-nav"> + <div class="ft-nav-title"> + <img src="/static/img/foodtruck.png" alt="Food Truck" /> + <div class="ft-nav-brand">FoodTruck</div> + </div> + {% include 'layouts/menu.html' %} +</nav> +{% endblock %} +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/foodtruck/templates/layouts/master.html Sat Jan 16 14:24:35 2016 -0800 @@ -0,0 +1,38 @@ +<!doctype html> +<html lang=""> + <head> + <meta charset="utf-8"/> + <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"/> + <title>{%if title%}{{title}} – {%endif%}FoodTruck</title> + <meta name="description" content="A PieCrust management dashboard"/> + <meta name="viewport" content="width=device-width, initial-scale=1"/> + <link rel="apple-touch-icon" href="apple-touch-icon.png"/> + <link rel="stylesheet" href="/static/css/foodtruck.min.css"/> + <link href='https://fonts.googleapis.com/css?family=Lobster' rel='stylesheet' type='text/css'/> + </head> + <body> + <div id="ft-wrapper" class="{{wrapper_classes}}"> + <header> + {% if title %}<h1 class="title">{{title}}</h1>{% endif %} + {% block sub_header %}{% endblock %} + </header> + {% block before_content %}{% endblock %} + <section> + {% block content %}{% endblock %} + </section> + {% block after_content %}{% endblock %} + <div id="ft-bakelog"></div> + <footer> + <p>Prepared by <a href="http://bolt80.com">BOLT80</a>.</p> + <p>Much <span class="icon ion-heart"></span> to + <a href="http://python.org">Python</a>, + <a href="http://flask.pocoo.org">Flask</a>, + <a href="http://getbootstrap.com">Bootstrap</a>, + <a href="http://ionicons.com/">Ionicons</a>, + and many more.</p> + </footer> + </div> + <script src="/static/js/foodtruck.min.js"></script> + </body> +</html> +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/foodtruck/templates/layouts/menu.html Sat Jan 16 14:24:35 2016 -0800 @@ -0,0 +1,17 @@ +<ul> + {%-for e in menu.entries%} + <li><a href="{{e.url}}" class="{%if e.active%}ft-nav-active{%elif e.entries and not e.open%}ft-nav-collapsed{%endif%}"> + {%-if e.icon%}<span class="icon ion-{{e.icon}}"></span> {%endif%}{{e.title}}</a> + {%-if e.entries%} + <ul> + {%-for e2 in e.entries%} + <li><a href="{{e2.url}}" class="{%if e2.active%}ft-nav-active{%endif%}">{{e2.title}}</a></li> + {%endfor%} + </ul> + {%endif-%} + </li> + {%endfor%} +</ul> +{%if menu.user.is_authenticated%} +<p class="ft-nav-auth">Logged in as {{menu.user.id}}. <a href="{{menu.url_logout}}">Logout</a>.</p> +{%endif%}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/foodtruck/templates/list_source.html Sat Jan 16 14:24:35 2016 -0800 @@ -0,0 +1,38 @@ +{% extends 'layouts/default.html' %} + +{% block content %} +<div class="container"> + <div class="row"> + <div class="col-xs-12"> + {% for p in pages %} + <div><a href="{{p.url}}">{{p.title}}</a></div> + {% endfor %} + </div> + + {% if pagination.prev_page or pagination.next_page %} + <div class="col-sm-6 col-sm-offset-3"> + <div class="ft-pagination"> + {% if pagination.prev_page %} + <a href="{{pagination.prev_page}}"><span class="icon ion-chevron-left"></span> prev</a> + {% else %} + <span class="icon ion-chevron-left"></span> prev + {% endif %} + + {% for p in pagination.nums %} + {% if p.url %}<a href="{{p.url}}" class="ft-pagination-a">{{p.num}}</a>{% else %}<span class="ft-pagination-a">{{p.num}}</span>{% endif %} + {% endfor %} + + {% if pagination.next_page %} + <a href="{{pagination.next_page}}">next <span class="icon ion-chevron-right"></span></a> + {% else %} + next <span class="icon ion-chevron-right"></span> + {% endif %} + </div> + </div> + {% endif %} + </div> +</div> + +{% endblock %} + +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/foodtruck/templates/login.html Sat Jan 16 14:24:35 2016 -0800 @@ -0,0 +1,35 @@ +{% set wrapper_classes = 'container' %} + +{% extends 'layouts/master.html' %} + +{% block content %} +<div class="row"> + <div class="col-md-6 col-md-offset-3"> + <div class="ft-login"> + <p class="login-box-msg">{{message|default('You know the drill.')}}</p> + + <form action="{{login_postback}}" method="post"> + <div class="form-group has-feedback"> + <input type="text" name="username" class="form-control" placeholder="Username"> + <span class="form-control-feedback"><i class="icon ion-log-in"></i></span> + </div> + <div class="form-group has-feedback"> + <input type="password" name="password" class="form-control" placeholder="Password"> + <span class="form-control-feedback"><i class="icon ion-locked"></i></span> + </div> + <div class="row"> + <div class="col-xs-8"> + <div class="checkbox"> + <label><input type="checkbox"> Remember Me</input></label> + </div> + </div> + <div class="col-xs-4"> + <button type="submit" class="btn btn-primary btn-block">Log In</button> + </div> + </div> + </form> + </div> + </div> +</div> +{% endblock %} +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/foodtruck/templates/settings.html Sat Jan 16 14:24:35 2016 -0800 @@ -0,0 +1,38 @@ +{% set title = 'Settings' %} + +{% extends 'layouts/default.html' %} + +{% block content %} +<div class="container"> +{% for sec in sections %} +<form action="{{url_settings}}" method="POST"> + <h2>{{sec.title}}</h2> + <input type="hidden" name="_section" value="{{sec.name}}" /> + + {% for op in sec.options %} + <div class="form-group"> + {% if op.type == 'checkbox' %} + <div class="checkbox"> + <label><input type="checkbox" name="{{op.name}}" value="true" {%if op.value == 'true'%}checked="true"{%endif%} />{{op.title}}</label> + </div> + {% else %} + <div class="input-group"> + <span class="input-group-addon" id="ft-setting-{{op.name}}">{{op.title}}</span> + <input type="{{op.type}}" class="form-control" aria-describedby="ft-setting-{{op.name}}" name="{{op.name}}" value="{{op.value}}" /> + </div> + {% endif %} + </div> + {% endfor %} + + <button type="submit" name="_do_save" class="btn btn-success">Save Changes</button> +</form> +{% endfor %} +<form action="{{url_reload_settings}}" method="POST"> + <h2>Reload Settings</h2> + <p>This will reload settings from the configuration file.</p> + <button type="submit" name="_do_reload" class="btn btn-info">Reload Settings</button> +</form> +</div> +{% endblock %} + +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/foodtruck/views/__init__.py Sat Jan 16 14:24:35 2016 -0800 @@ -0,0 +1,28 @@ +from flask import render_template +from flask.views import View +from .menu import get_menu_context + + +class FoodTruckView(View): + template_name = 'index.html' + requires_menu = True + + def render_template(self, context): + if self.requires_menu: + context = with_menu_context() + return render_template(self.template_name, **context) + + def get_context(self): + return None + + def dispatch_request(self): + ctx = self.get_context() + return render_template(ctx) + + +def with_menu_context(context=None): + if context is None: + context = {} + context['menu'] = get_menu_context() + return context +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/foodtruck/views/baking.py Sat Jan 16 14:24:35 2016 -0800 @@ -0,0 +1,116 @@ +import os +import os.path +import time +import signal +import logging +from werkzeug.wrappers import Response +from flask import g, redirect +from flask.ext.login import login_required +from ..web import app + + +logger = logging.getLogger(__name__) + +server_shutdown = False + + +def _shutdown_server_and_raise_sigint(): + if not app.debug or os.environ.get('WERKZEUG_RUN_MAIN') == 'true': + # This is needed when hitting CTRL+C to shutdown the Werkzeug server, + # otherwise SSE generators will keep it alive. + logger.debug("Shutting down SSE generators...") + global server_shutdown + server_shutdown = True + raise KeyboardInterrupt() + + +# Make sure CTRL+C works correctly. +signal.signal(signal.SIGINT, + lambda *args: _shutdown_server_and_raise_sigint()) + + +class _BakeLogReader(object): + _bake_max_time = 10 * 60 # Don't bother about bakes older than 10mins. + _poll_interval = 2 # Check the PID file every 2 seconds. + _ping_interval = 30 # Send a ping message every 30 seconds. + + def __init__(self, pid_path, log_path): + self.pid_path = pid_path + self.log_path = log_path + self._bake_pid_mtime = 0 + self._last_seek = 0 + self._last_ping_time = 0 + + def run(self): + logger.debug("Opening bake log...") + + try: + while not server_shutdown: + # PING! + interval = time.time() - self._last_ping_time + if interval > self._ping_interval: + logger.debug("Sending ping...") + self._last_ping_time = time.time() + yield bytes("event: ping\ndata: 1\n\n", 'utf8') + + # Check pid file. + prev_mtime = self._bake_pid_mtime + try: + self._bake_pid_mtime = os.path.getmtime(self.pid_path) + if time.time() - self._bake_pid_mtime > \ + self._bake_max_time: + self._bake_pid_mtime = 0 + except OSError: + self._bake_pid_mtime = 0 + + # Send data. + new_data = None + if self._bake_pid_mtime > 0 or prev_mtime > 0: + if self._last_seek == 0: + outstr = 'event: message\ndata: Bake started.\n\n' + yield bytes(outstr, 'utf8') + + try: + with open(self.log_path, 'r', encoding='utf8') as fp: + fp.seek(self._last_seek) + new_data = fp.read() + self._last_seek = fp.tell() + except OSError: + pass + if self._bake_pid_mtime == 0: + self._last_seek = 0 + + if new_data: + logger.debug("SSE: %s" % outstr) + for line in new_data.split('\n'): + outstr = 'event: message\ndata: %s\n\n' % line + yield bytes(outstr, 'utf8') + + time.sleep(self._poll_interval) + + except GeneratorExit: + pass + + logger.debug("Closing bake log...") + + +@app.route('/bake', methods=['POST']) +@login_required +def bake_site(): + site = g.sites.get() + site.bake() + return redirect('/') + + +@app.route('/bakelog') +@login_required +def stream_bake_log(): + site = g.sites.get() + pid_path = os.path.join(site.root_dir, 'foodtruck_bake.pid') + log_path = os.path.join(site.root_dir, 'foodtruck_bake.log') + rdr = _BakeLogReader(pid_path, log_path) + + response = Response(rdr.run(), mimetype='text/event-stream') + response.headers['Cache-Control'] = 'no-cache' + return response +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/foodtruck/views/create.py Sat Jan 16 14:24:35 2016 -0800 @@ -0,0 +1,73 @@ +import os.path +import logging +from flask import ( + g, request, abort, render_template, url_for, redirect, flash) +from flask.ext.login import login_required +from piecrust.sources.interfaces import IInteractiveSource +from piecrust.sources.base import MODE_CREATING +from piecrust.routing import create_route_metadata +from ..views import with_menu_context +from ..web import app + + +logger = logging.getLogger(__name__) + + +@app.route('/write/<source_name>', methods=['GET', 'POST']) +@login_required +def write_page(source_name): + site = g.sites.get().piecrust_app + source = site.getSource(source_name) + if source is None: + abort(400) + if not isinstance(source, IInteractiveSource): + abort(400) + + if request.method == 'POST': + if 'do_save' in request.form: + metadata = dict(request.form.items()) + form_keys = list(metadata.keys()) + for k in filter(lambda k: not k.startswith('meta-'), form_keys): + del metadata[k] + + fac = source.findPageFactory(metadata, MODE_CREATING) + if fac is None: + raise Exception("Can't find page for %s" % metadata) + abort(400) + + logger.debug("Creating page: %s" % fac.path) + with open(fac.path, 'w', encoding='utf8') as fp: + fp.write(request.form['page_text']) + flash("%s was created." % os.path.relpath(fac.path, site.root_dir)) + + route = site.getRoute(source.name, fac.metadata, + skip_taxonomies=True) + dummy = object() + dummy.source_metadata = fac.metadata + dummy.getRouteMetadata = lambda: {} + route_metadata = create_route_metadata(dummy) + uri = route.getUri(route_metadata) + + return redirect(url_for('edit_page', slug=uri)) + + abort(400) + + return _write_page_form(source) + + +def _write_page_form(source): + data = {} + data['is_new_page'] = True + data['source_name'] = source.name + data['url_postback'] = url_for('write_page', source_name=source.name) + data['fields'] = [] + for f in source.getInteractiveFields(): + data['fields'].append({ + 'name': f.name, + 'display_name': f.name, + 'type': f.field_type, + 'value': f.default_value}) + + with_menu_context(data) + return render_template('create_page.html', **data) +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/foodtruck/views/edit.py Sat Jan 16 14:24:35 2016 -0800 @@ -0,0 +1,74 @@ +import os.path +import logging +from flask import ( + g, request, abort, render_template, url_for, flash) +from flask.ext.login import login_required +from piecrust.rendering import ( + PageRenderingContext, render_page) +from piecrust.serving.util import get_requested_page +from ..views import with_menu_context +from ..web import app + + +logger = logging.getLogger(__name__) + + +@app.route('/edit/', defaults={'slug': ''}, methods=['GET', 'POST']) +@app.route('/edit/<path:slug>', methods=['GET', 'POST']) +@login_required +def edit_page(slug): + site = g.sites.get() + site_app = site.piecrust_app + rp = get_requested_page(site_app, + '/site/%s/%s' % (g.sites.current_site, slug)) + page = rp.qualified_page + if page is None: + abort(404) + + if request.method == 'POST': + page_text = request.form['page_text'] + if request.form['is_dos_nl'] == '0': + page_text = page_text.replace('\r\n', '\n') + + if 'do_preview' in request.form or 'do_save' in request.form or \ + 'do_save_and_commit' in request.form: + logger.debug("Writing page: %s" % page.path) + with open(page.path, 'w', encoding='utf8') as fp: + fp.write(page_text) + flash("%s was saved." % os.path.relpath( + page.path, site_app.root_dir)) + + if 'do_save_and_commit' in request.form: + author = g.config.get('scm/username') + message = "Edit %s" % os.path.relpath( + page.path, site_app.root_dir) + site.scm.commit([page.path], author, message) + + if 'do_preview' in request.form: + return _preview_page(page) + + if 'do_save' in request.form or 'do_save_and_commit' in request.form: + return _edit_page_form(page) + + abort(400) + + return _edit_page_form(page) + + +def _preview_page(page): + render_ctx = PageRenderingContext(page, force_render=True) + rp = render_page(render_ctx) + return rp.content + + +def _edit_page_form(page): + data = {} + data['is_new_page'] = False + data['url_cancel'] = url_for('list_source', source_name=page.source.name) + with open(page.path, 'r', encoding='utf8') as fp: + data['page_text'] = fp.read() + data['is_dos_nl'] = "1" if '\r\n' in data['page_text'] else "0" + + with_menu_context(data) + return render_template('edit_page.html', **data) +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/foodtruck/views/main.py Sat Jan 16 14:24:35 2016 -0800 @@ -0,0 +1,155 @@ +import os +import os.path +import logging +from flask import ( + g, request, + render_template, url_for, redirect, make_response) +from flask.ext.login import login_user, logout_user, login_required +from piecrust.configuration import parse_config_header +from piecrust.rendering import QualifiedPage +from piecrust.uriutil import split_uri +from ..views import with_menu_context +from ..web import app, load_user, after_this_request + + +logger = logging.getLogger(__name__) + + +@app.route('/') +@login_required +def index(): + data = {} + site_name = request.cookies.get('foodtruck_site_name') + site = g.sites.get(site_name) + assert site is not None + + fs_endpoints = {} + data['sources'] = [] + for source in site.piecrust_app.sources: + if source.is_theme_source: + continue + facs = source.getPageFactories() + src_data = { + 'name': source.name, + 'list_url': url_for('list_source', source_name=source.name), + 'page_count': len(facs)} + data['sources'].append(src_data) + + fe = getattr(source, 'fs_endpoint', None) + if fe: + fs_endpoints[fe] = source + + st = site.scm.getStatus() + data['new_pages'] = [] + for p in st.new_files: + pd = _getWipData(p, site, fs_endpoints) + if pd: + data['new_pages'].append(pd) + data['edited_pages'] = [] + for p in st.edited_files: + pd = _getWipData(p, site, fs_endpoints) + if pd: + data['edited_pages'].append(pd) + + data['site_name'] = site.name + data['url_bake'] = url_for('bake_site') + data['url_preview'] = url_for('preview_site_root', sitename=site.name) + + data['sites'] = [] + for k, v in g.config.get('sites').items(): + data['sites'].append({ + 'name': k, + 'display_name': v.get('name', k), + 'url': url_for('index', site_name=site_name) + }) + data['url_switch'] = url_for('switch_site') + + with_menu_context(data) + return render_template('dashboard.html', **data) + + +def _getWipData(path, site, fs_endpoints): + source = None + for endpoint, s in fs_endpoints.items(): + if path.startswith(endpoint): + source = s + break + if source is None: + return None + + fac = source.buildPageFactory(os.path.join(site.root_dir, path)) + route = site.piecrust_app.getRoute( + source.name, fac.metadata, skip_taxonomies=True) + if not route: + return None + + qp = QualifiedPage(fac.buildPage(), route, fac.metadata) + uri = qp.getUri() + _, slug = split_uri(site.piecrust_app, uri) + + with open(fac.path, 'r', encoding='utf8') as fp: + raw_text = fp.read() + + preferred_length = 100 + max_length = 150 + header, offset = parse_config_header(raw_text) + extract = raw_text[offset:offset + preferred_length] + if len(raw_text) > offset + preferred_length: + for i in range(offset + preferred_length, + min(offset + max_length, len(raw_text))): + c = raw_text[i] + if c not in [' ', '\t', '\r', '\n']: + extract += c + else: + extract += '...' + break + + return { + 'title': qp.config.get('title'), + 'slug': slug, + 'url': url_for('edit_page', slug=slug), + 'text': extract + } + + +@login_required +@app.route('/switch_site', methods=['POST']) +def switch_site(): + site_name = request.form.get('site_name') + if not site_name: + return redirect(url_for('index')) + + @after_this_request + def _save_site(resp): + resp.set_cookie('foodtruck_site_name', site_name) + + return redirect(url_for('index')) + + +@app.route('/login', methods=['GET', 'POST']) +def login(): + data = {} + + if request.method == 'POST': + username = request.form.get('username') + password = request.form.get('password') + remember = request.form.get('remember') + + user = load_user(username) + if user is not None and app.bcrypt: + if app.bcrypt.check_password_hash(user.password, password): + login_user(user, remember=bool(remember)) + return redirect(url_for('index')) + data['message'] = ( + "User '%s' doesn't exist or password is incorrect." % + username) + + return render_template('login.html', **data) + + +@app.route('/logout') +@login_required +def logout(): + logout_user() + return redirect(url_for('index')) +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/foodtruck/views/menu.py Sat Jan 16 14:24:35 2016 -0800 @@ -0,0 +1,63 @@ +from flask import g, request, url_for +from flask.ext.login import current_user + + +def get_menu_context(): + entries = [] + entries.append({ + 'url': '/', + 'title': "Dashboard", + 'icon': 'speedometer'}) + + site = g.sites.get().piecrust_app + for s in site.sources: + if s.is_theme_source: + continue + + source_icon = s.config.get('foodtruck_icon/document') + if s.name == 'pages': + source_icon = 'document-text' + elif 'blog' in s.name: + source_icon = 'filing' + + url_write = url_for('write_page', source_name=s.name) + url_listall = url_for('list_source', source_name=s.name) + + ctx = { + 'url': url_listall, + 'title': s.name, + 'icon': source_icon, + 'entries': [ + {'url': url_listall, 'title': "List All"}, + {'url': url_write, 'title': "Write New"} + ] + } + entries.append(ctx) + + entries.append({ + 'url': url_for('settings'), + 'title': "Settings", + 'icon': 'gear-b'}) + + for e in entries: + needs_more_break = False + if 'entries' in e: + for e2 in e['entries']: + if e2['url'] == request.path: + e['open'] = True + e2['active'] = True + needs_more_break = True + break + if needs_more_break: + break + + if e['url'] == request.path: + e['active'] = True + break + + data = {'entries': entries, + 'user': current_user, + 'url_logout': url_for('logout')} + return data + +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/foodtruck/views/preview.py Sat Jan 16 14:24:35 2016 -0800 @@ -0,0 +1,25 @@ +import os.path +from flask import g, make_response +from flask.ext.login import login_required +from piecrust import CACHE_DIR +from piecrust.serving.server import Server +from ..web import app + + +@app.route('/site/<sitename>/') +@login_required +def preview_site_root(sitename): + return preview_site(sitename, '/') + + +@app.route('/site/<sitename>/<path:url>') +@login_required +def preview_site(sitename, url): + root_dir = g.sites.get_root_dir(sitename) + sub_cache_dir = os.path.join(root_dir, CACHE_DIR, 'foodtruck') + server = Server(root_dir, sub_cache_dir=sub_cache_dir, + root_url='/site/%s/' % sitename, + debug=app.debug) + return make_response(server._run_request) + +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/foodtruck/views/settings.py Sat Jan 16 14:24:35 2016 -0800 @@ -0,0 +1,98 @@ +import logging +import bcrypt +from flask import g, request, abort, render_template, redirect, url_for +from flask.ext.login import login_required +from ..views import with_menu_context +from ..web import app + + +logger = logging.getLogger(__name__) + + +def _hash_password(v): + binpw = v.encode('utf8') + v = bcrypt.hashpw(binpw, bcrypt.gensalt()).decode('utf8') + return v + + +config_meta = { + 'scm.title': 'Source Control', + 'triggers.bake.is_shell.title': 'Use Shell' + } + +config_defaults = { + 'triggers': { + 'bake.is_shell': 'false' + } + } + +config_coercer = { + 'security.password': _hash_password + } + + +@app.route('/settings', methods=['GET', 'POST']) +@login_required +def settings(): + if request.method == 'POST': + if '_do_save' not in request.form: + abort(400) + if not g.config.has(request.form['_section']): + abort(400) + + secname = request.form['_section'] + + defaults = config_defaults.get(secname) + if defaults: + for n, v in defaults.items(): + g.config.set('%s/%s' % (secname, n), v) + + for n, v in request.form.items(): + if n in ['_section', '_do_save']: + continue + + coercer = config_coercer.get('%s.%s' % (secname, n)) + if coercer: + v = coercer(v) + + logger.debug("Setting %s.%s to %s" % (secname, n, v)) + g.config.set('%s/%s' % (secname, n), v) + + g.config.save() + + data = {'sections': []} + for secname, sec in g.config.items(): + if secname in ['DEFAULT', 'foodtruck', 'sites']: + continue + secdata = {} + secdata['name'] = secname + secdata['title'] = config_meta.get( + '%s.title' % secname, secname.title()) + secdata['options'] = [] + for n, v in sec.items(): + opdata = {} + opdata['name'] = n + opdata['title'] = config_meta.get( + '%s.%s.title' % (secname, n), n.title()) + opdata['value'] = v + opdata['type'] = 'text' + if v in ['true', 'false']: + opdata['type'] = 'checkbox' + if secname == 'security' and n == 'password': + opdata['type'] = 'password' + secdata['options'].append(opdata) + data['sections'].append(secdata) + + data['url_settings'] = url_for('settings') + data['url_reload_settings'] = url_for('reload_settings') + with_menu_context(data) + return render_template('settings.html', **data) + + +@app.route('/reload', methods=['POST']) +@login_required +def reload_settings(): + from ..web import reload_foodtruck + reload_foodtruck() + return redirect(url_for('settings')) +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/foodtruck/views/sources.py Sat Jan 16 14:24:35 2016 -0800 @@ -0,0 +1,55 @@ +from flask import g, abort, render_template, url_for +from flask.ext.login import login_required +from piecrust.data.paginator import Paginator +from ..views import with_menu_context +from ..web import app + + +@app.route('/list/<source_name>/', defaults={'page_num': 1}) +@app.route('/list/<source_name>/<int:page_num>') +@login_required +def list_source(source_name, page_num): + site = g.sites.get().piecrust_app + source = site.getSource(source_name) + if source is None: + abort(400) + + i = 0 + data = {'title': "List %s" % source_name} + data['pages'] = [] + pgn = Paginator(None, source, page_num=page_num, items_per_page=20) + for p in pgn.items: + page_data = { + 'title': p['title'], + 'slug': p['slug'], + 'source': source_name, + 'url': url_for('edit_page', slug=p['slug'])} + data['pages'].append(page_data) + + prev_page_url = None + if pgn.prev_page_number: + prev_page_url = url_for( + 'list_source', source_name=source_name, + page_num=pgn.prev_page_number) + next_page_url = None + if pgn.next_page_number: + next_page_url = url_for( + 'list_source', source_name=source_name, + page_num=pgn.next_page_number) + + page_urls = [] + for i in pgn.all_page_numbers(7): + url = None + if i != page_num: + url = url_for('list_source', source_name=source_name, page_num=i) + page_urls.append({'num': i, 'url': url}) + + data['pagination'] = { + 'prev_page': prev_page_url, + 'next_page': next_page_url, + 'nums': page_urls + } + + with_menu_context(data) + return render_template('list_source.html', **data) +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/foodtruck/web.py Sat Jan 16 14:24:35 2016 -0800 @@ -0,0 +1,121 @@ +import logging +from flask import Flask, g, request, render_template +from .config import ( + FoodTruckConfigNotFoundError, get_foodtruck_config) +from .sites import FoodTruckSites + + +logger = logging.getLogger(__name__) + +app = Flask(__name__) + +c = get_foodtruck_config() +app.secret_key = c.get('foodtruck/secret_key') +del c + + +def after_this_request(f): + if not hasattr(g, 'after_request_callbacks'): + g.after_request_callbacks = [] + g.after_request_callbacks.append(f) + return f + + +class LazySomething(object): + def __init__(self, factory): + self._factory = factory + self._something = None + + def __getattr__(self, name): + if self._something is not None: + return getattr(self._something, name) + + self._something = self._factory() + return getattr(self._something, name) + + +@app.before_request +def _setup_foodtruck_globals(): + def _get_sites(): + s = FoodTruckSites(g.config, + request.cookies.get('foodtruck_site_name')) + return s + g.config = LazySomething(get_foodtruck_config) + g.sites = LazySomething(_get_sites) + + +@app.after_request +def _call_after_request_callbacks(response): + for callback in getattr(g, 'after_request_callbacks', ()): + callback(response) + return response + + +@app.errorhandler(FoodTruckConfigNotFoundError) +def _on_config_missing(ex): + return render_template('install.html') + + +@app.errorhandler +def _on_error(ex): + logging.exception(ex) + + +from flask.ext.login import LoginManager, UserMixin + + +class User(UserMixin): + def __init__(self, uid, pwd): + self.id = uid + self.password = pwd + + +def load_user(user_id): + admin_id = g.config.get('security/username') + if admin_id == user_id: + admin_pwd = g.config.get('security/password') + return User(admin_id, admin_pwd) + return None + + +login_manager = LoginManager() +login_manager.init_app(app) +login_manager.login_view = 'login' +login_manager.user_loader(load_user) + + +try: + from flask.ext.bcrypt import Bcrypt +except ImportError: + logging.warning("Bcrypt not available... falling back to SHA512.") + logging.warning("Run `pip install Flask-Bcrypt` for more secure " + "password hashing.") + + import hashlib + + def generate_password_hash(password): + return hashlib.sha512(password.encode('utf8')).hexdigest() + + def check_password_hash(reference, check): + check_hash = hashlib.sha512(check.encode('utf8')).hexdigest() + return check_hash == reference + + class SHA512Fallback(object): + def __init__(self, app=None): + self.generate_password_hash = generate_password_hash + self.check_password_hash = check_password_hash + + Bcrypt = SHA512Fallback + +app.bcrypt = Bcrypt(app) + + +import foodtruck.views.baking # NOQA +import foodtruck.views.create # NOQA +import foodtruck.views.edit # NOQA +import foodtruck.views.main # NOQA +import foodtruck.views.menu # NOQA +import foodtruck.views.preview # NOQA +import foodtruck.views.settings # NOQA +import foodtruck.views.sources # NOQA +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/foodtruckui/assets/js/foodtruck.js Sat Jan 16 14:24:35 2016 -0800 @@ -0,0 +1,43 @@ + +$(document).ready(function() { + $('.ft-nav-collapsed + ul').hide(); +}); + +if (!!window.EventSource) { + var source = new EventSource('/bakelog'); + + source.onerror = function(e) { + console.log("Error with SSE, closing.", e); + source.close(); + }; + source.addEventListener('message', function(e) { + var msgEl = $('<div></div>'); + + var removeMsgEl = function() { + msgEl.remove(); + var bakelogEl = $('#ft-bakelog'); + if (bakelogEl.children().length == 0) { + bakelogEl.hide(); + } + }; + + msgEl.addClass('alert-dismissible'); + msgEl.attr('role', 'alert'); + msgEl.append('<button type="button" class="close" data-dismiss="alert" aria-label="close">' + + '<span aria-hidden="true">×</span></button>'); + msgEl.append('<p>' + e.data + '</p>'); + var timeoutId = window.setTimeout(function() { + msgEl.fadeOut(400, removeMsgEl); + }, 4000); + msgEl.mouseenter(function() { + window.clearTimeout(timeoutId); + }); + $('button', msgEl).click(removeMsgEl); + + var logEl = $('#ft-bakelog'); + logEl.append(msgEl); + logEl.show(); + }); +} + +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/foodtruckui/assets/sass/foodtruck.scss Sat Jan 16 14:24:35 2016 -0800 @@ -0,0 +1,42 @@ + +// Overrides +$icon-font-path: '../fonts/'; + +// Core variables and mixins +@import "bootstrap/variables"; +@import "bootstrap/mixins"; + +// Reset and dependencies +@import "bootstrap/normalize"; +@import "bootstrap/print"; +@import "bootstrap/glyphicons"; + +// Core CSS +@import "bootstrap/scaffolding"; +@import "bootstrap/type"; +@import "bootstrap/code"; +@import "bootstrap/grid"; +@import "bootstrap/tables"; +@import "bootstrap/forms"; +@import "bootstrap/buttons"; + +// Components +@import "bootstrap/alerts"; +@import "bootstrap/button-groups"; +@import "bootstrap/close"; +@import "bootstrap/component-animations"; +@import "bootstrap/dropdowns"; +@import "bootstrap/input-groups"; + +// Utility classes +@import "bootstrap/utilities"; +@import "bootstrap/responsive-utilities"; + +// Ionicons +@import "ionicons"; + +// Foodtruck +@import "foodtruck/base"; +@import "foodtruck/sidebar"; +@import "foodtruck/editing"; +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/foodtruckui/assets/sass/foodtruck/_base.scss Sat Jan 16 14:24:35 2016 -0800 @@ -0,0 +1,62 @@ + +$ft-color-gray-darkest: #1A2226; +$ft-color-gray-darker: #1E282C; +$ft-color-gray-dark: #222D32; +$ft-color-gray: #2C3B41; +$ft-color-gray-light: #8AA4AF; +$ft-color-gray-lighter: #B8C7CE; +$ft-color-white: #FFF; +$ft-color-black: #000; +$ft-color-red: #D33939; +$ft-color-blue: #3C8DBC; +$ft-color-yellow: #C9C836; + + +header h1, header.title { + text-align: center; +} + +footer { + text-align: center; + font-size: 0.8em; + letter-spacing: -0.02em; + color: #777; + margin: 4em 2em 2em 2em; +} + +h1, h2, h3, h4, h5, h6 { + margin-bottom: 1em; +} + +.ft-login { + padding: 1em; + margin: 2em 0; + box-shadow: 0 5px 10px #ddd; + border: 1px solid #dcdcdc; +} + +.ft-pagination { + text-align: center; + color: $ft-color-gray; + font-size: 2em; + margin: 1em 0; +} +.ft-pagination-a { + padding: 0 0.2em; +} + +#ft-bakelog { + position: fixed; + right: 0; + bottom: 0; + margin: 0.5em; + padding: 1em; + width: 50%; + max-width: 30em; + color: $ft-color-gray; + background: $ft-color-gray-lighter; + border: 1px solid $ft-color-gray-light; + border-radius: 0.5em; + display: none; +} +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/foodtruckui/assets/sass/foodtruck/_editing.scss Sat Jan 16 14:24:35 2016 -0800 @@ -0,0 +1,13 @@ +// +// Page editing +// -------------------------------------------------- + +.ft-write-form textarea { + @include resizable(vertical); + + outline: none; + overflow: auto; + font-family: 'Courier', 'Courier New', sans-serif; + padding: 1em; +} +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/foodtruckui/assets/sass/foodtruck/_sidebar.scss Sat Jan 16 14:24:35 2016 -0800 @@ -0,0 +1,120 @@ +// +// Sidebar navigation +// -------------------------------------------------- + +$ft-nav-width: 25rem; +$ft-nav-margin: 2rem; + +// Layout +.ft-nav-container { + padding: 2rem; + transition: padding-left 0.5s ease; +} +.ft-nav-container.ft-nav-enabled { + padding-left: $ft-nav-width + $ft-nav-margin; +} + +.ft-nav { + z-index: 1000; + position: fixed; + height: 100%; + width: $ft-nav-width; + left: -$ft-nav-width; + top: 0; + bottom: 0; + overflow-y: auto; + transition: all 0.5s ease; +} +.ft-nav.ft-nav-enabled { + left: 0; +} + +@media(min-width:768px) { + .ft-nav-container { + padding-left: $ft-nav-width + $ft-nav-margin; + } + .ft-nav { + left: 0; + } +} + +// Style +.ft-nav { + background: $ft-color-gray-darkest; + color: #fff; + + span.icon { + font-size: 1.5em; + margin-right: 0.4em; + } +} +.ft-nav ul { + list-style: none; + margin: 0; + padding: 0; +} +.ft-nav ul li { + margin: 0; + padding: 0; +} +.ft-nav ul li a { + border-left: 5px solid transparent; + color: $ft-color-gray-light; + background: $ft-color-gray-dark; + padding: 1rem; + display: block; + letter-spacing: 0.05em; + text-transform: uppercase; + + &:hover { + border-left: 5px solid $ft-color-blue; + color: $ft-color-white; + background: $ft-color-gray-darker; + text-decoration: none; + } +} +.ft-nav li>ul { +} +.ft-nav li>ul li a { + color: $ft-color-gray-lighter; + background: $ft-color-gray; + padding-left: 3em; + text-transform: none; + + &:hover { + color: $ft-color-white; + background: $ft-color-gray; + } +} +.ft-nav ul li a.ft-nav-active { + border-left: 5px solid $ft-color-red; + color: $ft-color-white; + background: $ft-color-gray-darker; +} +.ft-nav li>ul li a.ft-nav-active { + color: $ft-color-white; + background: $ft-color-gray; +} + +// Title/logo +.ft-nav-title { + font-size: 2rem; + font-weight: bold; + text-align: center; + padding: 2rem 0; + margin: 0; +} +.ft-nav-brand { + font-family: 'Lobster', cursive; + font-size: 2em; + text-shadow: 2px 5px 0 $ft-color-gray; +} + +// Footer +.ft-nav-auth { + color: $ft-color-gray; + font-size: 0.8em; + text-align: center; + margin: 2em 0; +} +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/foodtruckui/bower.json Sat Jan 16 14:24:35 2016 -0800 @@ -0,0 +1,17 @@ +{ + "name": "FoodTruck", + "description": "", + "authors": [ + "Ludovic Chabant <ludovic@chabant.com>" + ], + "homepage": "http://bolt80.com/piecrust", + "ignore": [ + "node_modules", + "bower_components" + ], + "dependencies": { + "bootstrap-sass": "~3.3.5", + "font-awesome": "fontawesome#~4.5.0", + "Ionicons": "ionicons#~2.0.1" + } +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/foodtruckui/gulpfile.js Sat Jan 16 14:24:35 2016 -0800 @@ -0,0 +1,80 @@ +'use strict'; + +var gulp = require('gulp'), + util = require('gulp-util'), + sass = require('gulp-sass'), + sourcemaps = require('gulp-sourcemaps'), + rename = require('gulp-rename'), + minify = require('gulp-minify-css'), + concat = require('gulp-concat'), + uglify = require('gulp-uglify'); + +// Stylesheets +gulp.task('sass', function() { + //util.log("Generating CSS"); + return gulp.src('assets/sass/**/*.scss') + //.pipe(sourcemaps.init()) + .pipe(sass({ + errLogToConsole: true, + outputStyle: 'compressed', + includePaths: [ + 'bower_components/bootstrap-sass/assets/stylesheets', + 'bower_components/Ionicons/scss']})) + //.pipe(sourcemaps.write()) + .pipe(rename({suffix: '.min'})) + .pipe(minify()) + .pipe(gulp.dest('../foodtruck/static/css')); +}); +gulp.task('sass:watch', function() { + return gulp.watch('assets/sass/**/*.scss', ['sass']); +}); + +// Javascript +gulp.task('js', function() { + return gulp.src([ + 'bower_components/jquery/dist/jquery.js', + 'bower_components/bootstrap-sass/assets/javascripts/bootstrap/alert.js', + 'bower_components/bootstrap-sass/assets/javascripts/bootstrap/button.js', + 'bower_components/bootstrap-sass/assets/javascripts/bootstrap/collapse.js', + 'bower_components/bootstrap-sass/assets/javascripts/bootstrap/dropdown.js', + 'bower_components/bootstrap-sass/assets/javascripts/bootstrap/tooltip.js', + 'bower_components/bootstrap-sass/assets/javascripts/bootstrap/transition.js', + 'assets/js/**/*.js' + ]) + .pipe(sourcemaps.init()) + .pipe(concat('foodtruck.js')) + .pipe(sourcemaps.write()) + .pipe(rename({suffix: '.min'})) + //.pipe(uglify()) + .pipe(gulp.dest('../foodtruck/static/js')); +}); +gulp.task('js:watch', function() { + return gulp.watch('assets/js/**/*.js', ['js']); +}); + +// Fonts/images +gulp.task('fonts', function() { + return gulp.src([ + 'bower_components/bootstrap-sass/assets/fonts/bootstrap/*', + 'bower_components/Ionicons/fonts/*' + ]) + .pipe(gulp.dest('../foodtruck/static/fonts')); +}); + +gulp.task('images', function() { + return gulp.src([ + 'bower_components/bootstrap-sass/assets/images/*', + 'assets/img/*' + ]) + .pipe(gulp.dest('../foodtruck/static/img')); +}); + +// Launch tasks +gulp.task('default', function() { + gulp.start(['sass', 'js', 'fonts', 'images']); +}); + +gulp.task('watch', function() { + gulp.start(['sass:watch', 'js:watch']); +}); +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/foodtruckui/package.json Sat Jan 16 14:24:35 2016 -0800 @@ -0,0 +1,14 @@ +{ + "name": "FoodTruck", + "version": "1.0.0", + "devDependencies": { + "gulp": "~3.9.0", + "gulp-sass": "~2.1.0", + "gulp-minify-css": "~1.2.1", + "gulp-uglify": "~1.4.2", + "gulp-util": "~3.0.7", + "gulp-sourcemaps": "~1.6.0", + "gulp-rename": "~1.2.2", + "gulp-concat": "~2.6.0" + } +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/foodtruckui/tests/test_config.py Sat Jan 16 14:24:35 2016 -0800 @@ -0,0 +1,21 @@ +import os.path +from foodtruck.config import FoodTruckConfig + + +default_config = os.path.join( + os.path.dirname(__file__), + '..', + 'foodtruck', + 'foodtruck.cfg.defaults') + + +def test_getcomplex_option(): + cstr = '''[foo] + bar.name = My bar + bar.path = /path/to/bar + ''' + c = FoodTruckConfig(None, None) + c.load_from_string(cstr) + expected = {'name': "My bar", 'path': '/path/to/bar'} + assert c.getcomplex('foo', 'bar') == expected +