@@ -11,6 +11,7 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| | |||
config.vm.synced_folder "srv/", "/srv/" | |||
config.vm.synced_folder ".", "/home/deploy/realms" | |||
config.vm.synced_folder "~/.virtualenvs", "/home/deploy/virtualenvs" | |||
config.vm.provision :salt do |salt| | |||
salt.minion_config = "srv/minion" | |||
salt.run_highstate = true | |||
@@ -1,9 +1,15 @@ | |||
from gevent import wsgi | |||
from realms import config, app, manager | |||
from flask.ext.script import Server | |||
manager.add_command("runserver", Server(host="0.0.0.0", port=config.PORT)) | |||
@manager.command | |||
def server(): | |||
def run(): | |||
""" | |||
Run production ready server | |||
""" | |||
print "Server started. Env: %s Port: %s" % (config.ENV, config.PORT) | |||
wsgi.WSGIServer(('', int(config.PORT)), app).serve_forever() | |||
@@ -20,18 +20,18 @@ import sys | |||
import json | |||
import httplib | |||
import traceback | |||
from flask import Flask, request, render_template, url_for, redirect, session, flash, g | |||
from flask import Flask, request, render_template, url_for, redirect, g | |||
from flask.ctx import _AppCtxGlobals | |||
from flask.ext.cache import Cache | |||
from flask.ext.script import Manager | |||
from flask.ext.login import LoginManager | |||
from flask.ext.login import LoginManager, current_user | |||
from flask.ext.sqlalchemy import SQLAlchemy | |||
from flask.ext.assets import Environment, Bundle | |||
from werkzeug.routing import BaseConverter | |||
from werkzeug.utils import cached_property | |||
from werkzeug.exceptions import HTTPException | |||
from realms import config | |||
from realms.lib.ratelimit import get_view_rate_limit, ratelimiter | |||
from realms.lib.session import RedisSessionInterface | |||
from realms.lib.wiki import Wiki | |||
from realms.lib.util import to_canonical, remove_ext, mkdir_safe, gravatar_url, to_dict | |||
@@ -39,11 +39,7 @@ class AppCtxGlobals(_AppCtxGlobals): | |||
@cached_property | |||
def current_user(self): | |||
return session.get('user') if session.get('user') else {'username': 'Anon'} | |||
@cached_property | |||
def current_wiki(self): | |||
return Wiki(config.WIKI_PATH) | |||
return current_user | |||
class Application(Flask): | |||
@@ -68,8 +64,8 @@ class Application(Flask): | |||
return super(Application, self).__call__(environ, start_response) | |||
def discover(self): | |||
IMPORT_NAME = 'realms.modules' | |||
FROMLIST = ( | |||
import_name = 'realms.modules' | |||
fromlist = ( | |||
'assets', | |||
'commands', | |||
'models', | |||
@@ -78,10 +74,10 @@ class Application(Flask): | |||
start_time = time.time() | |||
__import__(IMPORT_NAME, fromlist=FROMLIST) | |||
__import__(import_name, fromlist=fromlist) | |||
for module_name in self.config['MODULES']: | |||
sources = __import__('%s.%s' % (IMPORT_NAME, module_name), fromlist=FROMLIST) | |||
sources = __import__('%s.%s' % (import_name, module_name), fromlist=fromlist) | |||
# Blueprint | |||
if hasattr(sources, 'views'): | |||
@@ -107,6 +103,18 @@ class Application(Flask): | |||
return super(Application, self).make_response(tuple(rv)) | |||
class Assets(Environment): | |||
default_filters = {'js': 'uglifyjs', 'css': 'cssmin'} | |||
default_output = {'js': 'assets/%(version)s.js', 'css': 'assets/%(version)s.css'} | |||
def register(self, name, *args, **kwargs): | |||
ext = args[0].split('.')[-1] | |||
filters = kwargs.get('filters', self.default_filters[ext]) | |||
output = kwargs.get('output', self.default_output[ext]) | |||
super(Assets, self).register(name, Bundle(*args, filters=filters, output=output)) | |||
class RegexConverter(BaseConverter): | |||
""" | |||
Enables Regex matching on endpoints | |||
@@ -122,20 +130,6 @@ def redirect_url(referrer=None): | |||
return request.args.get('next') or referrer or url_for('index') | |||
app = Application(__name__) | |||
app.config.from_object('realms.config') | |||
app.session_interface = RedisSessionInterface() | |||
app.url_map.converters['regex'] = RegexConverter | |||
app.url_map.strict_slashes = False | |||
app.debug = config.DEBUG | |||
manager = Manager(app) | |||
login_manager = LoginManager() | |||
login_manager.init_app(app) | |||
def error_handler(e): | |||
try: | |||
if isinstance(e, HTTPException): | |||
@@ -150,7 +144,7 @@ def error_handler(e): | |||
if request.is_xhr or request.accept_mimetypes.best in ['application/json', 'text/javascript']: | |||
response = { | |||
'message': message, | |||
'traceback': tb, | |||
'traceback': tb | |||
} | |||
else: | |||
response = render_template('errors/error.html', | |||
@@ -163,60 +157,67 @@ def error_handler(e): | |||
return response, status_code | |||
for status_code in httplib.responses: | |||
if status_code >= 400: | |||
app.register_error_handler(status_code, error_handler) | |||
from realms.lib.assets import register, assets | |||
assets.init_app(app) | |||
assets.app = app | |||
assets.debug = config.DEBUG | |||
def create_app(): | |||
app = Application(__name__) | |||
app.config.from_object('realms.config') | |||
app.url_map.converters['regex'] = RegexConverter | |||
app.url_map.strict_slashes = False | |||
register('main', | |||
'vendor/jquery/jquery.js', | |||
'vendor/components-underscore/underscore.js', | |||
'vendor/components-bootstrap/js/bootstrap.js', | |||
'vendor/handlebars/handlebars.js', | |||
'vendor/showdown/src/showdown.js', | |||
'vendor/showdown/src/extensions/table.js', | |||
'js/wmd.js', | |||
'js/html-sanitizer-minified.js', # don't minify | |||
'vendor/highlightjs/highlight.pack.js', | |||
'vendor/parsleyjs/dist/parsley.js', | |||
'js/main.js') | |||
for status_code in httplib.responses: | |||
if status_code >= 400: | |||
app.register_error_handler(status_code, error_handler) | |||
@app.before_request | |||
def init_g(): | |||
g.assets = ['main'] | |||
@app.before_request | |||
def init_g(): | |||
g.assets = ['main'] | |||
@app.template_filter('datetime') | |||
def _jinja2_filter_datetime(ts): | |||
return time.strftime('%b %d, %Y %I:%M %p', time.localtime(ts)) | |||
@app.errorhandler(404) | |||
def page_not_found(e): | |||
return render_template('errors/404.html'), 404 | |||
@app.after_request | |||
def inject_x_rate_headers(response): | |||
limit = get_view_rate_limit() | |||
if limit and limit.send_x_headers: | |||
h = response.headers | |||
h.add('X-RateLimit-Remaining', str(limit.remaining)) | |||
h.add('X-RateLimit-Limit', str(limit.limit)) | |||
h.add('X-RateLimit-Reset', str(limit.reset)) | |||
return response | |||
if config.RELATIVE_PATH: | |||
@app.route("/") | |||
def root(): | |||
return redirect(url_for(config.ROOT_ENDPOINT)) | |||
return app | |||
@app.template_filter('datetime') | |||
def _jinja2_filter_datetime(ts): | |||
return time.strftime('%b %d, %Y %I:%M %p', time.localtime(ts)) | |||
app = create_app() | |||
# Init plugins here if possible | |||
manager = Manager(app) | |||
login_manager = LoginManager(app) | |||
login_manager.login_view = 'auth.login' | |||
db = SQLAlchemy(app) | |||
cache = Cache(app) | |||
assets = Environment(app) | |||
assets.register('main', | |||
'vendor/jquery/jquery.js', | |||
'vendor/components-underscore/underscore.js', | |||
'vendor/components-bootstrap/js/bootstrap.js', | |||
'vendor/handlebars/handlebars.js', | |||
'vendor/showdown/src/showdown.js', | |||
'vendor/showdown/src/extensions/table.js', | |||
'js/wmd.js', | |||
'js/html-sanitizer-minified.js', # don't minify? | |||
'vendor/highlightjs/highlight.pack.js', | |||
'vendor/parsleyjs/dist/parsley.js', | |||
'js/main.js') | |||
app.discover() | |||
db.create_all() | |||
@app.errorhandler(404) | |||
def page_not_found(e): | |||
return render_template('errors/404.html'), 404 | |||
if config.RELATIVE_PATH: | |||
@app.route("/") | |||
def root(): | |||
return redirect(url_for(config.ROOT_ENDPOINT)) | |||
app.discover() | |||
@@ -7,19 +7,35 @@ ENV = 'DEV' | |||
DEBUG = True | |||
ASSETS_DEBUG = True | |||
SQLALCHEMY_ECHO = True | |||
PORT = 80 | |||
BASE_URL = 'http://realms.dev' | |||
REDIS_HOST = '127.0.0.1' | |||
REDIS_PORT = 6379 | |||
REDIS_DB = '0' | |||
DB_URI = 'sqlite:////home/deploy/wiki.db' | |||
CACHE_TYPE = 'simple' | |||
# Redis Example | |||
""" | |||
CACHE_TYPE = 'redis' | |||
CACHE_REDIS_HOST = '127.0.0.1' | |||
CACHE_REDIS_PORT = 6379 | |||
CACHE_REDIS_DB = '0' | |||
""" | |||
SECRET = 'K3dRq1q9eN72GJDkgvyshFVwlqHHCyPI' | |||
RECAPTCHA_ENABLE = True | |||
RECAPTCHA_USE_SSL = False | |||
RECAPTCHA_PUBLIC_KEY = "6LfYbPkSAAAAAB4a2lG2Y_Yjik7MG9l4TDzyKUao" | |||
RECAPTCHA_PRIVATE_KEY = "6LfYbPkSAAAAAG-KlkwjZ8JLWgwc9T0ytkN7lWRE" | |||
RECAPTCHA_OPTIONS = {} | |||
SECRET_KEY = 'K3dRq1q9eN72GJDkgvyshFVwlqHHCyPI' | |||
WIKI_PATH = '/home/deploy/wiki' | |||
WIKI_HOME = 'home' | |||
ALLOW_ANON = True | |||
LOGIN_DISABLED = ALLOW_ANON | |||
ROOT_ENDPOINT = 'wiki.page' | |||
@@ -27,10 +43,11 @@ with open(os.path.join(os.path.dirname(__file__) + "/../../", 'config.json')) as | |||
__settings = json.load(f) | |||
globals().update(__settings) | |||
# String trailing slash | |||
if BASE_URL.endswith('/'): | |||
BASE_URL = BASE_URL[-1] | |||
SQLALCHEMY_DATABASE_URI = DB_URI | |||
_url = urlparse(BASE_URL) | |||
RELATIVE_PATH = _url.path | |||
@@ -1,11 +0,0 @@ | |||
from flask.ext.assets import Bundle, Environment | |||
# This can be done better, make it better | |||
assets = Environment() | |||
filters = 'uglifyjs' | |||
output = 'assets/%(version)s.js' | |||
def register(name, *files): | |||
assets.register(name, Bundle(*files, filters=filters, output=output)) |
@@ -0,0 +1,287 @@ | |||
import json | |||
from realms import db | |||
from sqlalchemy import not_ | |||
from datetime import datetime | |||
class Model(db.Model): | |||
"""Base SQLAlchemy Model for automatic serialization and | |||
deserialization of columns and nested relationships. | |||
Source: https://gist.github.com/alanhamlett/6604662 | |||
Usage:: | |||
>>> class User(Model): | |||
>>> id = db.Column(db.Integer(), primary_key=True) | |||
>>> email = db.Column(db.String(), index=True) | |||
>>> name = db.Column(db.String()) | |||
>>> password = db.Column(db.String()) | |||
>>> posts = db.relationship('Post', backref='user', lazy='dynamic') | |||
>>> ... | |||
>>> default_fields = ['email', 'name'] | |||
>>> hidden_fields = ['password'] | |||
>>> readonly_fields = ['email', 'password'] | |||
>>> | |||
>>> class Post(Model): | |||
>>> id = db.Column(db.Integer(), primary_key=True) | |||
>>> user_id = db.Column(db.String(), db.ForeignKey('user.id'), nullable=False) | |||
>>> title = db.Column(db.String()) | |||
>>> ... | |||
>>> default_fields = ['title'] | |||
>>> readonly_fields = ['user_id'] | |||
>>> | |||
>>> model = User(email='john@localhost') | |||
>>> db.session.add(model) | |||
>>> db.session.commit() | |||
>>> | |||
>>> # update name and create a new post | |||
>>> validated_input = {'name': 'John', 'posts': [{'title':'My First Post'}]} | |||
>>> model.set_columns(**validated_input) | |||
>>> db.session.commit() | |||
>>> | |||
>>> print(model.to_dict(show=['password', 'posts'])) | |||
>>> {u'email': u'john@localhost', u'posts': [{u'id': 1, u'title': u'My First Post'}], u'name': u'John', u'id': 1} | |||
""" | |||
__abstract__ = True | |||
# Stores changes made to this model's attributes. Can be retrieved | |||
# with model.changes | |||
_changes = {} | |||
def __init__(self, **kwargs): | |||
kwargs['_force'] = True | |||
self._set_columns(**kwargs) | |||
def _set_columns(self, **kwargs): | |||
force = kwargs.get('_force') | |||
readonly = [] | |||
if hasattr(self, 'readonly_fields'): | |||
readonly = self.readonly_fields | |||
if hasattr(self, 'hidden_fields'): | |||
readonly += self.hidden_fields | |||
readonly += [ | |||
'id', | |||
'created', | |||
'updated', | |||
'modified', | |||
'created_at', | |||
'updated_at', | |||
'modified_at', | |||
] | |||
changes = {} | |||
columns = self.__table__.columns.keys() | |||
relationships = self.__mapper__.relationships.keys() | |||
for key in columns: | |||
allowed = True if force or key not in readonly else False | |||
exists = True if key in kwargs else False | |||
if allowed and exists: | |||
val = getattr(self, key) | |||
if val != kwargs[key]: | |||
changes[key] = {'old': val, 'new': kwargs[key]} | |||
setattr(self, key, kwargs[key]) | |||
for rel in relationships: | |||
allowed = True if force or rel not in readonly else False | |||
exists = True if rel in kwargs else False | |||
if allowed and exists: | |||
is_list = self.__mapper__.relationships[rel].uselist | |||
if is_list: | |||
valid_ids = [] | |||
query = getattr(self, rel) | |||
cls = self.__mapper__.relationships[rel].argument() | |||
for item in kwargs[rel]: | |||
if 'id' in item and query.filter_by(id=item['id']).limit(1).count() == 1: | |||
obj = cls.query.filter_by(id=item['id']).first() | |||
col_changes = obj.set_columns(**item) | |||
if col_changes: | |||
col_changes['id'] = str(item['id']) | |||
if rel in changes: | |||
changes[rel].append(col_changes) | |||
else: | |||
changes.update({rel: [col_changes]}) | |||
valid_ids.append(str(item['id'])) | |||
else: | |||
col = cls() | |||
col_changes = col.set_columns(**item) | |||
query.append(col) | |||
db.session.flush() | |||
if col_changes: | |||
col_changes['id'] = str(col.id) | |||
if rel in changes: | |||
changes[rel].append(col_changes) | |||
else: | |||
changes.update({rel: [col_changes]}) | |||
valid_ids.append(str(col.id)) | |||
# delete related rows that were not in kwargs[rel] | |||
for item in query.filter(not_(cls.id.in_(valid_ids))).all(): | |||
col_changes = { | |||
'id': str(item.id), | |||
'deleted': True, | |||
} | |||
if rel in changes: | |||
changes[rel].append(col_changes) | |||
else: | |||
changes.update({rel: [col_changes]}) | |||
db.session.delete(item) | |||
else: | |||
val = getattr(self, rel) | |||
if self.__mapper__.relationships[rel].query_class is not None: | |||
if val is not None: | |||
col_changes = val.set_columns(**kwargs[rel]) | |||
if col_changes: | |||
changes.update({rel: col_changes}) | |||
else: | |||
if val != kwargs[rel]: | |||
setattr(self, rel, kwargs[rel]) | |||
changes[rel] = {'old': val, 'new': kwargs[rel]} | |||
return changes | |||
def set_columns(self, **kwargs): | |||
self._changes = self._set_columns(**kwargs) | |||
if 'modified' in self.__table__.columns: | |||
self.modified = datetime.utcnow() | |||
if 'updated' in self.__table__.columns: | |||
self.updated = datetime.utcnow() | |||
if 'modified_at' in self.__table__.columns: | |||
self.modified_at = datetime.utcnow() | |||
if 'updated_at' in self.__table__.columns: | |||
self.updated_at = datetime.utcnow() | |||
return self._changes | |||
def __repr__(self): | |||
if 'id' in self.__table__.columns.keys(): | |||
return '%s(%s)' % (self.__class__.__name__, self.id) | |||
data = {} | |||
for key in self.__table__.columns.keys(): | |||
val = getattr(self, key) | |||
if type(val) is datetime: | |||
val = val.strftime('%Y-%m-%dT%H:%M:%SZ') | |||
data[key] = val | |||
return json.dumps(data, use_decimal=True) | |||
@property | |||
def changes(self): | |||
return self._changes | |||
def reset_changes(self): | |||
self._changes = {} | |||
def to_dict(self, show=None, hide=None, path=None, show_all=None): | |||
""" Return a dictionary representation of this model. | |||
""" | |||
if not show: | |||
show = [] | |||
if not hide: | |||
hide = [] | |||
hidden = [] | |||
if hasattr(self, 'hidden_fields'): | |||
hidden = self.hidden_fields | |||
default = [] | |||
if hasattr(self, 'default_fields'): | |||
default = self.default_fields | |||
ret_data = {} | |||
if not path: | |||
path = self.__tablename__.lower() | |||
def prepend_path(item): | |||
item = item.lower() | |||
if item.split('.', 1)[0] == path: | |||
return item | |||
if len(item) == 0: | |||
return item | |||
if item[0] != '.': | |||
item = '.%s' % item | |||
item = '%s%s' % (path, item) | |||
return item | |||
show[:] = [prepend_path(x) for x in show] | |||
hide[:] = [prepend_path(x) for x in hide] | |||
columns = self.__table__.columns.keys() | |||
relationships = self.__mapper__.relationships.keys() | |||
properties = dir(self) | |||
for key in columns: | |||
check = '%s.%s' % (path, key) | |||
if check in hide or key in hidden: | |||
continue | |||
if show_all or key is 'id' or check in show or key in default: | |||
ret_data[key] = getattr(self, key) | |||
for key in relationships: | |||
check = '%s.%s' % (path, key) | |||
if check in hide or key in hidden: | |||
continue | |||
if show_all or check in show or key in default: | |||
hide.append(check) | |||
is_list = self.__mapper__.relationships[key].uselist | |||
if is_list: | |||
ret_data[key] = [] | |||
for item in getattr(self, key): | |||
ret_data[key].append(item.to_dict( | |||
show=show, | |||
hide=hide, | |||
path=('%s.%s' % (path, key.lower())), | |||
show_all=show_all, | |||
)) | |||
else: | |||
if self.__mapper__.relationships[key].query_class is not None: | |||
ret_data[key] = getattr(self, key).to_dict( | |||
show=show, | |||
hide=hide, | |||
path=('%s.%s' % (path, key.lower())), | |||
show_all=show_all, | |||
) | |||
else: | |||
ret_data[key] = getattr(self, key) | |||
for key in list(set(properties) - set(columns) - set(relationships)): | |||
if key.startswith('_'): | |||
continue | |||
check = '%s.%s' % (path, key) | |||
if check in hide or key in hidden: | |||
continue | |||
if show_all or check in show or key in default: | |||
val = getattr(self, key) | |||
try: | |||
ret_data[key] = json.loads(json.dumps(val)) | |||
except: | |||
pass | |||
return ret_data | |||
@classmethod | |||
def insert_or_update(cls, cond, data): | |||
obj = cls.query.filter_by(**cond).first() | |||
if obj: | |||
obj.set_columns(**data) | |||
else: | |||
data.update(cond) | |||
obj = cls(**data) | |||
db.session.add(obj) | |||
db.session.commit() | |||
def save(self): | |||
if self not in db.session: | |||
db.session.merge(self) | |||
db.session.commit() | |||
def delete(self): | |||
if self not in db.session: | |||
db.session.merge(self) | |||
db.session.delete(self) | |||
db.session.commit() | |||
@classmethod | |||
def get_by_id(cls, id_): | |||
return cls.query.filter_by(id=id_).first() |
@@ -1,46 +0,0 @@ | |||
import time | |||
from functools import update_wrapper | |||
from flask import request, g | |||
from services import db | |||
class RateLimit(object): | |||
expiration_window = 10 | |||
def __init__(self, key_prefix, limit, per, send_x_headers): | |||
self.reset = (int(time.time()) // per) * per + per | |||
self.key = key_prefix + str(self.reset) | |||
self.limit = limit | |||
self.per = per | |||
self.send_x_headers = send_x_headers | |||
p = db.pipeline() | |||
p.incr(self.key) | |||
p.expireat(self.key, self.reset + self.expiration_window) | |||
self.current = min(p.execute()[0], limit) | |||
remaining = property(lambda x: x.limit - x.current) | |||
over_limit = property(lambda x: x.current >= x.limit) | |||
def get_view_rate_limit(): | |||
return getattr(g, '_view_rate_limit', None) | |||
def on_over_limit(limit): | |||
return 'Slow it down', 400 | |||
def ratelimiter(limit, per=300, send_x_headers=True, | |||
over_limit=on_over_limit, | |||
scope_func=lambda: request.remote_addr, | |||
key_func=lambda: request.endpoint): | |||
def decorator(f): | |||
def rate_limited(*args, **kwargs): | |||
key = 'rate-limit/%s/%s/' % (key_func(), scope_func()) | |||
rlimit = RateLimit(key, limit, per, send_x_headers) | |||
g._view_rate_limit = rlimit | |||
if over_limit is not None and rlimit.over_limit: | |||
return over_limit(rlimit) | |||
return f(*args, **kwargs) | |||
return update_wrapper(rate_limited, f) | |||
return decorator |
@@ -1,4 +0,0 @@ | |||
import redis | |||
from realms import config | |||
db = redis.StrictRedis(host=config.REDIS_HOST, port=config.REDIS_PORT, db=config.REDIS_DB) |
@@ -1,64 +0,0 @@ | |||
import pickle | |||
from datetime import timedelta | |||
from uuid import uuid4 | |||
from redis import Redis | |||
from werkzeug.datastructures import CallbackDict | |||
from flask.sessions import SessionInterface, SessionMixin | |||
class RedisSession(CallbackDict, SessionMixin): | |||
def __init__(self, initial=None, sid=None, new=False): | |||
def on_update(self): | |||
self.modified = True | |||
CallbackDict.__init__(self, initial, on_update) | |||
self.sid = sid | |||
self.new = new | |||
self.modified = False | |||
class RedisSessionInterface(SessionInterface): | |||
serializer = pickle | |||
session_class = RedisSession | |||
def __init__(self, redis=None, prefix='session:'): | |||
if redis is None: | |||
redis = Redis() | |||
self.redis = redis | |||
self.prefix = prefix | |||
def generate_sid(self): | |||
return str(uuid4()) | |||
def get_redis_expiration_time(self, app, session): | |||
if session.permanent: | |||
return app.permanent_session_lifetime | |||
return timedelta(days=1) | |||
def open_session(self, app, request): | |||
sid = request.cookies.get(app.session_cookie_name) | |||
if not sid: | |||
sid = self.generate_sid() | |||
return self.session_class(sid=sid) | |||
val = self.redis.get(self.prefix + sid) | |||
if val is not None: | |||
data = self.serializer.loads(val) | |||
return self.session_class(data, sid=sid) | |||
return self.session_class(sid=sid, new=True) | |||
def save_session(self, app, session, response): | |||
domain = self.get_cookie_domain(app) | |||
if not session: | |||
self.redis.delete(self.prefix + session.sid) | |||
if session.modified: | |||
response.delete_cookie(app.session_cookie_name, | |||
domain=domain) | |||
return | |||
redis_exp = self.get_redis_expiration_time(app, session) | |||
cookie_exp = self.get_expiration_time(app, session) | |||
val = self.serializer.dumps(dict(session)) | |||
self.redis.setex(self.prefix + session.sid, val, | |||
int(redis_exp.total_seconds())) | |||
response.set_cookie(app.session_cookie_name, session.sid, | |||
expires=cookie_exp, httponly=True, | |||
domain=domain) |
@@ -3,8 +3,6 @@ import os | |||
import hashlib | |||
import json | |||
from realms.lib.services import db | |||
class AttrDict(dict): | |||
def __init__(self, *args, **kwargs): | |||
@@ -34,36 +32,6 @@ def to_dict(data): | |||
return row2dict(data) | |||
def cache_it(fn): | |||
def wrap(*args, **kw): | |||
key = "%s:%s" % (args[0].table, args[1]) | |||
data = db.get(key) | |||
# Assume strings are JSON encoded | |||
try: | |||
data = json.loads(data) | |||
except TypeError: | |||
pass | |||
except ValueError: | |||
pass | |||
if data is not None: | |||
return data | |||
else: | |||
data = fn(*args) | |||
print data | |||
ret = data | |||
if data is None: | |||
data = '' | |||
if not isinstance(data, basestring): | |||
try: | |||
data = json.dumps(data, separators=(',', ':')) | |||
except TypeError: | |||
pass | |||
db.set(key, data) | |||
return ret | |||
return wrap | |||
def mkdir_safe(path): | |||
if path and not(os.path.exists(path)): | |||
os.makedirs(path) | |||
@@ -1,5 +1,6 @@ | |||
from wtforms import Form, StringField, PasswordField, validators | |||
from flask_wtf import Form, RecaptchaField | |||
from wtforms import StringField, PasswordField, validators | |||
from realms import config | |||
class RegistrationForm(Form): | |||
username = StringField('Username', [validators.Length(min=4, max=25)]) | |||
@@ -10,9 +11,12 @@ class RegistrationForm(Form): | |||
]) | |||
confirm = PasswordField('Repeat Password') | |||
if config.RECAPTCHA_ENABLE: | |||
setattr(RegistrationForm, 'recaptcha', RecaptchaField("You Human?")) | |||
class LoginForm(Form): | |||
email = StringField('Email', [validators.DataRequired]) | |||
password = PasswordField('Password', [validators.DataRequired]) | |||
email = StringField('Email', [validators.DataRequired()]) | |||
password = PasswordField('Password', [validators.DataRequired()]) | |||
@@ -1,23 +1,15 @@ | |||
from flask.ext.login import UserMixin, logout_user, login_user | |||
from realms import config, login_manager | |||
from realms.lib.services import db | |||
from flask.ext.login import UserMixin, logout_user, login_user, AnonymousUserMixin | |||
from realms import config, login_manager, db | |||
from realms.lib.model import Model | |||
from realms.lib.util import gravatar_url | |||
from itsdangerous import URLSafeSerializer, BadSignature | |||
from hashlib import sha256 | |||
import json | |||
import bcrypt | |||
FIELD_MAP = dict( | |||
u='username', | |||
e='email', | |||
p='password', | |||
nv='not_verified', | |||
a='admin', | |||
b='banned') | |||
@login_manager.user_loader | |||
def load_user(user_id): | |||
return User.get(user_id) | |||
return User.get_by_id(user_id) | |||
@login_manager.token_loader | |||
@@ -29,7 +21,7 @@ def load_token(token): | |||
return False | |||
# User key *could* be stored in payload to avoid user lookup in db | |||
user = User.get(payload.get('id')) | |||
user = User.get_by_id(payload.get('id')) | |||
if not user: | |||
return False | |||
@@ -43,68 +35,78 @@ def load_token(token): | |||
return False | |||
class User(UserMixin): | |||
class AnonUser(AnonymousUserMixin): | |||
username = 'Anon' | |||
email = '' | |||
username = None | |||
email = None | |||
password = None | |||
class User(Model, UserMixin): | |||
__tablename__ = 'users' | |||
id = db.Column(db.Integer, primary_key=True) | |||
username = db.Column(db.String, unique=True) | |||
email = db.Column(db.String, unique=True) | |||
password = db.Column(db.String) | |||
def __init__(self, email, data=None): | |||
self.id = email | |||
for k, v in data.items(): | |||
setattr(self, FIELD_MAP.get(k, k), v) | |||
hidden_fields = ['password'] | |||
readonly_fields = ['email', 'password'] | |||
def get_auth_token(self): | |||
key = sha256(self.password).hexdigest() | |||
return User.signer(key).dumps(dict(id=self.username)) | |||
return User.signer(key).dumps(dict(id=self.id)) | |||
@property | |||
def avatar(self): | |||
return gravatar_url(self.email) | |||
@staticmethod | |||
def create(username, email, password): | |||
User.set(email, dict(u=username, e=email, p=User.hash(password), nv=1)) | |||
u = User() | |||
u.username = username | |||
u.email = email | |||
u.password = User.hash_password(password) | |||
u.save() | |||
@staticmethod | |||
def signer(salt): | |||
""" | |||
Signed with app secret salted with sha256 of password hash of user (client secret) | |||
""" | |||
return URLSafeSerializer(config.SECRET + salt) | |||
def get_by_username(username): | |||
return User.query.filter_by(username=username).first() | |||
@staticmethod | |||
def set(email, data): | |||
db.set('u:%s' % email, json.dumps(data, separators=(',', ':'))) | |||
def get_by_email(email): | |||
return User.query.filter_by(email=email).first() | |||
@staticmethod | |||
def get(email): | |||
data = db.get('u:%s', email) | |||
try: | |||
data = json.loads(data) | |||
except ValueError: | |||
return None | |||
if data: | |||
return User(email, data) | |||
else: | |||
return None | |||
def signer(salt): | |||
""" | |||
Signed with app secret salted with sha256 of password hash of user (client secret) | |||
""" | |||
return URLSafeSerializer(config.SECRET_KEY + salt) | |||
@staticmethod | |||
def auth(email, password): | |||
user = User.get(email) | |||
user = User.query.filter_by(email=email).first() | |||
if not user: | |||
# User doesn't exist | |||
return False | |||
if bcrypt.checkpw(password, user.password): | |||
if User.check_password(password, user.password): | |||
# Password is good, log in user | |||
login_user(user, remember=True) | |||
return user | |||
else: | |||
# Password check failed | |||
return False | |||
@staticmethod | |||
def hash(password): | |||
return bcrypt.hashpw(password, bcrypt.gensalt(log_rounds=12)) | |||
def hash_password(password): | |||
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt(12)) | |||
@staticmethod | |||
def check_password(password, hashed): | |||
return bcrypt.hashpw(password.encode('utf-8'), hashed.encode('utf-8')) == hashed | |||
@classmethod | |||
def logout(cls): | |||
logout_user() | |||
login_manager.anonymous_user = AnonUser |
@@ -13,24 +13,55 @@ def logout_page(): | |||
return redirect(url_for(config.ROOT_ENDPOINT)) | |||
@blueprint.route("/login") | |||
@blueprint.route("/login", methods=['GET', 'POST']) | |||
def login(): | |||
if request.method == "POST": | |||
form = RegistrationForm() | |||
form = LoginForm() | |||
# TODO | |||
if request.method == "POST": | |||
if not form.validate(): | |||
flash('Form invalid') | |||
flash('Form invalid', 'warning') | |||
return redirect(url_for('auth.login')) | |||
if User.auth(request.form['email'], request.form['password']): | |||
return redirect(request.args.get("next") or url_for(config.ROOT_ENDPOINT)) | |||
else: | |||
flash('Email or Password Incorrect', 'warning') | |||
return redirect(url_for('auth.login')) | |||
return render_template("auth/login.html") | |||
return render_template("auth/login.html", form=form) | |||
@blueprint.route("/register") | |||
@blueprint.route("/register", methods=['GET', 'POST']) | |||
def register(): | |||
form = RegistrationForm() | |||
if request.method == "POST": | |||
if not form.validate(): | |||
flash('Form invalid', 'warning') | |||
return redirect(url_for('auth.register')) | |||
if User.get_by_username(request.form['username']): | |||
flash('Username is taken', 'warning') | |||
return redirect(url_for('auth.register')) | |||
if User.get_by_email(request.form['email']): | |||
flash('Email is taken', 'warning') | |||
return redirect(url_for('auth.register')) | |||
User.create(request.form['username'], request.form['email'], request.form['password']) | |||
User.auth(request.form['email'], request.form['password']) | |||
return redirect(request.args.get("next") or url_for(config.ROOT_ENDPOINT)) | |||
else: | |||
return render_template("auth/register.html") | |||
return render_template("auth/register.html", form=form) | |||
@blueprint.route("/settings", methods=['GET', 'POST']) | |||
def settings(): | |||
return render_template("auth/settings.html") | |||
@blueprint.route("/logout") | |||
def logout(): | |||
User.logout() | |||
return redirect("/") |
@@ -1,8 +1,7 @@ | |||
from realms.lib.assets import register | |||
from realms import assets | |||
register( | |||
'editor', | |||
'js/ace/ace.js', | |||
'js/ace/mode-markdown.js', | |||
'vendor/keymaster/keymaster.js', | |||
'js/dillinger.js') | |||
assets.register('editor', | |||
'js/ace/ace.js', | |||
'js/ace/mode-markdown.js', | |||
'vendor/keymaster/keymaster.js', | |||
'js/dillinger.js') |
@@ -7,7 +7,8 @@ import gittle.utils | |||
from gittle import Gittle | |||
from dulwich.repo import NotGitRepository | |||
from werkzeug.utils import escape, unescape | |||
from util import to_canonical | |||
from realms.lib.util import to_canonical | |||
from realms import cache | |||
class MyGittle(Gittle): | |||
@@ -94,7 +95,7 @@ class Wiki(): | |||
content = lxml.html.tostring(tree, encoding='utf-8', method='html') | |||
# post processing to fix errors | |||
# remove added div tags | |||
content = content[5:-6] | |||
# FIXME this is for block quotes, doesn't work for double ">" | |||
@@ -103,7 +104,8 @@ class Wiki(): | |||
content = re.sub(r"```(.*?)```", unescape_repl, content, flags=re.DOTALL) | |||
filename = self.cname_to_filename(to_canonical(name)) | |||
cname = to_canonical(name) | |||
filename = self.cname_to_filename(cname) | |||
with open(self.path + "/" + filename, 'w') as f: | |||
f.write(content) | |||
@@ -119,10 +121,14 @@ class Wiki(): | |||
if not email: | |||
email = self.default_committer_email | |||
return self.repo.commit(name=username, | |||
email=email, | |||
message=message, | |||
files=[filename]) | |||
ret = self.repo.commit(name=username, | |||
email=email, | |||
message=message, | |||
files=[filename]) | |||
cache.delete_memoized(Wiki.get_page, cname) | |||
return ret | |||
def rename_page(self, old_name, new_name): | |||
old_name, new_name = map(self.cname_to_filename, [old_name, new_name]) | |||
@@ -131,7 +137,10 @@ class Wiki(): | |||
email=self.default_committer_email, | |||
message="Moving %s to %s" % (old_name, new_name), | |||
files=[old_name]) | |||
cache.delete_memoized(Wiki.get_page, old_name) | |||
cache.delete_memoized(Wiki.get_page, new_name) | |||
@cache.memoize() | |||
def get_page(self, name, sha='HEAD'): | |||
# commit = gittle.utils.git.commit_info(self.repo[sha]) | |||
name = self.cname_to_filename(name).encode('latin-1') | |||
@@ -151,5 +160,6 @@ class Wiki(): | |||
def get_history(self, name): | |||
return self.repo.file_history(self.cname_to_filename(name)) | |||
def cname_to_filename(self, cname): | |||
@staticmethod | |||
def cname_to_filename(cname): | |||
return cname.lower() + ".md" |
@@ -1,5 +0,0 @@ | |||
import realms | |||
c = realms.app.test_client() | |||
print c.get('/wiki/_create') | |||
print c.get('/wiki/_create/blah') |
@@ -1,15 +1,19 @@ | |||
from flask import g, render_template, request, redirect, Blueprint, flash, url_for | |||
from flask import g, render_template, request, redirect, Blueprint, flash, url_for, current_app | |||
from flask.ext.login import login_required | |||
from realms.lib.util import to_canonical, remove_ext | |||
from realms import config | |||
from realms.modules.wiki.models import Wiki | |||
from realms import config, current_user | |||
blueprint = Blueprint('wiki', __name__, url_prefix=config.RELATIVE_PATH) | |||
wiki = Wiki(config.WIKI_PATH) | |||
@blueprint.route("/_commit/<sha>/<name>") | |||
def commit(name, sha): | |||
cname = to_canonical(name) | |||
data = g.current_wiki.get_page(cname, sha=sha) | |||
data = wiki.get_page(cname, sha=sha) | |||
if data: | |||
return render_template('wiki/page.html', name=name, page=data, commit=sha) | |||
else: | |||
@@ -18,41 +22,42 @@ def commit(name, sha): | |||
@blueprint.route("/_compare/<name>/<regex('[^.]+'):fsha><regex('\.{2,3}'):dots><regex('.+'):lsha>") | |||
def compare(name, fsha, dots, lsha): | |||
diff = g.current_wiki.compare(name, fsha, lsha) | |||
diff = wiki.compare(name, fsha, lsha) | |||
return render_template('wiki/compare.html', name=name, diff=diff, old=fsha, new=lsha) | |||
@blueprint.route("/_revert", methods=['POST']) | |||
@login_required | |||
def revert(): | |||
if request.method == 'POST': | |||
name = request.form.get('name') | |||
commit = request.form.get('commit') | |||
cname = to_canonical(name) | |||
g.current_wiki.revert_page(name, commit, message="Reverting %s" % cname, | |||
username=g.current_user.get('username')) | |||
flash('Page reverted', 'success') | |||
return redirect(url_for('wiki.page', name=cname)) | |||
name = request.form.get('name') | |||
commit = request.form.get('commit') | |||
cname = to_canonical(name) | |||
wiki.revert_page(name, commit, message="Reverting %s" % cname, | |||
username=g.current_user.username) | |||
flash('Page reverted', 'success') | |||
return redirect(url_for('wiki.page', name=cname)) | |||
@blueprint.route("/_history/<name>") | |||
def history(name): | |||
history = g.current_wiki.get_history(name) | |||
history = wiki.get_history(name) | |||
return render_template('wiki/history.html', name=name, history=history, wiki_home=url_for('wiki.page')) | |||
@blueprint.route("/_edit/<name>", methods=['GET', 'POST']) | |||
@login_required | |||
def edit(name): | |||
data = g.current_wiki.get_page(name) | |||
data = wiki.get_page(name) | |||
cname = to_canonical(name) | |||
if request.method == 'POST': | |||
edit_cname = to_canonical(request.form['name']) | |||
if edit_cname.lower() != cname.lower(): | |||
g.current_wiki.rename_page(cname, edit_cname) | |||
wiki.rename_page(cname, edit_cname) | |||
g.current_wiki.write_page(edit_cname, | |||
request.form['content'], | |||
message=request.form['message'], | |||
username=g.current_user.get('username')) | |||
wiki.write_page(edit_cname, | |||
request.form['content'], | |||
message=request.form['message'], | |||
username=g.current_user.username) | |||
else: | |||
if data: | |||
name = remove_ext(data['name']) | |||
@@ -64,22 +69,24 @@ def edit(name): | |||
@blueprint.route("/_delete/<name>", methods=['POST']) | |||
@login_required | |||
def delete(name): | |||
pass | |||
@blueprint.route("/_create/", defaults={'name': None}, methods=['GET', 'POST']) | |||
@blueprint.route("/_create/<name>", methods=['GET', 'POST']) | |||
@login_required | |||
def create(name): | |||
if request.method == 'POST': | |||
g.current_wiki.write_page(request.form['name'], | |||
request.form['content'], | |||
message=request.form['message'], | |||
create=True, | |||
username=g.current_user.get('username')) | |||
wiki.write_page(request.form['name'], | |||
request.form['content'], | |||
message=request.form['message'], | |||
create=True, | |||
username=g.current_user.username) | |||
else: | |||
cname = to_canonical(name) if name else "" | |||
if cname and g.current_wiki.get_page(cname): | |||
if cname and wiki.get_page(cname): | |||
# Page exists, edit instead | |||
return redirect(url_for('wiki.edit', name=cname)) | |||
@@ -94,7 +101,7 @@ def page(name): | |||
if cname != name: | |||
return redirect(url_for('wiki.page', name=cname)) | |||
data = g.current_wiki.get_page(cname) | |||
data = wiki.get_page(cname) | |||
if data: | |||
return render_template('wiki/page.html', name=cname, page=data) | |||
@@ -145,14 +145,8 @@ body { | |||
border-radius: 2px; | |||
} | |||
.navbar-nav .user-avatar a { | |||
} | |||
.navbar-nav>li.user-avatar a { | |||
padding-top: 9px; | |||
padding-bottom: 9px; | |||
.navbar-nav>li.user-avatar img { | |||
margin-right: 3px; | |||
} | |||
.floating-header { | |||
@@ -169,4 +163,41 @@ body { | |||
/* background-image: -webkit-linear-gradient(top, #fff 0%,#fff 25%,rgba(255,255,255,0.9) 100%); */ | |||
/* background-image: linear-gradient(to bottom,#fff 0%,#fff 25%,rgba(255,255,255,0.9) 100%); */ | |||
font-size: 10px; | |||
} | |||
input.parsley-success, | |||
select.parsley-success, | |||
textarea.parsley-success { | |||
color: #468847; | |||
background-color: #DFF0D8; | |||
border: 1px solid #D6E9C6; | |||
} | |||
input.parsley-error, | |||
select.parsley-error, | |||
textarea.parsley-error { | |||
color: #B94A48; | |||
background-color: #F2DEDE; | |||
border: 1px solid #EED3D7; | |||
} | |||
.parsley-errors-list { | |||
margin: 2px 0 3px 0; | |||
padding: 0; | |||
list-style-type: none; | |||
font-size: 0.9em; | |||
line-height: 0.9em; | |||
opacity: 0; | |||
-moz-opacity: 0; | |||
-webkit-opacity: 0; | |||
transition: all .3s ease-in; | |||
-o-transition: all .3s ease-in; | |||
-ms-transition: all .3s ease-in; | |||
-moz-transition: all .3s ease-in; | |||
-webkit-transition: all .3s ease-in; | |||
} | |||
.parsley-errors-list.filled { | |||
opacity: 1; | |||
} |
@@ -1,18 +1,8 @@ | |||
{% extends 'layout.html' %} | |||
{% from 'macros.html' import render_form, render_field %} | |||
{% block body %} | |||
<form role="form" method="post" action="{{ url_for('auth.login') }}" data-parsley-validate> | |||
<div class="form-group"> | |||
<label for="email">Email</label> | |||
<input id="email" type="email" class="form-control" name="email" placeholder="Email" required /> | |||
</div> | |||
<div class="form-group"> | |||
<label for="password">Password</label> | |||
<input type="password" name="password" id="password" class="form-control" placeholder="Password" min="6" required /> | |||
</div> | |||
<input type="submit" class="btn btn-primary" value="Login" /> | |||
</form> | |||
{% endblock %} | |||
{% call render_form(form, action_url=url_for('auth.login'), action_text='Login', btn_class='btn btn-primary') %} | |||
{{ render_field(form.email, placeholder='Email', type='email', required=1) }} | |||
{{ render_field(form.password, placeholder='Password', type='password', required=1) }} | |||
{% endcall %} | |||
{% endblock %} |
@@ -1,29 +1,13 @@ | |||
{% extends 'layout.html' %} | |||
{% from 'macros.html' import render_form, render_field %} | |||
{% block body %} | |||
<form role="form" method="post" action="{{ url_for('auth.register') }}" data-parsley-validate> | |||
<div class="form-group"> | |||
<label for="username">Username</label> | |||
<input id="username" type="text" class="form-control" name="username" placeholder="Username" required data-parsley-type="alphanum" /> | |||
</div> | |||
<div class="form-group"> | |||
<label for="email">Email</label> | |||
<input id="email" type="email" class="form-control" name="email" placeholder="Email" required /> | |||
</div> | |||
<div class="form-group"> | |||
<label for="password">Password</label> | |||
<input type="password" name="password" id="password" class="form-control" placeholder="Password" required min="6"/> | |||
</div> | |||
<div class="form-group"> | |||
<label for="password_again">Confirm Password</label> | |||
<input type="password" name="password_again" id="password_again" class="form-control" placeholder="Password" required min="6"/> | |||
</div> | |||
<input type="submit" class="btn btn-primary" value="Register" /> | |||
</form> | |||
{% call render_form(form, action_url=url_for('auth.register'), action_text='Register', btn_class='btn btn-primary') %} | |||
{{ render_field(form.username, placeholder='Username', type='username', **{"required": 1, "data-parsley-type": "alphanum"}) }} | |||
{{ render_field(form.email, placeholder='Email', type='email', required=1) }} | |||
{{ render_field(form.password, placeholder='Password', type='password', **{"required": 1, "data-parsley-minlength": "6"}) }} | |||
{{ render_field(form.confirm, placeholder='Confirm Password', type='password', **{"required": 1, "data-parsley-minlength": "6"}) }} | |||
{% if config.RECAPTCHA_ENABLE %} | |||
{{ render_field(form.recaptcha) }} | |||
{% endif %} | |||
{% endcall %} | |||
{% endblock %} |
@@ -0,0 +1,5 @@ | |||
{% extends 'layout.html' %} | |||
{% from 'macros.html' import render_form, render_field %} | |||
{% block body %} | |||
{% endblock %} |
@@ -43,16 +43,16 @@ | |||
{% endif %} | |||
</ul> | |||
<ul class="nav navbar-nav navbar-right"> | |||
{% if session.get('user') %} | |||
{% if g.current_user.is_authenticated() %} | |||
<li class="dropdown user-avatar"> | |||
<a href="#" class="dropdown-toggle" data-toggle="dropdown"> | |||
<span> | |||
<img src="{{ session['user'].get('avatar') }}" class="menu-avatar"> | |||
<span>{{ session['user'].get('username') }} <i class="icon-caret-down"></i></span> | |||
<img src="{{ g.current_user.avatar }}" class="menu-avatar"> | |||
<span>{{ g.current_user.username }} <i class="icon-caret-down"></i></span> | |||
</span> | |||
</a> | |||
<ul class="dropdown-menu"> | |||
<li><a href="{{ url_for('account') }}">Account</a></li> | |||
<li><a href="{{ url_for('auth.settings') }}">Settings</a></li> | |||
<li><a href="{{ url_for('auth.logout') }}">Logout</a></li> | |||
</ul> | |||
</li> | |||
@@ -68,8 +68,6 @@ | |||
<!-- Page Menu --> | |||
<div class="container"> | |||
<div id="main-body"> | |||
{% with messages = get_flashed_messages(with_categories=True) %} | |||
@@ -0,0 +1,99 @@ | |||
{# Source: https://gist.github.com/bearz/7394681 #} | |||
{# Renders field for bootstrap 3 standards. | |||
Params: | |||
field - WTForm field | |||
kwargs - pass any arguments you want in order to put them into the html attributes. | |||
There are few exceptions: for - for_, class - class_, class__ - class_ | |||
Example usage: | |||
{{ macros.render_field(form.email, placeholder='Input email', type='email') }} | |||
#} | |||
{% macro render_field(field, label_visible=true) -%} | |||
<div class="form-group {% if field.errors %}has-error{% endif %} {{ kwargs.pop('class_', '') }}"> | |||
{% if (field.type != 'HiddenField' or field.type !='CSRFTokenField') and label_visible %} | |||
<label for="{{ field.id }}" class="control-label">{{ field.label }}</label> | |||
{% endif %} | |||
{{ field(class_='form-control', **kwargs) }} | |||
{% if field.errors %} | |||
{% for e in field.errors %} | |||
<p class="help-block">{{ e }}</p> | |||
{% endfor %} | |||
{% endif %} | |||
</div> | |||
{%- endmacro %} | |||
{# Renders checkbox fields since they are represented differently in bootstrap | |||
Params: | |||
field - WTForm field (there are no check, but you should put here only BooleanField. | |||
kwargs - pass any arguments you want in order to put them into the html attributes. | |||
There are few exceptions: for - for_, class - class_, class__ - class_ | |||
Example usage: | |||
{{ macros.render_checkbox_field(form.remember_me) }} | |||
#} | |||
{% macro render_checkbox_field(field) -%} | |||
<div class="checkbox"> | |||
<label> | |||
{{ field(type='checkbox', **kwargs) }} {{ field.label }} | |||
</label> | |||
</div> | |||
{%- endmacro %} | |||
{# Renders radio field | |||
Params: | |||
field - WTForm field (there are no check, but you should put here only BooleanField. | |||
kwargs - pass any arguments you want in order to put them into the html attributes. | |||
There are few exceptions: for - for_, class - class_, class__ - class_ | |||
Example usage: | |||
{{ macros.render_radio_field(form.answers) }} | |||
#} | |||
{% macro render_radio_field(field) -%} | |||
{% for value, label, _ in field.iter_choices() %} | |||
<div class="radio"> | |||
<label> | |||
<input type="radio" name="{{ field.id }}" id="{{ field.id }}" value="{{ value }}">{{ label }} | |||
</label> | |||
</div> | |||
{% endfor %} | |||
{%- endmacro %} | |||
{# Renders WTForm in bootstrap way. There are two ways to call function: | |||
- as macros: it will render all field forms using cycle to iterate over them | |||
- as call: it will insert form fields as you specify: | |||
e.g. {% call macros.render_form(form, action_url=url_for('login_view'), action_text='Login', | |||
class_='login-form') %} | |||
{{ macros.render_field(form.email, placeholder='Input email', type='email') }} | |||
{{ macros.render_field(form.password, placeholder='Input password', type='password') }} | |||
{{ macros.render_checkbox_field(form.remember_me, type='checkbox') }} | |||
{% endcall %} | |||
Params: | |||
form - WTForm class | |||
action_url - url where to submit this form | |||
action_text - text of submit button | |||
class_ - sets a class for form | |||
#} | |||
{% macro render_form(form, action_url='', action_text='Submit', class_='', btn_class='btn btn-default') -%} | |||
<form method="POST" action="{{ action_url }}" role="form" class="{{ class_ }}" data-parsley-validate> | |||
{{ form.hidden_tag() if form.hidden_tag }} | |||
{% if caller %} | |||
{{ caller() }} | |||
{% else %} | |||
{% for f in form %} | |||
{% if f.type == 'BooleanField' %} | |||
{{ render_checkbox_field(f) }} | |||
{% elif f.type == 'RadioField' %} | |||
{{ render_radio_field(f) }} | |||
{% else %} | |||
{{ render_field(f) }} | |||
{% endif %} | |||
{% endfor %} | |||
{% endif %} | |||
<button type="submit" class="{{ btn_class }}">{{ action_text }} </button> | |||
</form> | |||
{%- endmacro %} |
@@ -1,8 +1,10 @@ | |||
bcrypt | |||
Flask | |||
Flask-Login | |||
Flask-Assets | |||
Flask-Cache | |||
Flask-Login | |||
Flask-Script | |||
Flask-SQLAlchemy | |||
Flask-WTF | |||
beautifulsoup4 | |||
closure==20121212 | |||