Feat/admin dashboard (#1098)

This commit is contained in:
Christopher Terrazas 2023-06-06 18:53:34 +00:00 committed by GitHub
parent 035ee79e0a
commit d793e7a4dd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 246 additions and 5 deletions

27
poetry.lock generated
View File

@ -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"

View File

@ -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
pynecone/admin.py Normal file
View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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)

View File

@ -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:

View File

@ -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}"
)

View File

@ -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]

View File

@ -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.