Restructure models and database import paths

Signed-off-by: Ferdinand Thiessen <rpm@fthiessen.de>
This commit is contained in:
Ferdinand Thiessen 2022-08-18 19:53:58 +02:00
parent 7f8aa80b0e
commit e41be21c47
26 changed files with 202 additions and 176 deletions

View File

@ -9,6 +9,7 @@ from sqlalchemy.exc import OperationalError
from werkzeug.exceptions import HTTPException
from flaschengeist import logger
from flaschengeist.models import Plugin
from flaschengeist.utils.hook import Hook
from flaschengeist.plugins import AuthPlugin
from flaschengeist.config import config, configure_app

View File

@ -6,7 +6,7 @@ from importlib.metadata import EntryPoint, entry_points
from flaschengeist.database import db
from flaschengeist.config import config
from flaschengeist.models.user import Permission
from flaschengeist.models import Permission
@click.group()

View File

@ -1,15 +1,14 @@
from datetime import date
from flask import send_file
from pathlib import Path
from flask import send_file
from PIL import Image as PImage
from werkzeug.exceptions import NotFound, UnprocessableEntity
from werkzeug.datastructures import FileStorage
from werkzeug.utils import secure_filename
from werkzeug.datastructures import FileStorage
from werkzeug.exceptions import NotFound, UnprocessableEntity
from flaschengeist.models.image import Image
from flaschengeist.database import db
from flaschengeist.config import config
from ..models import Image
from ..database import db
from ..config import config
def check_mimetype(mime: str):

View File

@ -1,5 +1,5 @@
from flaschengeist.utils.hook import Hook
from flaschengeist.models.user import User, Role
from flaschengeist.models import User, Role
class Message:

View File

@ -4,9 +4,16 @@ Used by plugins for setting and notification functionality.
"""
import sqlalchemy
from typing import Union
from flask import current_app
from werkzeug.exceptions import NotFound
from importlib.metadata import entry_points
from .. import logger
from ..database import db
from ..models.setting import _PluginSetting
from ..models.notification import Notification
from ..utils import Hook
from ..models import Plugin, PluginSetting, Notification
def get_setting(plugin_id: str, name: str, **kwargs):
@ -23,7 +30,7 @@ def get_setting(plugin_id: str, name: str, **kwargs):
"""
try:
setting = (
_PluginSetting.query.filter(_PluginSetting.plugin == plugin_id).filter(_PluginSetting.name == name).one()
PluginSetting.query.filter(PluginSetting.plugin == plugin_id).filter(PluginSetting.name == name).one()
)
return setting.value
except sqlalchemy.orm.exc.NoResultFound:
@ -42,8 +49,8 @@ def set_setting(plugin_id: str, name: str, value):
value: Value to be stored
"""
setting = (
_PluginSetting.query.filter(_PluginSetting.plugin == plugin_id)
.filter(_PluginSetting.name == name)
PluginSetting.query.filter(PluginSetting.plugin == plugin_id)
.filter(PluginSetting.name == name)
.one_or_none()
)
if setting is not None:
@ -52,7 +59,7 @@ def set_setting(plugin_id: str, name: str, value):
else:
setting.value = value
else:
db.session.add(_PluginSetting(plugin=plugin_id, name=name, value=value))
db.session.add(PluginSetting(plugin=plugin_id, name=name, value=value))
db.session.commit()

View File

@ -2,10 +2,10 @@ from typing import Union
from sqlalchemy.exc import IntegrityError
from werkzeug.exceptions import BadRequest, Conflict, NotFound
from flaschengeist import logger
from flaschengeist.models.user import Role, Permission
from flaschengeist.database import db, case_sensitive
from flaschengeist.utils.hook import Hook
from .. import logger
from ..models import Role, Permission
from ..database import db, case_sensitive
from ..utils.hook import Hook
def get_all():

View File

@ -1,9 +1,12 @@
import secrets
from flaschengeist.models.session import Session
from flaschengeist.database import db
from flaschengeist import logger
from werkzeug.exceptions import Forbidden, Unauthorized
from datetime import datetime, timezone
from werkzeug.exceptions import Forbidden, Unauthorized
from .. import logger
from ..models import Session
from ..database import db
lifetime = 1800

View File

@ -1,5 +1,6 @@
import secrets
import re
import secrets
from io import BytesIO
from sqlalchemy import exc
from flask import current_app
@ -7,15 +8,15 @@ from datetime import datetime, timedelta, timezone
from flask.helpers import send_file
from werkzeug.exceptions import NotFound, BadRequest, Forbidden
from flaschengeist import logger
from flaschengeist.config import config
from flaschengeist.database import db
from flaschengeist.models.notification import Notification
from flaschengeist.utils.hook import Hook
from flaschengeist.utils.datetime import from_iso_format
from flaschengeist.utils.foreign_keys import merge_references
from flaschengeist.models.user import User, Role, _PasswordReset
from flaschengeist.controller import imageController, messageController, sessionController
from .. import logger
from ..config import config
from ..database import db
from ..models import Notification, User, Role
from ..models.user import _PasswordReset
from ..utils.hook import Hook
from ..utils.datetime import from_iso_format
from ..utils.foreign_keys import merge_references
from ..controller import imageController, messageController, sessionController
def __active_users():

View File

@ -0,0 +1,95 @@
import sys
import datetime
from sqlalchemy import BigInteger, util
from sqlalchemy.dialects import mysql, sqlite
from sqlalchemy.types import DateTime, TypeDecorator
class ModelSerializeMixin:
"""Mixin class used for models to serialize them automatically
Ignores private and protected members as well as members marked as not to publish (name ends with _)
"""
def __is_optional(self, param):
if sys.version_info < (3, 8):
return False
import typing
hint = typing.get_type_hints(self.__class__)[param]
if (
typing.get_origin(hint) is typing.Union
and len(typing.get_args(hint)) == 2
and typing.get_args(hint)[1] is type(None)
):
return getattr(self, param) is None
def serialize(self):
"""Serialize class to dict
Returns:
Dict of all not private or protected annotated member variables.
"""
d = {
param: getattr(self, param)
for param in self.__class__.__annotations__
if not param.startswith("_") and not param.endswith("_") and not self.__is_optional(param)
}
if len(d) == 1:
key, value = d.popitem()
return value
return d
def __str__(self) -> str:
return self.serialize().__str__()
class Serial(TypeDecorator):
"""Same as MariaDB Serial used for IDs"""
cache_ok = True
impl = BigInteger().with_variant(mysql.BIGINT(unsigned=True), "mysql").with_variant(sqlite.INTEGER(), "sqlite")
# https://alembic.sqlalchemy.org/en/latest/autogenerate.html?highlight=custom%20column#affecting-the-rendering-of-types-themselves
def __repr__(self) -> str:
return util.generic_repr(self)
class UtcDateTime(TypeDecorator):
"""Almost equivalent to `sqlalchemy.types.DateTime` with
``timezone=True`` option, but it differs from that by:
- Never silently take naive :class:`datetime.datetime`, instead it
always raise :exc:`ValueError` unless time zone aware value.
- :class:`datetime.datetime` value's :attr:`datetime.datetime.tzinfo`
is always converted to UTC.
- Unlike SQLAlchemy's built-in :class:`sqlalchemy.types.DateTime`,
it never return naive :class:`datetime.datetime`, but time zone
aware value, even with SQLite or MySQL.
"""
cache_ok = True
impl = DateTime(timezone=True)
@staticmethod
def current_utc():
return datetime.datetime.now(tz=datetime.timezone.utc)
def process_bind_param(self, value, dialect):
if value is not None:
if not isinstance(value, datetime.datetime):
raise TypeError("expected datetime.datetime, not " + repr(value))
elif value.tzinfo is None:
raise ValueError("naive datetime is disallowed")
return value.astimezone(datetime.timezone.utc)
def process_result_value(self, value, dialect):
if value is not None:
if value.tzinfo is not None:
value = value.astimezone(datetime.timezone.utc)
value = value.replace(tzinfo=datetime.timezone.utc)
return value
# https://alembic.sqlalchemy.org/en/latest/autogenerate.html?highlight=custom%20column#affecting-the-rendering-of-types-themselves
def __repr__(self) -> str:
return util.generic_repr(self)

View File

@ -1,95 +1,5 @@
import sys
import datetime
from sqlalchemy import BigInteger, util
from sqlalchemy.dialects import mysql, sqlite
from sqlalchemy.types import DateTime, TypeDecorator
class ModelSerializeMixin:
"""Mixin class used for models to serialize them automatically
Ignores private and protected members as well as members marked as not to publish (name ends with _)
"""
def __is_optional(self, param):
if sys.version_info < (3, 8):
return False
import typing
hint = typing.get_type_hints(self.__class__)[param]
if (
typing.get_origin(hint) is typing.Union
and len(typing.get_args(hint)) == 2
and typing.get_args(hint)[1] is type(None)
):
return getattr(self, param) is None
def serialize(self):
"""Serialize class to dict
Returns:
Dict of all not private or protected annotated member variables.
"""
d = {
param: getattr(self, param)
for param in self.__class__.__annotations__
if not param.startswith("_") and not param.endswith("_") and not self.__is_optional(param)
}
if len(d) == 1:
key, value = d.popitem()
return value
return d
def __str__(self) -> str:
return self.serialize().__str__()
class Serial(TypeDecorator):
"""Same as MariaDB Serial used for IDs"""
cache_ok = True
impl = BigInteger().with_variant(mysql.BIGINT(unsigned=True), "mysql").with_variant(sqlite.INTEGER(), "sqlite")
# https://alembic.sqlalchemy.org/en/latest/autogenerate.html?highlight=custom%20column#affecting-the-rendering-of-types-themselves
def __repr__(self) -> str:
return util.generic_repr(self)
class UtcDateTime(TypeDecorator):
"""Almost equivalent to `sqlalchemy.types.DateTime` with
``timezone=True`` option, but it differs from that by:
- Never silently take naive :class:`datetime.datetime`, instead it
always raise :exc:`ValueError` unless time zone aware value.
- :class:`datetime.datetime` value's :attr:`datetime.datetime.tzinfo`
is always converted to UTC.
- Unlike SQLAlchemy's built-in :class:`sqlalchemy.types.DateTime`,
it never return naive :class:`datetime.datetime`, but time zone
aware value, even with SQLite or MySQL.
"""
cache_ok = True
impl = DateTime(timezone=True)
@staticmethod
def current_utc():
return datetime.datetime.now(tz=datetime.timezone.utc)
def process_bind_param(self, value, dialect):
if value is not None:
if not isinstance(value, datetime.datetime):
raise TypeError("expected datetime.datetime, not " + repr(value))
elif value.tzinfo is None:
raise ValueError("naive datetime is disallowed")
return value.astimezone(datetime.timezone.utc)
def process_result_value(self, value, dialect):
if value is not None:
if value.tzinfo is not None:
value = value.astimezone(datetime.timezone.utc)
value = value.replace(tzinfo=datetime.timezone.utc)
return value
# https://alembic.sqlalchemy.org/en/latest/autogenerate.html?highlight=custom%20column#affecting-the-rendering-of-types-themselves
def __repr__(self) -> str:
return util.generic_repr(self)
from .session import *
from .user import *
from .plugin import *
from .notification import *
from .image import *

View File

@ -1,8 +1,8 @@
from sqlalchemy import event
from pathlib import Path
from . import ModelSerializeMixin, Serial
from ..database import db
from ..database.types import ModelSerializeMixin, Serial
class Image(db.Model, ModelSerializeMixin):

View File

@ -1,9 +1,8 @@
from datetime import datetime
from typing import Any
from . import Serial, UtcDateTime, ModelSerializeMixin
from ..database import db
from .user import User
from ..database.types import Serial, UtcDateTime, ModelSerializeMixin
class Notification(db.Model, ModelSerializeMixin):

View File

@ -0,0 +1,24 @@
from typing import Any
from ..database import db
from ..database.types import Serial
class Plugin(db.Model):
__tablename__ = "plugin"
id: int = db.Column("id", Serial, primary_key=True)
name: str = db.Column(db.String(127), nullable=False)
version: str = db.Column(db.String(30), nullable=False)
"""The latest installed version"""
enabled: bool = db.Column(db.Boolean, default=False)
settings_ = db.relationship("PluginSetting", cascade="all, delete")
permissions_ = db.relationship("Permission", cascade="all, delete")
class PluginSetting(db.Model):
__tablename__ = "plugin_setting"
id = db.Column("id", Serial, primary_key=True)
plugin_id: int = db.Column("plugin", Serial, db.ForeignKey("plugin.id"))
name: str = db.Column(db.String(127), nullable=False)
value: Any = db.Column(db.PickleType(protocol=4))

View File

@ -1,5 +1,9 @@
from datetime import datetime, timedelta, timezone
from secrets import compare_digest
from ..database import db
from ..database.types import ModelSerializeMixin, UtcDateTime, Serial
from flaschengeist import logger
@ -22,7 +26,7 @@ class Session(db.Model, ModelSerializeMixin):
_id = db.Column("id", Serial, primary_key=True)
_user_id = db.Column("user_id", Serial, db.ForeignKey("user.id"))
user_: User = db.relationship("User", back_populates="sessions_")
user_: "User" = db.relationship("User", back_populates="sessions_")
@property
def userid(self):

View File

@ -1,13 +0,0 @@
from __future__ import annotations # TODO: Remove if python requirement is >= 3.10
from typing import Any
from . import Serial
from ..database import db
class _PluginSetting(db.Model):
__tablename__ = "plugin_setting"
id = db.Column("id", Serial, primary_key=True)
plugin: str = db.Column(db.String(127))
name: str = db.Column(db.String(127), nullable=False)
value: Any = db.Column(db.PickleType(protocol=4))

View File

@ -3,9 +3,7 @@ from datetime import date, datetime
from sqlalchemy.orm.collections import attribute_mapped_collection
from ..database import db
from . import ModelSerializeMixin, UtcDateTime, Serial
from .image import Image
from ..database.types import ModelSerializeMixin, UtcDateTime, Serial
association_table = db.Table(
"user_x_role",
@ -19,7 +17,6 @@ role_permission_association_table = db.Table(
db.Column("permission_id", Serial, db.ForeignKey("permission.id")),
)
class Permission(db.Model, ModelSerializeMixin):
__tablename__ = "permission"
name: str = db.Column(db.String(30), unique=True)
@ -66,7 +63,7 @@ class User(db.Model, ModelSerializeMixin):
sessions_: list["Session"] = db.relationship(
"Session", back_populates="user_", cascade="all, delete, delete-orphan"
)
avatar_: Optional[Image] = db.relationship("Image", cascade="all, delete, delete-orphan", single_parent=True)
avatar_: Optional["Image"] = db.relationship("Image", cascade="all, delete, delete-orphan", single_parent=True)
reset_requests_: list["_PasswordReset"] = db.relationship("_PasswordReset", cascade="all, delete, delete-orphan")
# Private stuff for internal use

View File

@ -9,7 +9,8 @@ from importlib.metadata import Distribution, EntryPoint
from werkzeug.exceptions import MethodNotAllowed, NotFound
from werkzeug.datastructures import FileStorage
from flaschengeist.models.user import _Avatar, User
from flaschengeist.models import User
from flaschengeist.models.user import _Avatar
from flaschengeist.utils.hook import HookBefore, HookAfter
__all__ = [
@ -19,7 +20,7 @@ __all__ = [
"before_role_updated",
"before_update_user",
"after_role_updated",
"Plugin",
"BasePlugin",
"AuthPlugin",
]
@ -70,7 +71,7 @@ Passed args:
"""
class Plugin:
class BasePlugin:
"""Base class for all Plugins
All plugins must derived from this class.
@ -193,10 +194,10 @@ class Plugin:
return {"version": self.version, "permissions": self.permissions}
class AuthPlugin(Plugin):
class AuthPlugin(BasePlugin):
"""Base class for all authentification plugins
See also `Plugin`
See also `BasePlugin`
"""
def login(self, user, pw):

View File

@ -6,13 +6,13 @@ from flask import Blueprint, request, jsonify
from werkzeug.exceptions import Forbidden, BadRequest, Unauthorized
from flaschengeist import logger
from flaschengeist.plugins import Plugin
from flaschengeist.plugins import BasePlugin
from flaschengeist.utils.HTTP import no_content, created
from flaschengeist.utils.decorators import login_required
from flaschengeist.controller import sessionController, userController
class AuthRoutePlugin(Plugin):
class AuthRoutePlugin(BasePlugin):
blueprint = Blueprint("auth", __name__)

View File

@ -12,7 +12,8 @@ from werkzeug.datastructures import FileStorage
from flaschengeist import logger
from flaschengeist.controller import userController
from flaschengeist.models.user import User, Role, _Avatar
from flaschengeist.models import User, Role
from flaschengeist.models.user import _Avatar
from flaschengeist.plugins import AuthPlugin, before_role_updated

View File

@ -8,7 +8,7 @@ import hashlib
import binascii
from werkzeug.exceptions import BadRequest
from flaschengeist.plugins import AuthPlugin, plugins_installed
from flaschengeist.models.user import User, Role, Permission
from flaschengeist.models import User, Role, Permission
from flaschengeist.database import db
from flaschengeist import logger

View File

@ -3,15 +3,14 @@ from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from flaschengeist import logger
from flaschengeist.models.user import User
from flaschengeist.models import User
from flaschengeist.plugins import BasePlugin
from flaschengeist.utils.hook import HookAfter
from flaschengeist.controller import userController
from flaschengeist.controller.messageController import Message
from . import Plugin
class MailMessagePlugin(Plugin):
class MailMessagePlugin(BasePlugin):
def __init__(self, entry_point, config):
super().__init__(entry_point, config)
self.server = config["SERVER"]

View File

@ -1,6 +1,6 @@
from flaschengeist.database import db
from flaschengeist.models import ModelSerializeMixin, Serial
from flaschengeist.models.image import Image
from flaschengeist.database.types import ModelSerializeMixin, Serial
from flaschengeist.models import Image
from typing import Optional

View File

@ -5,17 +5,16 @@ Provides routes used to configure roles and permissions of users / roles.
from werkzeug.exceptions import BadRequest
from flask import Blueprint, request, jsonify
from http.client import NO_CONTENT
from flaschengeist.plugins import Plugin
from flaschengeist.utils.decorators import login_required
from flaschengeist.plugins import BasePlugin
from flaschengeist.controller import roleController
from flaschengeist.utils.HTTP import created, no_content
from flaschengeist.utils.decorators import login_required
from . import permissions
class RolesPlugin(Plugin):
class RolesPlugin(BasePlugin):
blueprint = Blueprint("roles", __name__)
permissions = permissions.permissions

View File

@ -2,10 +2,9 @@ from flask import Blueprint
from datetime import datetime, timedelta
from flaschengeist import logger
from flaschengeist.plugins import BasePlugin
from flaschengeist.utils.HTTP import no_content
from . import Plugin
class __Task:
def __init__(self, function, **kwags):
@ -38,7 +37,7 @@ def scheduled(id: str, replace=False, **kwargs):
return real_decorator
class SchedulerPlugin(Plugin):
class SchedulerPlugin(BasePlugin):
def __init__(self, entry_point, config=None):
super().__init__(entry_point, config)
self.blueprint = Blueprint(self.name, __name__)

View File

@ -2,14 +2,14 @@
Provides routes used to manage users
"""
from http.client import NO_CONTENT, CREATED
from http.client import CREATED
from flask import Blueprint, request, jsonify, make_response
from werkzeug.exceptions import BadRequest, Forbidden, MethodNotAllowed, NotFound
from werkzeug.exceptions import BadRequest, Forbidden, MethodNotAllowed
from . import permissions
from flaschengeist import logger
from flaschengeist.config import config
from flaschengeist.plugins import Plugin
from flaschengeist.plugins import BasePlugin
from flaschengeist.models.user import User
from flaschengeist.utils.decorators import login_required, extract_session, headers
from flaschengeist.controller import userController
@ -17,7 +17,7 @@ from flaschengeist.utils.HTTP import created, no_content
from flaschengeist.utils.datetime import from_iso_format
class UsersPlugin(Plugin):
class UsersPlugin(BasePlugin):
blueprint = Blueprint("users", __name__)
permissions = permissions.permissions