forked from scribeengine/scribeengine
Initial import
This commit is contained in:
commit
d74460f075
16
.editorconfig
Normal file
16
.editorconfig
Normal 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
|
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
__pycache__
|
||||||
|
build
|
||||||
|
dist
|
18
CHANGES.rst
Normal file
18
CHANGES.rst
Normal 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
22
LICENSE
Normal 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
39
README.rst
Normal 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
66
pyproject.toml
Normal 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"
|
1
src/scribeengine/__about__.py
Normal file
1
src/scribeengine/__about__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
__version__ = '0.0.1'
|
0
src/scribeengine/__init__.py
Normal file
0
src/scribeengine/__init__.py
Normal file
12
src/scribeengine/__main__.py
Normal file
12
src/scribeengine/__main__.py
Normal 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
29
src/scribeengine/app.py
Normal 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
|
0
src/scribeengine/db/__init__.py
Normal file
0
src/scribeengine/db/__init__.py
Normal file
28
src/scribeengine/db/base.py
Normal file
28
src/scribeengine/db/base.py
Normal 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
|
87
src/scribeengine/db/models/node.py
Normal file
87
src/scribeengine/db/models/node.py
Normal 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()
|
52
src/scribeengine/db/models/settings.py
Normal file
52
src/scribeengine/db/models/settings.py
Normal 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
|
110
src/scribeengine/db/models/user.py
Normal file
110
src/scribeengine/db/models/user.py
Normal 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
|
52
src/scribeengine/settings.py
Normal file
52
src/scribeengine/settings.py
Normal 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()
|
11549
src/scribeengine/static/css/admin.css
Normal file
11549
src/scribeengine/static/css/admin.css
Normal file
File diff suppressed because it is too large
Load Diff
22
src/scribeengine/static/js/admin.js
Normal file
22
src/scribeengine/static/js/admin.js
Normal 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'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
87
src/scribeengine/templates/admin/base.html
Normal file
87
src/scribeengine/templates/admin/base.html
Normal 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 © Your Website 2022</div>
|
||||||
|
<div>
|
||||||
|
<a href="#">Privacy Policy</a>
|
||||||
|
·
|
||||||
|
<a href="#">Terms & 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>
|
24
src/scribeengine/templates/admin/content/list_content.html
Normal file
24
src/scribeengine/templates/admin/content/list_content.html
Normal 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 %}
|
7
src/scribeengine/templates/admin/index.html
Normal file
7
src/scribeengine/templates/admin/index.html
Normal 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 %}
|
69
src/scribeengine/templates/admin/login.html
Normal file
69
src/scribeengine/templates/admin/login.html
Normal 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 © Your Website 2022</div>
|
||||||
|
<div>
|
||||||
|
<a href="#">Privacy Policy</a>
|
||||||
|
·
|
||||||
|
<a href="#">Terms & 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>
|
21
src/scribeengine/templates/admin/macros.html
Normal file
21
src/scribeengine/templates/admin/macros.html
Normal 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 %}
|
62
src/scribeengine/templates/admin/password.html
Normal file
62
src/scribeengine/templates/admin/password.html
Normal 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 © Your Website 2022</div>
|
||||||
|
<div>
|
||||||
|
<a href="#">Privacy Policy</a>
|
||||||
|
·
|
||||||
|
<a href="#">Terms & 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>
|
88
src/scribeengine/templates/admin/register.html
Normal file
88
src/scribeengine/templates/admin/register.html
Normal 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 © Your Website 2022</div>
|
||||||
|
<div>
|
||||||
|
<a href="#">Privacy Policy</a>
|
||||||
|
·
|
||||||
|
<a href="#">Terms & 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>
|
9
src/scribeengine/templates/admin/users/list_users.html
Normal file
9
src/scribeengine/templates/admin/users/list_users.html
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<main>
|
||||||
|
{% for user in users %}
|
||||||
|
<article>
|
||||||
|
<h2>{{ user.name }}</h2>
|
||||||
|
</article>
|
||||||
|
{% else %}
|
||||||
|
<p>No users available</p>
|
||||||
|
{% endfor %}
|
||||||
|
</main>
|
9
src/scribeengine/templates/posts.html
Normal file
9
src/scribeengine/templates/posts.html
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<main>
|
||||||
|
{% for user in users %}
|
||||||
|
<article>
|
||||||
|
<h2>{{ user.name }}</h2>
|
||||||
|
</article>
|
||||||
|
{% else %}
|
||||||
|
<p>No users available</p>
|
||||||
|
{% endfor %}
|
||||||
|
</main>
|
23
src/scribeengine/templating.py
Normal file
23
src/scribeengine/templating.py
Normal 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)
|
0
src/scribeengine/util/__init__.py
Normal file
0
src/scribeengine/util/__init__.py
Normal file
65
src/scribeengine/util/nodes.py
Normal file
65
src/scribeengine/util/nodes.py
Normal 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']
|
9
src/scribeengine/util/pagination.py
Normal file
9
src/scribeengine/util/pagination.py
Normal 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
|
13
src/scribeengine/views/admin/__init__.py
Normal file
13
src/scribeengine/views/admin/__init__.py
Normal 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')
|
28
src/scribeengine/views/admin/content.py
Normal file
28
src/scribeengine/views/admin/content.py
Normal 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')
|
23
src/scribeengine/views/admin/users.py
Normal file
23
src/scribeengine/views/admin/users.py
Normal 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')
|
50
src/scribeengine/views/nodes.py
Normal file
50
src/scribeengine/views/nodes.py
Normal 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
0
tests/__init__.py
Normal file
5
tests/test_scribeengine.py
Normal file
5
tests/test_scribeengine.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from scribeengine import __version__
|
||||||
|
|
||||||
|
|
||||||
|
def test_version():
|
||||||
|
assert __version__ == '0.1.0'
|
Loading…
Reference in New Issue
Block a user