import io import sys from pathlib import Path from types import SimpleNamespace from dotenv import dotenv_values from flask import Flask from flask_session import Session from grung.db import GrungDB from tinydb import where from tinydb.storages import MemoryStorage from ttfrog import schema from ttfrog.exceptions import ApplicationNotInitializedError class ApplicationContext: """ The global context for the application, this class provides access to the Flask app instance, the GrungDB instance, and the loaded configuration. To prevent multiple contexts from being created, the class is instantiated at import time and replaces the module in the symbol table. The first time it is imported, callers should call both .load_config() and .initialize(); this is typically done at program start. After being intialized, callers can import ttfrog.app and interact with the ApplicationContext instance directly: >>> from ttfrog import app >>> print(app.config.NAME) ttfrog """ CONFIG_DEFAULTS = """ # ttfrog Defaults NAME=ttfrog LOG_LEVEL=INFO SECRET_KEY=fnord IN_MEMORY_DB= DATA_ROOT=~/.dnd/ttfrog/ ADMIN_USERNAME=admin ADMIN_EMAIL=admin@telisar THEME=default VIEW_URI=/ """ def __init__(self): self.config: SimpleNamespace = None self.web: Flask = None self.db: GrungDB = None self._initialized = False def load_config(self, defaults: Path | None = Path("~/.dnd/ttfrog/defaults"), **overrides) -> None: """ Load the user configuration from the following in sources, in order: 1. ApplicationContext.CONFIG_DEFAULTS 2. The user's configuration defaults file, if any 3. Overrides specified by the caller, if any Once the configuration is loaded, the path attribute is also configured. """ config_file = defaults.expanduser() if defaults else None self.config = SimpleNamespace( **{ **dotenv_values(stream=io.StringIO(ApplicationContext.CONFIG_DEFAULTS)), **(dotenv_values(config_file) if config_file else {}), **overrides, } ) data_root = Path(self.config.DATA_ROOT).expanduser() self.path = SimpleNamespace( config=config_file, data_root=data_root, database=data_root / f"{self.config.NAME}.json", sessions=data_root / "session_cache", ) def initialize(self, db: GrungDB = None, force: bool = False) -> None: """ Instantiate both the database and the flask application. """ if force or not self._initialized: if db: self.db = db elif self.config.IN_MEMORY_DB: self.db = GrungDB.with_schema(schema, storage=MemoryStorage) else: self.db = GrungDB.with_schema( schema, self.path.database, sort_keys=True, indent=4, separators=(",", ": ") ) self.theme = Path(__file__).parent / "themes" / "default" self.web = Flask(self.config.NAME, template_folder=self.theme) self.web.config["SECRET_KEY"] = self.config.SECRET_KEY self.web.config["SEND_FILE_MAX_AGE_DEFAULT"] = 0 self.web.config["DEBUG"] = True self.web.config["SESSION_TYPE"] = "filesystem" self.web.config["SESSION_REFRESH_EACH_REQUEST"] = True self.web.config["SESSION_FILE_DIR"] = self.path.sessions Session(self.web) self._initialized = True def check_state(self) -> None: if not self._initialized: raise ApplicationNotInitializedError("This action requires the application to be initialized.") def authenticate(self, username: str, password: str) -> schema.User: """ Returns the User record matching the given username and password """ if not (username and password): self.web.logger.debug("Need both username and password to login") return None user = self.db.User.get(where("name") == username) if not user: self.web.logger.debug(f"No user matching {username}") return None if not user.check_credentials(username, password): self.web.logger.debug(f"Invalid credentials for {username}") return None return user def authorize(self, user, record, requested): return user.has_permission(record, requested) sys.modules[__name__] = ApplicationContext()