diff --git a/poetry.lock b/poetry.lock index 2144989d0..df1271cfd 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.4.0 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.4.2 and should not be changed by hand. [[package]] name = "anyio" @@ -1526,6 +1526,29 @@ typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\"" [package.extras] full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart", "pyyaml"] +[[package]] +name = "starlette-admin" +version = "0.9.0" +description = "Fast, beautiful and extensible administrative interface framework for Starlette/FastApi applications" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "starlette_admin-0.9.0-py3-none-any.whl", hash = "sha256:96e5550b6e9611b129f5ab8932c33c31057321a2a9c9c478f27a4340ff64a194"}, + {file = "starlette_admin-0.9.0.tar.gz", hash = "sha256:0481355da1fb547cda216eb83f3db6f0f7b830258f5b00a2b4a0b8ad70ff8625"}, +] + +[package.dependencies] +jinja2 = ">=3,<4" +python-multipart = "*" +starlette = "*" + +[package.extras] +dev = ["pre-commit (>=2.20.0,<4.0.0)", "uvicorn (>=0.20.0,<0.23.0)"] +doc = ["mkdocs (>=1.4.2,<2.0.0)", "mkdocs-material (>=9.0.0,<10.0.0)", "mkdocs-static-i18n (>=0.53.0,<0.57.0)", "mkdocstrings[python] (>=0.19.0,<0.22.0)"] +i18n = ["babel (>=2.12.1)"] +test = ["aiomysql (>=0.1.1,<0.2.0)", "aiosqlite (>=0.17.0,<0.20.0)", "arrow (>=1.2.3,<1.3.0)", "asyncpg (>=0.27.0,<0.28.0)", "backports-zoneinfo", "black (==23.3.0)", "colour (>=0.1.5,<0.2.0)", "coverage (>=7.0.0,<7.3.0)", "fasteners (==0.18)", "httpx (>=0.23.3,<0.25.0)", "itsdangerous (>=2.1.2,<2.2.0)", "mongoengine (>=0.25.0,<0.28.0)", "mypy (==1.3.0)", "odmantic (>=0.9.0,<0.10.0)", "passlib (>=1.7.4,<1.8.0)", "phonenumbers (>=8.13.3,<8.14.0)", "pillow (>=9.4.0,<9.6.0)", "psycopg2-binary (>=2.9.5,<3.0.0)", "pydantic[email] (>=1.10.2,<2.0.0)", "pymysql[rsa] (>=1.0.2,<1.1.0)", "pytest (>=7.2.0,<7.4.0)", "pytest-asyncio (>=0.20.2,<0.22.0)", "ruff (==0.0.261)", "sqlalchemy-file (>=0.4.0,<0.5.0)", "sqlalchemy-utils (>=0.40.0,<0.42.0)", "tinydb (>=4.7.0,<4.8.0)"] + [[package]] name = "tenacity" version = "8.2.2" @@ -1812,4 +1835,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more [metadata] lock-version = "2.0" python-versions = "^3.7" -content-hash = "5fb5d2f286d176b41b11f6e35ce1dbf4890d9553c673aef3d59bffdecc75f07a" +content-hash = "8d9847174be44200843831d75f0f58e35a47619a11384606df14a7d64cee003f" diff --git a/pynecone/__init__.py b/pynecone/__init__.py index 946ffb1d7..c3f288a5b 100644 --- a/pynecone/__init__.py +++ b/pynecone/__init__.py @@ -6,6 +6,7 @@ we use the Flask "import name as name" syntax. """ from . import el as el +from .admin import AdminDash as AdminDash from .app import App as App from .app import UploadFile as UploadFile from .base import Base as Base diff --git a/pynecone/admin.py b/pynecone/admin.py new file mode 100644 index 000000000..361ab5dfb --- /dev/null +++ b/pynecone/admin.py @@ -0,0 +1,13 @@ +"""The Pynecone Admin Dashboard.""" +from dataclasses import dataclass, field +from typing import Optional + +from starlette_admin.base import BaseAdmin as Admin + + +@dataclass +class AdminDash: + """Data used to build the admin dashboard.""" + + models: list = field(default_factory=list) + admin: Optional[Admin] = None diff --git a/pynecone/app.py b/pynecone/app.py index a4b970e93..410713455 100644 --- a/pynecone/app.py +++ b/pynecone/app.py @@ -18,8 +18,11 @@ from typing import ( from fastapi import FastAPI, UploadFile from fastapi.middleware import cors from socketio import ASGIApp, AsyncNamespace, AsyncServer +from starlette_admin.contrib.sqla.admin import Admin +from starlette_admin.contrib.sqla.view import ModelView from pynecone import constants +from pynecone.admin import AdminDash from pynecone.base import Base from pynecone.compiler import compiler from pynecone.compiler import utils as compiler_utils @@ -76,6 +79,9 @@ class App(Base): # List of event handlers to trigger when a page loads. load_events: Dict[str, List[EventHandler]] = {} + # Admin dashboard + admin_dash: Optional[AdminDash] = None + # The component to render if there is a connection error to the server. connect_error_component: Optional[Component] = None @@ -126,6 +132,9 @@ class App(Base): # Mount the socket app with the API. self.api.mount(str(constants.Endpoint.EVENT), self.socket_app) + # Set up the admin dash. + self.setup_admin_dash() + def __repr__(self) -> str: """Get the string representation of the app. @@ -389,6 +398,25 @@ class App(Base): ): self.pages[froute(constants.SLUG_404)] = component + def setup_admin_dash(self): + """Setup the admin dash.""" + # Get the config. + config = get_config() + if config.enable_admin and config.admin_dash and config.admin_dash.models: + # Build the admin dashboard + admin = ( + config.admin_dash.admin + if config.admin_dash.admin + else Admin( + engine=Model.get_db_engine(), + title="Pynecone Admin Dashboard", + logo_url="https://pynecone.io/logo.png", + ) + ) + for model in config.admin_dash.models: + admin.add_view(ModelView(model)) + admin.mount_to(self.api) + def compile(self): """Compile the app and output it to the pages folder.""" for render, kwargs in DECORATED_ROUTES: diff --git a/pynecone/config.py b/pynecone/config.py index 427d9c313..ca2e29ebd 100644 --- a/pynecone/config.py +++ b/pynecone/config.py @@ -11,6 +11,7 @@ from typing import List, Optional from dotenv import load_dotenv from pynecone import constants +from pynecone.admin import AdminDash from pynecone.base import Base @@ -173,6 +174,12 @@ class Config(Base): # Additional frontend packages to install. frontend_packages: List[str] = [] + # Enable the admin dash. + enable_admin: bool = False + + # The Admin Dash + admin_dash: Optional[AdminDash] = None + # Backend transport methods. backend_transports: Optional[ constants.Transports diff --git a/pynecone/model.py b/pynecone/model.py index 367ca74c9..b5959ce5c 100644 --- a/pynecone/model.py +++ b/pynecone/model.py @@ -18,9 +18,14 @@ def get_engine(): ValueError: If the database url is None. """ url = get_config().db_url + enable_admin = get_config().enable_admin if not url: raise ValueError("No database url in config") - return sqlmodel.create_engine(url, echo=False) + return sqlmodel.create_engine( + url, + echo=False, + connect_args={"check_same_thread": False} if enable_admin else {}, + ) class Model(Base, sqlmodel.SQLModel): @@ -58,6 +63,15 @@ class Model(Base, sqlmodel.SQLModel): engine = get_engine() sqlmodel.SQLModel.metadata.create_all(engine) + @staticmethod + def get_db_engine(): + """Get the database engine. + + Returns: + The database engine. + """ + return get_engine() + @classmethod @property def select(cls): @@ -78,7 +92,13 @@ def session(url=None): Returns: A database session. """ + enable_admin = get_config().enable_admin if url is not None: - return sqlmodel.Session(sqlmodel.create_engine(url)) + return sqlmodel.Session( + sqlmodel.create_engine( + url, + connect_args={"check_same_thread": False} if enable_admin else {}, + ), + ) engine = get_engine() return sqlmodel.Session(engine) diff --git a/pynecone/pc.py b/pynecone/pc.py index e045d8360..9ae3c6f5d 100644 --- a/pynecone/pc.py +++ b/pynecone/pc.py @@ -125,6 +125,9 @@ def run( console.rule("[bold]Starting Pynecone App") app = prerequisites.get_app() + # Check the admin dashboard settings. + prerequisites.check_admin_settings() + # Get the frontend and backend commands, based on the environment. frontend_cmd = backend_cmd = None if env == constants.Env.DEV: diff --git a/pynecone/utils/prerequisites.py b/pynecone/utils/prerequisites.py index 56766271b..ee1f906fb 100644 --- a/pynecone/utils/prerequisites.py +++ b/pynecone/utils/prerequisites.py @@ -8,6 +8,7 @@ import platform import re import subprocess import sys +from datetime import datetime from pathlib import Path from types import ModuleType from typing import Optional @@ -334,3 +335,30 @@ def is_latest_template() -> bool: with open(constants.PCVERSION_APP_FILE) as f: # type: ignore app_version = json.load(f)["version"] return app_version == constants.VERSION + + +def check_admin_settings(): + """Check if admin settings are set and valid for logging in cli app.""" + admin_enabled = get_config().enable_admin + admin_dash = get_config().admin_dash + current_time = datetime.now() + if admin_enabled and admin_dash: + if not admin_dash.models: + console.print( + f"[yellow][Admin Dashboard][/yellow] :megaphone: Admin dashboard enabled, but no models defined in [bold magenta]pcconfig.py[/bold magenta]. Time: {current_time}" + ) + else: + console.print( + f"[yellow][Admin Dashboard][/yellow] Admin enabled, building admin dashboard. Time: {current_time}" + ) + console.print( + "Admin dashboard running at: [bold green]http://localhost:8000/admin[/bold green]" + ) + elif admin_enabled: + console.print( + f"[yellow][Admin Dashboard][/yellow] :megaphone: Admin enabled, but no admin dashboard defined in [bold magenta]pcconfig.py[/bold magenta]. Time: {current_time}" + ) + elif admin_dash: + console.print( + f"[yellow][Admin Dashboard][/yellow] :megaphone: Admin dashboard defined, but admin is not enabled in [bold magenta]pcconfig.py[/bold magenta]. Time: {current_time}" + ) diff --git a/pyproject.toml b/pyproject.toml index d291cf09b..c2b3e2ade 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ typer = "0.4.2" uvicorn = "^0.20.0" watchdog = "^2.3.1" websockets = "^10.4" +starlette-admin = "^0.9.0" python-dotenv = "^0.13.0" [tool.poetry.group.dev.dependencies] diff --git a/tests/test_app.py b/tests/test_app.py index 046c49550..93a7f3590 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -3,13 +3,17 @@ import os.path from typing import List, Tuple, Type import pytest +import sqlmodel from fastapi import UploadFile +from starlette_admin.auth import AuthProvider +from starlette_admin.contrib.sqla.admin import Admin -from pynecone import constants +from pynecone import AdminDash, constants from pynecone.app import App, DefaultState, process, upload from pynecone.components import Box from pynecone.event import Event, get_hydrate_event from pynecone.middleware import HydrateMiddleware +from pynecone.model import Model from pynecone.state import State, StateUpdate from pynecone.style import Style from pynecone.utils import format @@ -68,6 +72,85 @@ def test_state() -> Type[State]: return TestState +@pytest.fixture() +def test_model() -> Type[Model]: + """A default model. + + Returns: + A default model. + """ + + class TestModel(Model): + pass + + return TestModel + + +@pytest.fixture() +def test_model_auth() -> Type[Model]: + """A default model. + + Returns: + A default model. + """ + + class TestModelAuth(Model): + """A test model with auth.""" + + pass + + return TestModelAuth + + +@pytest.fixture() +def test_get_engine(): + """A default database engine. + + Returns: + A default database engine. + """ + enable_admin = True + url = "sqlite:///test.db" + return sqlmodel.create_engine( + url, + echo=False, + connect_args={"check_same_thread": False} if enable_admin else {}, + ) + + +@pytest.fixture() +def test_custom_auth_admin() -> Type[AuthProvider]: + """A default auth provider. + + Returns: + A default default auth provider. + """ + + class TestAuthProvider(AuthProvider): + """A test auth provider.""" + + login_path: str = "/login" + logout_path: str = "/logout" + + def login(self): + """Login.""" + pass + + def is_authenticated(self): + """Is authenticated.""" + pass + + def get_admin_user(self): + """Get admin user.""" + pass + + def logout(self): + """Logout.""" + pass + + return TestAuthProvider + + def test_default_app(app: App): """Test creating an app with no args. @@ -77,6 +160,7 @@ def test_default_app(app: App): assert app.state() == DefaultState() assert app.middleware == [HydrateMiddleware()] assert app.style == Style() + assert app.admin_dash is None def test_add_page_default_route(app: App, index_page, about_page): @@ -143,6 +227,39 @@ def test_add_page_set_route_nested(app: App, index_page, windows_platform: bool) assert set(app.pages.keys()) == {route.strip(os.path.sep)} +def test_initialize_with_admin_dashboard(test_model): + """Test setting the admin dashboard of an app. + + Args: + test_model: The default model. + """ + app = App(admin_dash=AdminDash(models=[test_model])) + assert app.admin_dash is not None + assert len(app.admin_dash.models) > 0 + assert app.admin_dash.models[0] == test_model + + +def test_initialize_with_custom_admin_dashboard( + test_get_engine, + test_custom_auth_admin, + test_model_auth, +): + """Test setting the custom admin dashboard of an app. + + Args: + test_get_engine: The default database engine. + test_model_auth: The default model for an auth admin dashboard. + test_custom_auth_admin: The custom auth provider. + """ + custom_admin = Admin(engine=test_get_engine, auth_provider=test_custom_auth_admin) + app = App(admin_dash=AdminDash(models=[test_model_auth], admin=custom_admin)) + assert app.admin_dash is not None + assert app.admin_dash.admin is not None + assert len(app.admin_dash.models) > 0 + assert app.admin_dash.models[0] == test_model_auth + assert app.admin_dash.admin.auth_provider == test_custom_auth_admin + + def test_initialize_with_state(test_state): """Test setting the state of an app.