diff --git a/keyosk/database/__init__.py b/keyosk/database/__init__.py new file mode 100644 index 0000000..9026813 --- /dev/null +++ b/keyosk/database/__init__.py @@ -0,0 +1,95 @@ +"""Database interface and tooling module + +Keyosk uses the +`Peewee Object Relational Model `_ +Python library for its database interface. The database interface object is available as the +``interface`` variable under the ``keyosk.database`` namespace. The individual models (a.k.a. +database tables) are available in the same namespace. + +The database connection can be accessed using the +`Peewee atomic transaction context manager `_. +Similarly, the ORM models- which correspond to database tables- can be accessed in the same way: + +:: + + from keyosk import database + + with database.interface.atomic(): + database.Account.get(username == "atticusfinch") +""" +import logging +from typing import List +from typing import Type + +import peewee + +from keyosk import config +from keyosk import datatypes +from keyosk.database._shared import INTERFACE as interface +from keyosk.database._shared import KeyoskBaseModel +from keyosk.database.account import Account +from keyosk.database.domain import Domain +from keyosk.database.mappings import AccountACL +from keyosk.database.mappings import AccountAssignment +from keyosk.database.mappings import DomainAccessList +from keyosk.database.mappings import DomainAdmin +from keyosk.database.mappings import DomainPermission + + +MODELS: List[Type[KeyoskBaseModel]] = [ + Account, + Domain, + DomainAccessList, + DomainPermission, + DomainAdmin, + AccountACL, + AccountAssignment, +] + + +def initialize(conf: config.KeyoskConfig): + """Initialize the database interface + + Defining the database as an + `unconfigured proxy object `_ + allows it to be configured at runtime based on the config values. + + :param config: Populated configuration container object + """ + + logger = logging.getLogger(__name__) + + if conf.storage.backend == datatypes.StorageBackend.SQLITE: + logger.debug("Using SQLite database backend") + pragmas = { + **conf.storage.sqlite.pragmas, + **{ + "journal_mode": "wal", + "cache_size": -1 * 64000, + "foreign_keys": 1, + "ignore_check_constraints": 0, + "synchronous": 0, + }, + } + for key, value in pragmas: + logger.debug(f"Applying pragma '{key}' with value '{value}'") + database = peewee.SqliteDatabase(conf.storage.sqlite.path, pragmas=pragmas) + + elif conf.storage.backend == datatypes.StorageBackend.MARIA: + logger.debug("Using MariaDB database backend") + database = peewee.MySQLDatabase( + conf.storage.maria.schema, + host=conf.storage.maria.host, + port=conf.storage.maria.port, + user=conf.storage.maria.username, + password=conf.storage.maria.password, + charset="utf8mb4", + ) + logger.debug( + f"Configuring MariaDB: {conf.storage.maria.username}@{conf.storage.maria.host}:{conf.storage.maria.port} `{conf.storage.maria.schema}`" + ) + + interface.initialize(database) + + with interface.atomic(): + interface.create_tables(MODELS) diff --git a/keyosk/database/_shared.py b/keyosk/database/_shared.py new file mode 100644 index 0000000..a165769 --- /dev/null +++ b/keyosk/database/_shared.py @@ -0,0 +1,78 @@ +"""Internally shared database components + +This submodule exists to avoid circular imports: architecturally there's no reason why +this module's base model and the :func:`initialize` function cannot both go in +``__init__``, or indeed why they can't both go here. However if both existed in the same +module then every other submodule would need to both import :class:`KeyoskBaseModel` +from that module, and be imported into that module for the :func:`initialize` function +to work. This would lead to a circular import. + +Thus the :func:`initialize` function and :class:`KeyoskBaseModel` class need to go in +separate modules, and for somewhat arbitrary reasons the base model was put here and the +init function kept in init. +""" +from typing import Any +from typing import Generator +from typing import List +from typing import Tuple + +import peewee + + +INTERFACE = peewee.DatabaseProxy() + + +class KeyoskBaseModel(peewee.Model): + """Base model for primary models to inherit from + + * Attaches the ``uuid`` field to the model as the primary key + * Attaches the model- and all child models- to the database proxy + * Provides the structure for casting the model to a dictionary + + .. warning:: This model is a stub and should not be created in the database or + used for querying. + """ + + class Meta: # pylint: disable=missing-docstring,too-few-public-methods + database = INTERFACE + + uuid = peewee.UUIDField(null=False, unique=True, primary_key=True) + + @staticmethod + def dict_keys() -> List[str]: + """Return tuple of attribute names that should be included in the dict form of the model + Inteneded to be used in a dictionary comprehension; see the :meth:`__iter__` method for + usage example. + """ + return [] + + @staticmethod + def foreign_ref() -> List[str]: + """Return tuple of attribute names that point to foreign key references on the model + Intended for usage when recursively converting models into dictionaries ahead of + serialization; see the :meth:`__iter__` method for usage example. + + .. warning:: Foreign keys should only be included here when their attribute appears in the + tuple returned from :meth:`dict_keys` + """ + return [] + + @staticmethod + def foreign_backref() -> List[str]: + """Return tuple of attribute names that point to foreign backreferences on the model + Inteneded for usage when recursively converting models into dictionaries ahead of + serialization; see the :meth:`__iter__` method for usage example. + + .. warning:: Foreign keys should only be included here when their attribute appears in the + tuple returned from :meth:`dict_keys` + """ + return [] + + def __iter__(self) -> Generator[Tuple[str, Any], None, None]: + for key in self.dict_keys(): + if key in self.foreign_ref(): + yield key, dict(getattr(self, key)) + elif key in self.foreign_backref(): + yield key, [dict(item) for item in getattr(self, key)] + else: + yield key, getattr(self, key) diff --git a/keyosk/datatypes.py b/keyosk/datatypes.py new file mode 100644 index 0000000..85f1ff6 --- /dev/null +++ b/keyosk/datatypes.py @@ -0,0 +1,17 @@ +import enum +from typing import Dict +from typing import Union + + +Extras = Dict[str, Union[int, float, bool, str, None]] + + +class TokenUsage(enum.Enum): + REFRESH = enum.auto() + ACCESS = enum.auto() + + +@enum.unique +class StorageBackend(enum.Enum): + SQLITE = "sqlite" + MARIA = "maria"