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]]
|
||||
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"
|
||||
|
@ -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
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.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:
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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:
|
||||
|
@ -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}"
|
||||
)
|
||||
|
@ -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]
|
||||
|
@ -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.
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user