Feat/admin dashboard (#1098)
This commit is contained in:
parent
035ee79e0a
commit
d793e7a4dd
27
poetry.lock
generated
27
poetry.lock
generated
@ -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"
|
||||||
|
@ -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
13
pynecone/admin.py
Normal 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
|
@ -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:
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
|
@ -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:
|
||||||
|
@ -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}"
|
||||||
|
)
|
||||||
|
@ -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]
|
||||||
|
@ -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.
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user