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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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