Initial import

This commit is contained in:
Raoul Snyman 2024-11-19 19:46:48 -06:00
commit d74460f075
38 changed files with 12700 additions and 0 deletions

16
.editorconfig Normal file
View File

@ -0,0 +1,16 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
[*.{js,py,css,html,json,yaml}]
charset = utf-8
indent_style = space
max_line_length = 120
[*.{js,html,css,json,yaml}]
indent_size = 2
[*.py]
indent_size = 4

2
.flake8 Normal file
View File

@ -0,0 +1,2 @@
[flake8]
max_line_length = 120

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
__pycache__
build
dist

18
CHANGES.rst Normal file
View File

@ -0,0 +1,18 @@
.. _changelog:
Changelog
==========
v0.1.0
------
Initial release of ScribeEngine.
Features
~~~~~~~~
- Generic "node" content type for building content types
- Dynamic template resolution for custom nodes
- URL slugs built in
- Edit pages directly
- Comes pre-configured by default with blogs, pages, comments

22
LICENSE Normal file
View File

@ -0,0 +1,22 @@
Copyright 2022 Raoul Snyman
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.

39
README.rst Normal file
View File

@ -0,0 +1,39 @@
ScribeEngine
============
ScribeEngine is a flexible open source content management system written in Python.
Get Started
-----------
The easiest way to get up and running is via a container system like Docker or podman. Use the
following compose file to get up and running:
.. code-block:: yaml
version: '3'
services:
web:
image: nginx
ports:
- 8080:80
depends_on:
- cms
cms:
image: scribeengine
depends_on:
- database
database:
image: postgres:14
Using Docker's Compose plugin, run the following command::
$ docker compose up -d
Using the Docker-Compose command::
$ docker-compose up -d
Using podman-compose::
$ podman-compose up -d

66
pyproject.toml Normal file
View File

@ -0,0 +1,66 @@
[build-system]
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"
[project]
name = "scribeengine"
description = "ScribeEngine is a flexible open source content management system written in Python"
readme = "README.rst"
requires-python = ">=3.10"
license = "MIT"
keywords = ["content management", "blog", "web site"]
authors = [
{ name = "Raoul Snyman", email = "raoul@snyman.info" }
]
classifiers = [
"Development Status :: 3 - Alpha",
"Environment :: Web Environment",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Topic :: Internet :: WWW/HTTP",
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
"Topic :: Internet :: WWW/HTTP :: Dynamic Content :: Content Management System"
]
dynamic = ["version"]
dependencies = [
"Hypercorn",
"python-dotenv",
"Quart",
"quart-auth",
"quart-bcrypt",
"quart-flask-patch",
"quart-sqlalchemy>=3.0",
"flask-theme"
]
[project.urls]
Homepage = "https://scribeengine.org"
Documentation = "https://docs.scribeengine.org"
Source = "https://git.snyman.info/raoul/scribeengine"
Issues = "https://git.snyman.info/raoul/scribeengine/issues"
[tool.hatch.version]
source = "vcs"
path = "src/scribeengine/__about__.py"
[tool.hatch.build.targets.wheel]
packages = ["src/scribeengine"]
[tool.hatch.envs.default]
dependencies = [
"pytest",
"pytest-watch",
"pytest-cov"
]
[tool.hatch.envs.default.scripts]
start = "hypercorn 'scribeengine.app:run()' {args}"
devserver = "python -m scribeengine {args}"
tests = "pytest --cov-report=term-missing --cov-config=pyproject.toml --cov=scribeengine --cov=tests {args}"
"tests:watch" = "ptw -- --cov scribeengine --cov-report html"

View File

@ -0,0 +1 @@
__version__ = '0.0.1'

View File

View File

@ -0,0 +1,12 @@
from argparse import ArgumentParser
from scribeengine.app import make_app
parser = ArgumentParser()
parser.add_argument('-b', '--bind', help='The IP addres to bind to')
parser.add_argument('-p', '--port', help='The port to use')
args = parser.parse_args()
app = make_app()
app.run(args.bind, args.port, debug=True)

29
src/scribeengine/app.py Normal file
View File

@ -0,0 +1,29 @@
import quart_flask_patch # noqa: F401
from quart import Quart
from flask_theme import setup_themes
# from scribeengine.bcrypt import bcrypt
from scribeengine.db.base import db
from scribeengine.settings import settings
from scribeengine.templating import register_globals
from scribeengine.views.admin import admin
from scribeengine.views.nodes import nodes
def make_app() -> Quart:
"""Create an application instance"""
app = Quart(__name__)
app.secret_key = settings.secret_key
app.config.update(settings.as_dict())
# bcrypt.init_app(app)
db.init_app(app)
db.create_all()
# QuartAuth(app)
register_globals(app)
setup_themes(app)
app.register_blueprint(admin)
app.register_blueprint(nodes)
return app

View File

View File

@ -0,0 +1,28 @@
from quart_sqlalchemy.config import SQLAlchemyConfig
from quart_sqlalchemy.framework import QuartSQLAlchemy
from scribeengine.settings import settings
_binds: dict[str, dict] = {
'default': {
'engine': {
'url': settings.database_url,
'echo': settings.database_echo
},
'session': {
'expire_on_commit': settings.database_expire_on_commit
}
}
}
if settings.database_url.startswith('sqlite'):
_binds['default']['engine']['connect_args'] = {
'check_same_thread': settings.database_check_same_thread
}
db = QuartSQLAlchemy(config=SQLAlchemyConfig(binds=_binds))
Model = db.Model
Session = db.bind.Session

View File

@ -0,0 +1,87 @@
from datetime import datetime
from sqlalchemy.orm import Mapped, mapped_column, object_session, relationship
from sqlalchemy.schema import ForeignKey, Table, Column
from sqlalchemy.sql.expression import select
from sqlalchemy.types import String, DateTime, Integer, Text
from scribeengine.db.base import Model
revisions_fields_table = Table(
'node_revisions_fields',
Model.metadata,
Column('node_revision_id', Integer, ForeignKey('node_revisions.id'), primary_key=True),
Column('node_field_instance_id', Integer, ForeignKey('node_field_instances.id'), primary_key=True)
)
class NodeType(Model):
"""A type of a content node"""
__tablename__ = 'node_types'
id: Mapped[int] = mapped_column(Integer, primary_key=True)
title: Mapped[str] = mapped_column(String(255), nullable=False)
slug: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
description: Mapped[str] = mapped_column(Text)
class NodeRevision(Model):
"""A revision of a content node"""
__tablename__ = 'node_revisions'
id: Mapped[int] = mapped_column(Integer, primary_key=True)
node_id: Mapped[int] = mapped_column(Integer, ForeignKey('nodes.id'))
revision: Mapped[int] = mapped_column(Integer, default=1)
fields: Mapped[list['NodeFieldInstance']] = relationship(secondary=revisions_fields_table,
back_populates='node_revision')
class Node(Model):
"""A content node"""
__tablename__ = 'nodes'
id: Mapped[int] = mapped_column(Integer, primary_key=True)
node_type_id: Mapped[int] = mapped_column(Integer, ForeignKey('node_types.id'))
title: Mapped[str] = mapped_column(String(255), nullable=False)
slug: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True)
created: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow())
modified: Mapped[datetime] = mapped_column(DateTime)
node_type: Mapped['NodeType'] = relationship()
revisions: Mapped[list['NodeRevision']] = relationship(back_populates='node')
@property
def latest_revision(self) -> NodeRevision | None:
session = object_session(self)
if not session:
return None
revision = session.scalars(
select(NodeRevision).where(NodeRevision.node_id == id).order_by(NodeRevision.revision.desc())
).first()
return revision
class NodeField(Model):
"""A field for a node"""
__tablename__ = 'node_fields'
id: Mapped[int] = mapped_column(Integer, primary_key=True)
node_type_id: Mapped[int] = mapped_column(Integer, ForeignKey('node_types.id'))
title: Mapped[str] = mapped_column(String(255), nullable=False)
slug: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True)
field_type: Mapped[str] = mapped_column(String(255))
node_type: Mapped['NodeType'] = relationship(back_populates='node_fields')
class NodeFieldInstance(Model):
"""An instance of a node's field"""
__tablename__ = 'node_field_instances'
id: Mapped[int] = mapped_column(Integer, primary_key=True)
node_field_id: Mapped[int] = mapped_column(Integer, ForeignKey('node_fields.id'))
value: Mapped[str] = mapped_column(Text)
node_field: Mapped['NodeField'] = relationship()

View File

@ -0,0 +1,52 @@
from enum import Enum
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.schema import ForeignKey
from sqlalchemy.types import String, Integer, Text
from scribeengine.db.base import Model
class SettingType(Enum):
"""The type of setting"""
string = 'string'
integer = 'integer'
float = 'float'
text = 'text'
boolean = 'boolean'
choice = 'choice'
class SettingKey(Model):
"""A model for the available settings. Plugins can add to these."""
__tablename__ = 'setting_keys'
id: Mapped[int] = mapped_column(Integer, primary_key=True)
slug: Mapped[str] = mapped_column(String(255), unique=True)
name: Mapped[str] = mapped_column(String(255))
description: Mapped[str] = mapped_column(Text)
type: Mapped[SettingType]
plugin: Mapped[str] = mapped_column(String(255))
valid_choices: Mapped[str] = mapped_column(Text)
class SettingValue(Model):
"""A model for an instance of a setting with its value"""
__tablename__ = 'settings_values'
id: Mapped[int] = mapped_column(Integer, primary_key=True)
value: Mapped[str] = mapped_column(Text)
type: Mapped[SettingType]
key_id: Mapped[int] = mapped_column(Integer, ForeignKey('setting_keys.id'))
key: Mapped['SettingKey'] = relationship()
def get_value(self) -> str | int | float | bool:
"""Return the value based on the type"""
if self.type == SettingType.integer:
return int(self.value)
if self.type == SettingType.float:
return float(self.value)
if self.type == SettingType.boolean:
return bool(self.value)
return self.value

View File

@ -0,0 +1,110 @@
from datetime import datetime
from logging import getLogger as get_logger
from quart_bcrypt import Bcrypt
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.orm.session import object_session
from sqlalchemy.schema import Column, Table, ForeignKey
from sqlalchemy.sql import select
from sqlalchemy.types import String, DateTime, Integer, Boolean, Text
from scribeengine.db.base import Model
log = get_logger(__name__)
permissions_roles_table = Table(
'permissions_roles',
Model.metadata,
Column('permission_id', Integer, ForeignKey('permissions.id'), primary_key=True),
Column('role_id', Integer, ForeignKey('roles.id'), primary_key=True)
)
roles_users_table = Table(
'roles_users',
Model.metadata,
Column('role_id', Integer, ForeignKey('roles.id'), primary_key=True),
Column('user', Integer, ForeignKey('users.id'), primary_key=True)
)
class Permission(Model):
"""A model of the permissions"""
__tablename__ = 'permissions'
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str] = mapped_column(Text)
slug: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True)
roles: Mapped[list['Role']] = relationship(secondary=permissions_roles_table,
back_populates='permissions')
class Role(Model):
"""A model for the roles"""
__tablename__ = 'roles'
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str] = mapped_column(Text)
permissions: Mapped[list['Permission']] = relationship(secondary=permissions_roles_table,
back_populates='roles')
users: Mapped[list['User']] = relationship(secondary=roles_users_table,
back_populates='roles')
class User(Model):
"""A model of the user object"""
__tablename__ = 'users'
id: Mapped[int] = mapped_column(Integer, primary_key=True)
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True)
_password: Mapped[str] = mapped_column('password', String(255))
is_active: Mapped[bool] = mapped_column(Boolean, default=False)
activation_code: Mapped[str] = mapped_column(String(255))
created: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow())
modified: Mapped[datetime] = mapped_column(DateTime)
roles: Mapped[list['Role']] = relationship(secondary=roles_users_table,
back_populates='users')
@hybrid_property
def password(self) -> str:
return self._password
@password.setter
def set_password(self, value: str):
bcrypt = Bcrypt()
self._password = bcrypt.generate_password_hash(value).decode("utf8")
@property
def is_authenticated(self) -> bool:
"""Return True or False depending on if the user is authenticated"""
return True
@property
def is_anonymous(self) -> bool:
"""Return True or False depending on if the user is an anonymous user (i.e. not logged in)"""
return False
def get_id(self) -> str:
"""Return a unique identifier for the user as a string"""
return str(self.id)
def check_password(self, plaintext: str) -> bool:
bcrypt = Bcrypt()
return bcrypt.check_password_hash(self.password, plaintext)
def has_permission(self, permission: str) -> bool:
"""Determine if the user has this permission"""
session = object_session(self)
if not session:
log.warning('No session found for user {self.id} while checking permissions')
return False
perms = session.scalars(select(Permission).join(Permission.roles).join(Role.users).
where(Permission.slug == permission, User.id == self.id)).all()
return len(perms) > 0

View File

@ -0,0 +1,52 @@
import os
from inspect import getmembers, isfunction, ismethod
from dotenv import load_dotenv
class Settings:
"""Settings"""
database_url: str = 'sqlite://'
database_echo: bool = False
database_check_same_thread: bool = False
database_expire_on_commit: bool = False
secret_key: str = ''
uploads_folder: str = 'uploads'
base_url: str = 'http://localhost:5000'
mail_hostname: str = '127.0.0.1'
mail_port: int = 465
mail_from: str = ''
mail_username: str = ''
mail_password: str = ''
mail_security: str = 'tls'
def __init__(self):
load_dotenv()
for name, value in getmembers(self):
if name.startswith('_') or isfunction(value) or ismethod(value):
continue
if os.environ.get(name.upper()):
setattr(self, name, self._smart_cast(os.environ[name.upper()]))
for key, value in os.environ.items():
if key.startswith('QUART_'):
setattr(self, key.lower(), self._smart_cast(value))
def as_dict(self) -> dict:
"""Return this as a dictionary"""
config = {}
for name, value in getmembers(self):
if name.startswith('_') or isfunction(value) or ismethod(value):
continue
config[name.upper()] = getattr(self, name)
return config
def _smart_cast(self, value: str) -> int | bool | str:
"""Type cast some values"""
if value.isdigit():
return int(value)
elif value.lower() in ['true', 'false']:
return value.lower() == 'true'
return value
settings = Settings()

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,22 @@
/*!
* Start Bootstrap - SB Admin v7.0.5 (https://startbootstrap.com/template/sb-admin)
* Copyright 2013-2022 Start Bootstrap
* Licensed under MIT (https://github.com/StartBootstrap/startbootstrap-sb-admin/blob/master/LICENSE)
*/
//
// Scripts
//
window.addEventListener('DOMContentLoaded', event => {
// Toggle the side navigation
const sidebarToggle = document.body.querySelector('#sidebarToggle');
if (sidebarToggle) {
if (localStorage.getItem('sb|sidebar-toggle') === 'true') {
document.body.classList.toggle('sb-sidenav-toggled');
}
sidebarToggle.addEventListener('click', event => {
event.preventDefault();
document.body.classList.toggle('sb-sidenav-toggled');
localStorage.setItem('sb|sidebar-toggle', document.body.classList.contains('sb-sidenav-toggled'));
});
}
});

View File

@ -0,0 +1,87 @@
{% import "admin/macros.html" as macros %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<meta name="description" content="" />
<meta name="author" content="" />
<title>{% block title %}{% endblock %} - ScribeEngine</title>
<link href="{{url_for('static', filename='css/admin.css')}}" rel="stylesheet" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.9.1/font/bootstrap-icons.css">
</head>
<body class="sb-nav-fixed">
<nav class="sb-topnav navbar navbar-expand navbar-dark bg-dark">
<a class="navbar-brand ps-3" href="{{url_for('admin.index')}}">ScribeEngine</a>
<button class="btn btn-link btn-sm order-1 order-lg-0 me-4 me-lg-0" id="sidebarToggle" href="#!">
<i class="bi bi-list bi-bold"></i>
</button>
<form class="d-none d-md-inline-block form-inline ms-auto me-0 me-md-3 my-2 my-md-0">
<div class="input-group">
<input class="form-control" type="text" placeholder="Search for..." aria-label="Search for..." aria-describedby="btnNavbarSearch" />
<button class="btn btn-primary" id="btnNavbarSearch" type="button">
<i class="bi bi-search"></i>
</button>
</div>
</form>
<!-- Navbar-->
<ul class="navbar-nav ms-auto ms-md-0 me-3 me-lg-4">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" id="navbarDropdown" href="#" role="button"
data-bs-toggle="dropdown" aria-expanded="false"><i class="bi bi-person bi-fw"></i></a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="navbarDropdown">
<li><a class="dropdown-item" href="#!">Settings</a></li>
<li><a class="dropdown-item" href="#!">Activity Log</a></li>
<li><hr class="dropdown-divider" /></li>
<li><a class="dropdown-item" href="#!">Logout</a></li>
</ul>
</li>
</ul>
</nav>
<div id="layoutSidenav">
<div id="layoutSidenav_nav">
<nav class="sb-sidenav accordion sb-sidenav-dark" id="sidenavAccordion">
<div class="sb-sidenav-menu">
<div class="nav">
<div class="sb-sidenav-menu-heading">Core</div>
<a class="nav-link" href="{{url_for('admin.index')}}">
<div class="sb-nav-link-icon"><i class="bi bi-speedometer"></i></div>
Dashboard
</a>
<div class="sb-sidenav-menu-heading">Content</div>
{{ macros.submenu('Blog', 'pen-fill', [(url_for('admin.posts.new_post'), 'New post'), (url_for('admin.posts.list_posts'), 'All posts')]) }}
{{ macros.submenu('Pages', 'book-fill', [(url_for('admin.pages.new_page'), 'New page'), (url_for('admin.pages.list_pages'), 'All pages')]) }}
{{ macros.submenu('Comments', 'chat-fill', [(url_for('admin.pages.new_page'), 'Awaiting approval'), (url_for('admin.pages.list_pages'), 'All pages')]) }}
<div class="sb-sidenav-menu-heading">Administration</div>
{{ macros.submenu('Users', 'people-fill', [(url_for('admin.users.new_user'), 'New user'), (url_for('admin.users.list_users'), 'All users')]) }}
</div>
</div>
<div class="sb-sidenav-footer">
<div class="small">Logged in as:</div>
Start Bootstrap
</div>
</nav>
</div>
<div id="layoutSidenav_content">
<main>
{% block content %}{% endblock %}
</main>
<footer class="py-4 bg-light mt-auto">
<div class="container-fluid px-4">
<div class="d-flex align-items-center justify-content-between small">
<div class="text-muted">Copyright &copy; Your Website 2022</div>
<div>
<a href="#">Privacy Policy</a>
&middot;
<a href="#">Terms &amp; Conditions</a>
</div>
</div>
</div>
</footer>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js" crossorigin="anonymous"></script>
<script src="{{url_for('static', filename='js/admin.js')}}"></script>
</body>
</html>

View File

@ -0,0 +1,24 @@
{% extends "admin/base.html" %}
{% block title %}Blog > All posts{% endblock %}
{% block content %}
<div class="container-fluid px-4">
<h1 class="mt-4">All posts</h1>
{{macros.breadcrumb(['Blog', 'All posts'])}}
<table role="table" class="table table-striped">
<thead>
<tr>
<th>Title</th>
<th>Published</th>
</tr>
</thead>
<tbody>
{%- for post in posts %}
<tr>
<td>{{post.title}}</td>
<td>{{post.published_date}}</td>
</tr>
{% endfor -%}
</tbody>
</table>
</div>
{% endblock content %}

View File

@ -0,0 +1,7 @@
{% extends "admin/base.html" %}
{% block title %}Dashboard{% endblock %}
{% block content %}
<div class="container-fluid px-4">
<h1 class="mt-4">Dashboard</h1>
</div>
{% endblock content %}

View File

@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<meta name="description" content="" />
<meta name="author" content="" />
<title>Login - SB Admin</title>
<link href="css/styles.css" rel="stylesheet" />
<script src="https://use.fontawesome.com/releases/v6.1.0/js/all.js" crossorigin="anonymous"></script>
</head>
<body class="bg-primary">
<div id="layoutAuthentication">
<div id="layoutAuthentication_content">
<main>
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-5">
<div class="card shadow-lg border-0 rounded-lg mt-5">
<div class="card-header"><h3 class="text-center font-weight-light my-4">Login</h3></div>
<div class="card-body">
<form>
<div class="form-floating mb-3">
<input class="form-control" id="inputEmail" type="email" placeholder="name@example.com" />
<label for="inputEmail">Email address</label>
</div>
<div class="form-floating mb-3">
<input class="form-control" id="inputPassword" type="password" placeholder="Password" />
<label for="inputPassword">Password</label>
</div>
<div class="form-check mb-3">
<input class="form-check-input" id="inputRememberPassword" type="checkbox" value="" />
<label class="form-check-label" for="inputRememberPassword">Remember Password</label>
</div>
<div class="d-flex align-items-center justify-content-between mt-4 mb-0">
<a class="small" href="password.html">Forgot Password?</a>
<a class="btn btn-primary" href="index.html">Login</a>
</div>
</form>
</div>
<div class="card-footer text-center py-3">
<div class="small"><a href="register.html">Need an account? Sign up!</a></div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
<div id="layoutAuthentication_footer">
<footer class="py-4 bg-light mt-auto">
<div class="container-fluid px-4">
<div class="d-flex align-items-center justify-content-between small">
<div class="text-muted">Copyright &copy; Your Website 2022</div>
<div>
<a href="#">Privacy Policy</a>
&middot;
<a href="#">Terms &amp; Conditions</a>
</div>
</div>
</div>
</footer>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js" crossorigin="anonymous"></script>
<script src="js/scripts.js"></script>
</body>
</html>

View File

@ -0,0 +1,21 @@
{% macro submenu(title, icon, items) -%}
<a class="nav-link{% if not is_here(items) %} collapsed{% endif %}" href="#" data-bs-toggle="collapse" data-bs-target="#collapse{{title.replace(' ', '')}}" aria-expanded="{% if is_here(items) %}true{% else %}false{% endif %}" aria-controls="collapse{{title.replace(' ', '')}}">
<div class="sb-nav-link-icon" id="heading{{title.replace(' ', '')}}"><i class="bi bi-{{icon}}"></i></div>
{{title}}
<div class="sb-sidenav-collapse-arrow"><i class="bi bi-chevron-down"></i></div>
</a>
<div class="collapse{% if is_here(items) %} show{% endif %}" id="collapse{{title.replace(' ', '')}}" aria-labelledby="heading{{title.replace(' ', '')}}" data-bs-parent="#sidenavAccordion">
<nav class="sb-sidenav-menu-nested nav">
{% for url, subtitle in items -%}
<a class="nav-link{% if request.path == url %} active{% endif %}" href="{{url}}">{{subtitle}}</a>
{%- endfor %}
</nav>
</div>
{%- endmacro %}
{% macro breadcrumb(items) -%}
<ol class="breadcrumb mb-4">
{% for item in items %}
<li class="breadcrumb-item{% if loop.last %} active{% endif %}">{{item}}</li>
{% endfor %}
</ol>
{% endmacro %}

View File

@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<meta name="description" content="" />
<meta name="author" content="" />
<title>Password Reset - SB Admin</title>
<link href="css/styles.css" rel="stylesheet" />
<script src="https://use.fontawesome.com/releases/v6.1.0/js/all.js" crossorigin="anonymous"></script>
</head>
<body class="bg-primary">
<div id="layoutAuthentication">
<div id="layoutAuthentication_content">
<main>
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-5">
<div class="card shadow-lg border-0 rounded-lg mt-5">
<div class="card-header"><h3 class="text-center font-weight-light my-4">Password Recovery</h3></div>
<div class="card-body">
<div class="small mb-3 text-muted">Enter your email address and we will send you a link to reset your password.</div>
<form>
<div class="form-floating mb-3">
<input class="form-control" id="inputEmail" type="email" placeholder="name@example.com" />
<label for="inputEmail">Email address</label>
</div>
<div class="d-flex align-items-center justify-content-between mt-4 mb-0">
<a class="small" href="login.html">Return to login</a>
<a class="btn btn-primary" href="login.html">Reset Password</a>
</div>
</form>
</div>
<div class="card-footer text-center py-3">
<div class="small"><a href="register.html">Need an account? Sign up!</a></div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
<div id="layoutAuthentication_footer">
<footer class="py-4 bg-light mt-auto">
<div class="container-fluid px-4">
<div class="d-flex align-items-center justify-content-between small">
<div class="text-muted">Copyright &copy; Your Website 2022</div>
<div>
<a href="#">Privacy Policy</a>
&middot;
<a href="#">Terms &amp; Conditions</a>
</div>
</div>
</div>
</footer>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js" crossorigin="anonymous"></script>
<script src="js/scripts.js"></script>
</body>
</html>

View File

@ -0,0 +1,88 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<meta name="description" content="" />
<meta name="author" content="" />
<title>Register - SB Admin</title>
<link href="css/styles.css" rel="stylesheet" />
<script src="https://use.fontawesome.com/releases/v6.1.0/js/all.js" crossorigin="anonymous"></script>
</head>
<body class="bg-primary">
<div id="layoutAuthentication">
<div id="layoutAuthentication_content">
<main>
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-7">
<div class="card shadow-lg border-0 rounded-lg mt-5">
<div class="card-header"><h3 class="text-center font-weight-light my-4">Create Account</h3></div>
<div class="card-body">
<form>
<div class="row mb-3">
<div class="col-md-6">
<div class="form-floating mb-3 mb-md-0">
<input class="form-control" id="inputFirstName" type="text" placeholder="Enter your first name" />
<label for="inputFirstName">First name</label>
</div>
</div>
<div class="col-md-6">
<div class="form-floating">
<input class="form-control" id="inputLastName" type="text" placeholder="Enter your last name" />
<label for="inputLastName">Last name</label>
</div>
</div>
</div>
<div class="form-floating mb-3">
<input class="form-control" id="inputEmail" type="email" placeholder="name@example.com" />
<label for="inputEmail">Email address</label>
</div>
<div class="row mb-3">
<div class="col-md-6">
<div class="form-floating mb-3 mb-md-0">
<input class="form-control" id="inputPassword" type="password" placeholder="Create a password" />
<label for="inputPassword">Password</label>
</div>
</div>
<div class="col-md-6">
<div class="form-floating mb-3 mb-md-0">
<input class="form-control" id="inputPasswordConfirm" type="password" placeholder="Confirm password" />
<label for="inputPasswordConfirm">Confirm Password</label>
</div>
</div>
</div>
<div class="mt-4 mb-0">
<div class="d-grid"><a class="btn btn-primary btn-block" href="login.html">Create Account</a></div>
</div>
</form>
</div>
<div class="card-footer text-center py-3">
<div class="small"><a href="login.html">Have an account? Go to login</a></div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
<div id="layoutAuthentication_footer">
<footer class="py-4 bg-light mt-auto">
<div class="container-fluid px-4">
<div class="d-flex align-items-center justify-content-between small">
<div class="text-muted">Copyright &copy; Your Website 2022</div>
<div>
<a href="#">Privacy Policy</a>
&middot;
<a href="#">Terms &amp; Conditions</a>
</div>
</div>
</div>
</footer>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js" crossorigin="anonymous"></script>
<script src="js/scripts.js"></script>
</body>
</html>

View File

@ -0,0 +1,9 @@
<main>
{% for user in users %}
<article>
<h2>{{ user.name }}</h2>
</article>
{% else %}
<p>No users available</p>
{% endfor %}
</main>

View File

@ -0,0 +1,9 @@
<main>
{% for user in users %}
<article>
<h2>{{ user.name }}</h2>
</article>
{% else %}
<p>No users available</p>
{% endfor %}
</main>

View File

@ -0,0 +1,23 @@
from quart import Quart, current_app, request
from flask_theme import get_theme, render_theme_template
def register_globals(app: Quart) -> None:
"""Register some global templating functions"""
@app.template_global(name='is_here')
def is_here(items: list) -> bool:
"""Determine the current page is in the list"""
return len(list(filter(lambda item: request.path == item[0], items))) > 0
def get_current_theme():
"""Get the current theme"""
ident = current_app.config.get('DEFAULT_THEME', 'quill')
return get_theme(ident)
def render(template, **context):
"""Render the template using the current theme"""
theme = get_current_theme()
return render_theme_template(theme, template, **context)

View File

View File

@ -0,0 +1,65 @@
from typing import Any, List, Union
from sqlalchemy import or_
from sqlalchemy.sql import select
from scribeengine.db.base import Session
from scribeengine.db.models.node import Node, NodeFieldInstance
def _safe_cast(value: Any, to_type: Any) -> Any:
"""Safely typecast a str value to a type"""
try:
return to_type(value)
except (ValueError, TypeError):
return value
def _typecast_field_value(field: NodeFieldInstance) -> Any:
"""Typecast a field value to the field type"""
if field.node_field.field_type == 'int' and field.value.isdigit():
return _safe_cast(field.value, int)
elif field.node_field.field_type == 'float' and field.value.isdecimal():
return _safe_cast(field.value, float)
elif field.node_field.field_type == 'bool':
return field.value.lower()[0] in ['y', '1', 't']
else:
return field.value
def get_node(node_id: Union[Node, str, int]) -> Union[None, dict]:
"""Assemble a node into a large dictionary"""
if node_id is None:
return None
if not isinstance(node_id, Node):
with Session() as session:
node = session.scalars(select(Node).where(or_(Node.id == node_id, Node.slug == node_id))).first()
else:
node = node_id
if not node:
return None
node_dict = {
'id': node.id,
'node_type': node.node_type.slug,
'title': node.title,
'slug': node.slug,
'created': node.created,
'modified': node.modified
}
if not node.latest_revision:
return node_dict
for field in node.latest_revision.fields:
node_dict[field.node_field.slug] = {
'title': field.node_field.title,
'slug': field.node_field.slug,
'type': field.node_field.field_type,
'value': _typecast_field_value(field)
}
return node_dict
def get_node_templates(node_type: str, template_type: str | None = None) -> List[str]:
"""Get a list of potential templates for this node type"""
if template_type:
return [f'nodes/{template_type}-{node_type}.html', f'nodes/{template_type}-node.html']
return [f'nodes/{node_type}.html', 'nodes/node.html']

View File

@ -0,0 +1,9 @@
from quart.wrappers.request import Request
async def paginate(request: Request, per_page: int = 9) -> tuple[int, int, int]:
"""Get the "page" parameter from the request, and determine the offset and limit"""
page = request.args.get('page', 1, type=int)
limit = request.args.get('perpage', per_page, type=int)
offset = (page * limit) - limit
return page, offset, limit

View File

@ -0,0 +1,13 @@
from quart import Blueprint, render_template
from scribeengine.views.admin.content import content
from scribeengine.views.admin.users import users
admin = Blueprint('admin', __name__, url_prefix='/admin')
admin.register_blueprint(content)
admin.register_blueprint(users)
@admin.route('')
async def index():
return await render_template('admin/index.html')

View File

@ -0,0 +1,28 @@
from quart import Blueprint, render_template, request
from sqlalchemy.sql import select
from scribeengine.db.base import Session
from scribeengine.db.models.node import NodeType, Node
from scribeengine.util.pagination import paginate
content = Blueprint('content', __name__, url_prefix='/content')
@content.route('', methods=['GET'])
@content.route('/<content_type>', methods=['GET'])
async def list_content(content_type: str | None = None):
"""List all the content"""
page, offset, limit = await paginate(request)
with Session() as session:
query = select(Node)
if content_type:
query.join(Node.node_type).where(NodeType.slug == content_type)
all_content = session.scalars(query.order_by(Node.created.desc()).offset(offset).limit(limit)).all()
return await render_template('admin/content/list_content.html', content=all_content)
@content.route('/new', methods=['GET', 'POST'])
async def new_content():
"""Create a new content"""
return await render_template('admin/content/new_content.html')

View File

@ -0,0 +1,23 @@
from quart import Blueprint, render_template, request
from sqlalchemy.sql import select
from scribeengine.db.base import Session
from scribeengine.db.models.user import User
from scribeengine.util.pagination import paginate
users = Blueprint('users', __name__, url_prefix='/users')
@users.route('', methods=['GET'])
async def list_users():
"""List all the users"""
page, offset, limit = await paginate(request)
with Session() as session:
all_users = session.scalars(select(User).offset(offset).limit(limit)).all()
return await render_template('admin/users/list_users.html', users=all_users)
@users.route('/new', methods=['GET', 'POST'])
async def new_user():
return await render_template('admin/users/new_user.html')

View File

@ -0,0 +1,50 @@
from typing import Union
from quart import Blueprint, render_template, request, abort
from sqlalchemy.sql import select
from scribeengine.db.base import Session
from scribeengine.db.models.node import Node
from scribeengine.util.nodes import get_node, get_node_templates
nodes = Blueprint('nodes', __name__, url_prefix='/')
def _get_node_list(*filters) -> tuple[int, int, list[Node]]:
"""Get a paginated list of nodes"""
page_size = request.args.get("page_size", 25)
page = request.args.get("page", 1)
offset = (page * page_size) - page
query = select(Node)
if filters:
query = query.where(*filters)
query = query.offset(offset).limit(page_size)
with Session() as session:
return page, page_size, session.scalars(query).all()
@nodes.route('', methods=['GET'])
async def list_nodes():
"""List all the nodes"""
page, page_size, node_list = _get_node_list()
all_nodes = [get_node(node) for node in node_list]
return await render_template('admin/nodes/list_nodes.html', nodes=all_nodes, page=page, page_size=page_size)
@nodes.route('/<node_type>', methods=['GET'])
async def list_nodes_by_type(node_type: str):
"""List all the nodes"""
page, page_size, node_list = _get_node_list(Node.node_type.slug == node_type)
all_nodes = [get_node(node) for node in node_list]
node_templates = get_node_templates(node_type, 'list')
return await render_template(node_templates, nodes=all_nodes, page=page, page_size=page_size)
@nodes.route('<slug_or_id>', methods=['GET'])
async def view_node(slug_or_id: Union[str, int]):
"""View a single node"""
node = get_node(slug_or_id)
if not node:
return await abort(404)
node_templates = get_node_templates(node['node_type'], 'view')
return await render_template(node_templates, node=node)

0
tests/__init__.py Normal file
View File

View File

@ -0,0 +1,5 @@
from scribeengine import __version__
def test_version():
assert __version__ == '0.1.0'