Verified Commit 2588927b authored by Raoul Snyman's avatar Raoul Snyman
Browse files

Initial commit

parents
__pycache__
build
dist
.venv
*.conf
*.egg-info
This diff is collapsed.
include LICENSE
recursive-include cornerstone *.py *.html *.json
CornerstoneCMS
==============
CornerstoneCMS is a really simple content management system for churches. It has only two components to it, pages and
sermons. Sermons are hosted on Simplecast.
Installing
----------
Install and update using `pip`_::
$ pip install -U CornerstoneCMS
Set up
------
To set up CornerstoneCMS for your site, you can either manually create a configuration file, or run a configuration
wizard.
Configuration wizard
~~~~~~~~~~~~~~~~~~~~
CornerstoneCMS comes with a short configuration wizard which will create a configuration file for you::
$ python -m cornerstone.conf
Manual configuration
~~~~~~~~~~~~~~~~~~~~
Set up CornerstoneCMS by creating a configuration file like ``cornerstone.conf``:
.. code-block:: ini
[flask]
secret_key = <create a secret for sessions etc>
[sqlalchemy]
database_uri = sqlite:///cornerstone.sqlite
[cornerstone]
title = My Church Name
Deploying to Production
-----------------------
CornerstoneCMS is a WSGI application, and needs to be deployed to a WSGI server. Create a file called ``wsgi.py`` and
point your WSGI server to the file.
.. code-block:: python
from cornerstone.app import create_app
application = create_app('/path/to/yourfile.conf')
Links
-----
* Website: https://cornerstonecms.org/
* Documentation: https://superfly.gitlab.io/cornerstonecms
* License: https://gitlab.com/superfly/cornerstonecms/blob/master/LICENSE
* Issue tracker: https://gitlab.com/superfly/cornerstonecms/issues
[flask]
secret_key = ${secretkey}
[sqlalchemy]
database_uri = mysql://${dbuser}:${dbpass}@${dbhost}/${dbname}
[cornerstone]
title = ${sitename}
__version__ = '0.1.0'
import re
from unidecode import unidecode
from flask import redirect, url_for, request
from flask_admin import Admin, AdminIndexView, BaseView, expose
from flask_admin.contrib.sqla import ModelView
from flask_user import current_user
from wtforms import TextAreaField, PasswordField
from wtforms.fields.html5 import IntegerField
from wtforms.widgets import TextArea
from cornerstone.models import User, Page, Sermon, Preacher, session
from cornerstone.settings import get_all_settings, has_setting, save_setting
def _create_slug(title):
"""
Convert the title to a slug
"""
return re.sub(r'\W+', '-', unidecode(title).lower()).strip('-')
class CKTextAreaWidget(TextArea):
def __call__(self, field, **kwargs):
if kwargs.get('class'):
kwargs['class'] += ' ckeditor'
else:
kwargs.setdefault('class', 'ckeditor')
return super(CKTextAreaWidget, self).__call__(field, **kwargs)
class CKTextAreaField(TextAreaField):
widget = CKTextAreaWidget()
class AuthorizedMixin(object):
def is_accessible(self):
return current_user.is_active and current_user.is_authenticated
def inaccessible_callback(self, name, **kwargs):
if current_user.is_authenticated:
return redirect(url_for('/'))
else:
return redirect(url_for('user.login', next=request.url))
class AuthorizedAdminIndexView(AuthorizedMixin, AdminIndexView):
pass
class AuthorizedModelView(AuthorizedMixin, ModelView):
extra_js = ['//cdn.ckeditor.com/4.11.4/full/ckeditor.js']
column_exclude_list = ('password',)
column_descriptions = {
'weight': 'Use this to order items in the menu'
}
form_excluded_columns = ('slug',)
form_overrides = {
'password': PasswordField,
'weight': IntegerField
}
def on_model_change(self, form, model, is_create):
if isinstance(model, Page):
model.slug = _create_slug(model.title)
class SettingsView(AuthorizedMixin, BaseView):
@expose('/', methods=['GET'])
def index(self):
settings = get_all_settings()
return self.render('admin/settings.html', settings=settings)
@expose('/', methods=['POST'])
def index_post(self):
for key, value in request.form.items():
if has_setting(key):
save_setting(key, value)
return redirect(self.get_url('settings.index'))
admin = Admin(name='CornerstoneCMS', template_mode='bootstrap4', index_view=AuthorizedAdminIndexView())
admin.add_view(AuthorizedModelView(Page, session, name='Pages'))
admin.add_view(AuthorizedModelView(Sermon, session, name='Sermons'))
admin.add_view(AuthorizedModelView(Preacher, session, name='Preachers'))
admin.add_view(AuthorizedModelView(User, session, name='Users'))
admin.add_view(SettingsView(name='Settings', endpoint='settings'))
import os
from flask import Flask
from flask_admin.menu import MenuLink
from flask_themes2 import Themes, packaged_themes_loader, theme_paths_loader
from flask_user import UserManager
from cornerstone.admin import admin
from cornerstone.config import config_from_file
from cornerstone.models import User, db, setup_db
from cornerstone.theming import get_themes_loader
from cornerstone.views.home import home
from cornerstone.views.pages import pages
from cornerstone.views.sermons import sermons
from cornerstone.views.uploads import uploads
def _resolve_themes_directory(config_file):
"""
Resolve the themes directory from the config file
"""
return os.path.join(os.path.dirname(os.path.abspath(config_file)), 'themes')
def create_app(config_file):
app = Flask('cornerstone')
config_from_file(app, config_file)
# Set up themes, making sure to use a local themes folder if it exists
loaders = [packaged_themes_loader, theme_paths_loader]
themes_directory = _resolve_themes_directory(config_file)
if os.path.exists(themes_directory):
local_themes_loader = get_themes_loader(themes_directory)
loaders.insert(0, local_themes_loader)
Themes(app, app_identifier='cornerstone', loaders=loaders)
# Initialise various other parts of the application
db.init_app(app)
admin.init_app(app)
UserManager(app, db, User)
# Register blueprints
app.register_blueprint(home)
app.register_blueprint(pages)
app.register_blueprint(sermons)
app.register_blueprint(uploads)
with app.app_context():
setup_db(app)
# Set up menu shortcuts
admin.add_link(MenuLink('Back to main site', '/'))
admin.add_link(MenuLink('Logout', '/user/sign-out'))
return app
import json
from pathlib import Path
from configparser import ConfigParser
import yaml
try:
from yaml import CLoader as Loader
except ImportError:
from yaml import Loader
BOOLEAN_VALUES = ['yes', 'true', 'on', 'no', 'false', 'off']
DEFAULT_CONFIG = {
# SQLAlchemy
'SQLALCHEMY_DATABASE_URI': 'sqlite:///cornerstonecms.sqlite',
'SQLALCHEMY_TRACK_MODIFICATIONS': False,
# User settings
'USER_ENABLE_EMAIL': False,
'USER_ENABLE_USERNAME': False,
'USER_REQUIRE_RETYPE_PASSWORD': True,
# CornerstoneCMS settings
'CORNERSTONE_TITLE': 'ZineMan',
'CORNERSTONE_SWATCH': 'default',
'CORNERSTONE_SUPERUSER': {
'email': 'info@example.com',
'name': 'Superuser',
'password': 'P@ssw0rd'
}
}
def _load_from_ini_file(filename):
"""
Load from a config file
"""
config = {}
parser = ConfigParser()
parser.read(str(filename))
for section in parser.sections():
for option in parser.options(section):
# Get the value, skip it if it is blank
string_value = parser.get(section, option)
if not string_value:
continue
# Try to figure out what type it is
if string_value.isnumeric() and '.' in string_value:
value = parser.getfloat(section, option)
elif string_value.isnumeric():
value = parser.getint(section, option)
elif string_value.lower() in BOOLEAN_VALUES:
value = parser.getboolean(section, option)
elif string_value.startswith('{'):
# Try to load string values beginning with '{' as JSON
try:
value = json.loads(string_value)
except ValueError:
# If this is not JSON, just use the string
value = string_value
else:
value = string_value
# Set up the configuration key
if section == 'flask':
# Options in the flask section don't need FLASK_*
key = option.upper()
else:
key = '{}_{}'.format(section, option).upper()
# Save this into our config dictionary
config[key] = value
return config
def _load_from_yaml_file(filename):
"""
Load the configuration from a yaml file
"""
with filename.open() as yaml_file:
config = yaml.load(yaml_file.read(), Loader=Loader)
return config
def _load_from_json_file(filename):
"""
Load the Flask configuration from a config file
"""
with filename.open() as json_file:
config = json.loads(json_file.read())
return config
def config_from_file(app, filename):
"""
Load configuration from a file
"""
# Get the default configuration
config = dict(**DEFAULT_CONFIG)
# Set up the filename
filename = Path(filename)
if not filename.exists:
# log a warning
print('No config file found')
return
# Load from the file based on the extension
if filename.suffix in ['.ini', '.conf', '.cfg']:
config.update(_load_from_ini_file(filename))
elif filename.suffix in ['.yaml', '.yml']:
config.update(_load_from_yaml_file(filename))
elif filename.suffix in ['.json']:
config.update(_load_from_json_file(filename))
# Set the app name everywhere it is needed so that the deployer doesn't need to
config['USER_APP_NAME'] = config['CORNERSTONE_TITLE']
config['USER_SWATCH_THEME'] = config['CORNERSTONE_SWATCH']
config['FLASK_ADMIN_SWATCH'] = config['CORNERSTONE_SWATCH']
app.config.update(config)
from flask_sqlalchemy import SQLAlchemy
from flask_user import UserMixin
db = SQLAlchemy()
session = db.session
def get_or_create(session, model, **kwargs):
instance = model.query.filter_by(**kwargs).first()
if not instance:
instance = model(**kwargs)
session.add(instance)
return instance
sermons_topics = db.Table(
'sermons_topics',
db.Column('sermon_id', db.Integer, db.ForeignKey('sermons.id'), primary_key=True),
db.Column('topic_id', db.Integer, db.ForeignKey('topics.id'), primary_key=True)
)
class User(db.Model, UserMixin):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(255), nullable=False)
email = db.Column(db.String(255), nullable=False, unique=True)
password = db.Column(db.String(255), nullable=False, server_default='')
active = db.Column('is_active', db.Boolean(), nullable=False, server_default='1')
def __repr__(self):
return self.name
class Sermon(db.Model):
__tablename__ = 'sermons'
id = db.Column(db.Integer, primary_key=True)
preacher_id = db.Column(db.Integer, db.ForeignKey('preachers.id'))
title = db.Column(db.String(255), nullable=False)
scripture = db.Column(db.String(255), nullable=False)
simplecast_id = db.Column(db.String(50))
date = db.Column(db.Date, nullable=False)
preacher = db.relationship('Preacher', lazy='subquery', backref=db.backref('sermons', lazy=True))
topics = db.relationship('Topic', secondary=sermons_topics, lazy='subquery',
backref=db.backref('sermons', lazy=True))
def __repr__(self):
return self.title
class Preacher(db.Model):
__tablename__ = 'preachers'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(255), nullable=False)
def __repr__(self):
return self.name
class Topic(db.Model):
__tablename__ = 'topics'
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(255), nullable=False)
class Page(db.Model):
__tablename__ = 'pages'
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(255), nullable=False)
slug = db.Column(db.String(255), nullable=False)
body = db.Column(db.Text)
weight = db.Column(db.Integer, default=0)
def __repr__(self):
return self.title
class Setting(db.Model):
__tablename__ = 'settings'
key = db.Column(db.String(255), primary_key=True)
title = db.Column(db.String(255), nullable=False)
group = db.Column(db.String(255), default='core')
value = db.Column(db.Text)
type = db.Column(db.String(20), nullable=False)
allowed_values = db.Column(db.Text, default='None')
class MenuLink(db.Model):
__tablename__ = 'menulinks'
id = db.Column(db.Integer, primary_key=True, )
title = db.Column(db.String(255), nullable=False)
url = db.Column(db.String(255), nullable=False)
weight = db.Column(db.Integer, default=0)
def setup_db(app):
"""
Set up the database
"""
# Need this to prevent a circular import
from cornerstone.settings import add_setting, has_setting, save_setting
# Create the tables
db.create_all()
# Optionally create a superuser
if app.config.get('CORNERSTONE_SUPERUSER', None) and app.config['CORNERSTONE_SUPERUSER'].get('email', None):
superuser = app.config['CORNERSTONE_SUPERUSER']
if not User.query.filter(User.email == superuser['email']).first():
user = User(
name=superuser.get('name', 'Superuser'),
email=superuser['email'],
password=app.user_manager.hash_password(superuser.get('password', 'Password1')),
)
db.session.add(user)
# Create the home page, if it doesn't exist
index_page = get_or_create(db.session, Page, slug='home')
if not index_page.title and not index_page.body:
index_page.title = 'Home'
index_page.body = '<h1>Home</h1><p>This is the home page. Edit it and replace this content with your own.</p>'
db.session.add(index_page)
# Add some settings, if they don't already exist
if not has_setting('sermons-on-home-page'):
add_setting('Show sermons on the home page', 'sermons-on-home-page', 'bool', 'home page')
save_setting('sermons-on-home-page', False)
add_setting('Number of sermons to show on the home page', 'sermons-home-page-count', 'int', 'home page')
save_setting('sermons-home-page-count', 10)
if not has_setting('pages-in-menu'):
add_setting('Include pages in menu automatically', 'pages-in-menu', 'bool', 'menu')
save_setting('pages-in-menu', True)
if not has_setting('theme'):
add_setting('Theme', 'theme', 'str', 'theme')
save_setting('theme', 'bootstrap4')
db.session.commit()
from serpent import dumps, loads
from cornerstone.models import Setting, db
def has_setting(key):
"""
Check if a setting exists
:param key: The key of the setting
"""
return Setting.query.get(key) is not None
def add_setting(title, key, type_, group='core', allowed_values=None):
"""
Add a setting
:param title: The visible title of the setting
:param key: The unique key used to look up the setting in the database
:param type_: The type of this setting. Can be one of "bool", "int", "str".
:param allowed_values: Restrict values to only those in this list (renders as a dropdown)
"""
setting = Setting(title=title, key=key, type=type_, group=group, allowed_values=dumps(allowed_values))
db.session.add(setting)
db.session.commit()
return setting
def get_all_settings():
"""
Get all the settings
"""
grouped_settings = {}
settings = Setting.query.all()
for setting in settings:
setting.value = loads(setting.value if isinstance(setting.value, bytes) else setting.value.encode('utf8'))
setting.allowed_values = loads(setting.allowed_values if isinstance(setting.allowed_values, bytes)
else setting.allowed_values.encode('utf8'))
try:
grouped_settings[setting.group].append(setting)
except KeyError:
grouped_settings[setting.group] = [setting]
return grouped_settings
def get_setting(key, default=None):
"""
Get a setting
"""
setting = Setting.query.get(key)
if not setting:
return default
return loads(setting.value if isinstance(setting.value, bytes) else setting.value.encode('utf8'))
def save_setting(key, value):
setting = Setting.query.get(key)
if not setting:
raise Exception('Cannot save setting without running add_setting: {}'.format(key))
setting.value = dumps(value)
db.session.add(setting)
db.session.commit()
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
{% extends 'admin/master.html' %}
{% block body %}
<article class="container" style="margin-top: 10px;">
<h2 class="h2">Settings</h2>
<form role="form" method="post">
{% for group, group_settings in settings.items() %}
<div class="card mb-3">
<div class="card-header">{{ group.title() }}</div>
<div class="card-body">
{% for setting in group_settings %}
<div class="form-group {% if setting.type == 'bool' %}custom-control custom-switch{% endif %}">
{% if setting.type == 'bool' %}
<input type="checkbox" class="custom-control-input" id="input-{{ setting.key }}" name="{{ setting.key }}" {% if setting.value %}checked{% endif %}>
<label for="input-{{ setting.key }}" class="custom-control-label">{{ setting.title }}</label>
{% else %}
<label for="input-{{ setting.key }}">{{ setting.title }}</label>
{% if setting.allowed_values %}
<select name="{{ setting.key }}" id="input-{{ setting.key }}" class="form-control">
{% for val in setting.allowed_values %}
<option value="{{ val }}" {% if val == setting.value %}selected{% endif %}>{{ val }}</option>
{% endfor %}
</select>
{% elif setting.type == "int" %}
<input type="number" name="{{ setting.key }}" id="input-{{ setting.key }}" class="form-control" value="{{ setting.value }}">
{% else %}
<input type="text" name="{{ setting.key }}" id="input-{{ setting.key }}" class="form-control" value="{{ setting.value }}">
{% endif %}
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endfor %}
<div class="mt-3">
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</article>
{% endblock %}
<!DOCTYPE html>
<html lang="en" class="h-100">
<head>
<meta charset="utf-8">
<title>{% if page %}{{ page.title }} | {% endif %}{{config.get('CORNERSTONE_TITLE', 'CornerstoneCMS')}}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
<style>
main {
padding-top: 70px;
padding-bottom: 15px;
}
.footer {
background-color: #f5f5f5;
}