Kaynağa Gözat

Feat/admin dashboard (#1098)

Christopher Terrazas 1 yıl önce
ebeveyn
işleme
d793e7a4dd

+ 25 - 2
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]]
 [[package]]
 name = "anyio"
 name = "anyio"
@@ -1526,6 +1526,29 @@ typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""
 [package.extras]
 [package.extras]
 full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart", "pyyaml"]
 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]]
 [[package]]
 name = "tenacity"
 name = "tenacity"
 version = "8.2.2"
 version = "8.2.2"
@@ -1812,4 +1835,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more
 [metadata]
 [metadata]
 lock-version = "2.0"
 lock-version = "2.0"
 python-versions = "^3.7"
 python-versions = "^3.7"
-content-hash = "5fb5d2f286d176b41b11f6e35ce1dbf4890d9553c673aef3d59bffdecc75f07a"
+content-hash = "8d9847174be44200843831d75f0f58e35a47619a11384606df14a7d64cee003f"

+ 1 - 0
pynecone/__init__.py

@@ -6,6 +6,7 @@ we use the Flask "import name as name" syntax.
 """
 """
 
 
 from . import el as el
 from . import el as el
+from .admin import AdminDash as AdminDash
 from .app import App as App
 from .app import App as App
 from .app import UploadFile as UploadFile
 from .app import UploadFile as UploadFile
 from .base import Base as Base
 from .base import Base as Base

+ 13 - 0
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

+ 28 - 0
pynecone/app.py

@@ -18,8 +18,11 @@ from typing import (
 from fastapi import FastAPI, UploadFile
 from fastapi import FastAPI, UploadFile
 from fastapi.middleware import cors
 from fastapi.middleware import cors
 from socketio import ASGIApp, AsyncNamespace, AsyncServer
 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 import constants
+from pynecone.admin import AdminDash
 from pynecone.base import Base
 from pynecone.base import Base
 from pynecone.compiler import compiler
 from pynecone.compiler import compiler
 from pynecone.compiler import utils as compiler_utils
 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.
     # List of event handlers to trigger when a page loads.
     load_events: Dict[str, List[EventHandler]] = {}
     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.
     # The component to render if there is a connection error to the server.
     connect_error_component: Optional[Component] = None
     connect_error_component: Optional[Component] = None
 
 
@@ -126,6 +132,9 @@ class App(Base):
         # Mount the socket app with the API.
         # Mount the socket app with the API.
         self.api.mount(str(constants.Endpoint.EVENT), self.socket_app)
         self.api.mount(str(constants.Endpoint.EVENT), self.socket_app)
 
 
+        # Set up the admin dash.
+        self.setup_admin_dash()
+
     def __repr__(self) -> str:
     def __repr__(self) -> str:
         """Get the string representation of the app.
         """Get the string representation of the app.
 
 
@@ -389,6 +398,25 @@ class App(Base):
         ):
         ):
             self.pages[froute(constants.SLUG_404)] = component
             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):
     def compile(self):
         """Compile the app and output it to the pages folder."""
         """Compile the app and output it to the pages folder."""
         for render, kwargs in DECORATED_ROUTES:
         for render, kwargs in DECORATED_ROUTES:

+ 7 - 0
pynecone/config.py

@@ -11,6 +11,7 @@ from typing import List, Optional
 from dotenv import load_dotenv
 from dotenv import load_dotenv
 
 
 from pynecone import constants
 from pynecone import constants
+from pynecone.admin import AdminDash
 from pynecone.base import Base
 from pynecone.base import Base
 
 
 
 
@@ -173,6 +174,12 @@ class Config(Base):
     # Additional frontend packages to install.
     # Additional frontend packages to install.
     frontend_packages: List[str] = []
     frontend_packages: List[str] = []
 
 
+    # Enable the admin dash.
+    enable_admin: bool = False
+
+    # The Admin Dash
+    admin_dash: Optional[AdminDash] = None
+
     # Backend transport methods.
     # Backend transport methods.
     backend_transports: Optional[
     backend_transports: Optional[
         constants.Transports
         constants.Transports

+ 22 - 2
pynecone/model.py

@@ -18,9 +18,14 @@ def get_engine():
         ValueError: If the database url is None.
         ValueError: If the database url is None.
     """
     """
     url = get_config().db_url
     url = get_config().db_url
+    enable_admin = get_config().enable_admin
     if not url:
     if not url:
         raise ValueError("No database url in config")
         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):
 class Model(Base, sqlmodel.SQLModel):
@@ -58,6 +63,15 @@ class Model(Base, sqlmodel.SQLModel):
         engine = get_engine()
         engine = get_engine()
         sqlmodel.SQLModel.metadata.create_all(engine)
         sqlmodel.SQLModel.metadata.create_all(engine)
 
 
+    @staticmethod
+    def get_db_engine():
+        """Get the database engine.
+
+        Returns:
+            The database engine.
+        """
+        return get_engine()
+
     @classmethod
     @classmethod
     @property
     @property
     def select(cls):
     def select(cls):
@@ -78,7 +92,13 @@ def session(url=None):
     Returns:
     Returns:
         A database session.
         A database session.
     """
     """
+    enable_admin = get_config().enable_admin
     if url is not None:
     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()
     engine = get_engine()
     return sqlmodel.Session(engine)
     return sqlmodel.Session(engine)

+ 3 - 0
pynecone/pc.py

@@ -125,6 +125,9 @@ def run(
     console.rule("[bold]Starting Pynecone App")
     console.rule("[bold]Starting Pynecone App")
     app = prerequisites.get_app()
     app = prerequisites.get_app()
 
 
+    # Check the admin dashboard settings.
+    prerequisites.check_admin_settings()
+
     # Get the frontend and backend commands, based on the environment.
     # Get the frontend and backend commands, based on the environment.
     frontend_cmd = backend_cmd = None
     frontend_cmd = backend_cmd = None
     if env == constants.Env.DEV:
     if env == constants.Env.DEV:

+ 28 - 0
pynecone/utils/prerequisites.py

@@ -8,6 +8,7 @@ import platform
 import re
 import re
 import subprocess
 import subprocess
 import sys
 import sys
+from datetime import datetime
 from pathlib import Path
 from pathlib import Path
 from types import ModuleType
 from types import ModuleType
 from typing import Optional
 from typing import Optional
@@ -334,3 +335,30 @@ def is_latest_template() -> bool:
     with open(constants.PCVERSION_APP_FILE) as f:  # type: ignore
     with open(constants.PCVERSION_APP_FILE) as f:  # type: ignore
         app_version = json.load(f)["version"]
         app_version = json.load(f)["version"]
     return app_version == constants.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}"
+        )

+ 1 - 0
pyproject.toml

@@ -41,6 +41,7 @@ typer = "0.4.2"
 uvicorn = "^0.20.0"
 uvicorn = "^0.20.0"
 watchdog = "^2.3.1"
 watchdog = "^2.3.1"
 websockets = "^10.4"
 websockets = "^10.4"
+starlette-admin = "^0.9.0"
 python-dotenv = "^0.13.0"
 python-dotenv = "^0.13.0"
 
 
 [tool.poetry.group.dev.dependencies]
 [tool.poetry.group.dev.dependencies]

+ 118 - 1
tests/test_app.py

@@ -3,13 +3,17 @@ import os.path
 from typing import List, Tuple, Type
 from typing import List, Tuple, Type
 
 
 import pytest
 import pytest
+import sqlmodel
 from fastapi import UploadFile
 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.app import App, DefaultState, process, upload
 from pynecone.components import Box
 from pynecone.components import Box
 from pynecone.event import Event, get_hydrate_event
 from pynecone.event import Event, get_hydrate_event
 from pynecone.middleware import HydrateMiddleware
 from pynecone.middleware import HydrateMiddleware
+from pynecone.model import Model
 from pynecone.state import State, StateUpdate
 from pynecone.state import State, StateUpdate
 from pynecone.style import Style
 from pynecone.style import Style
 from pynecone.utils import format
 from pynecone.utils import format
@@ -68,6 +72,85 @@ def test_state() -> Type[State]:
     return TestState
     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):
 def test_default_app(app: App):
     """Test creating an app with no args.
     """Test creating an app with no args.
 
 
@@ -77,6 +160,7 @@ def test_default_app(app: App):
     assert app.state() == DefaultState()
     assert app.state() == DefaultState()
     assert app.middleware == [HydrateMiddleware()]
     assert app.middleware == [HydrateMiddleware()]
     assert app.style == Style()
     assert app.style == Style()
+    assert app.admin_dash is None
 
 
 
 
 def test_add_page_default_route(app: App, index_page, about_page):
 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)}
     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):
 def test_initialize_with_state(test_state):
     """Test setting the state of an app.
     """Test setting the state of an app.