Răsfoiți Sursa

Feat/admin dashboard (#1098)

Christopher Terrazas 1 an în urmă
părinte
comite
d793e7a4dd
10 a modificat fișierele cu 246 adăugiri și 5 ștergeri
  1. 25 2
      poetry.lock
  2. 1 0
      pynecone/__init__.py
  3. 13 0
      pynecone/admin.py
  4. 28 0
      pynecone/app.py
  5. 7 0
      pynecone/config.py
  6. 22 2
      pynecone/model.py
  7. 3 0
      pynecone/pc.py
  8. 28 0
      pynecone/utils/prerequisites.py
  9. 1 0
      pyproject.toml
  10. 118 1
      tests/test_app.py

+ 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]]
 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"

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

+ 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.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:

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

+ 22 - 2
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)

+ 3 - 0
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:

+ 28 - 0
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}"
+        )

+ 1 - 0
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]

+ 118 - 1
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.