Add project scaffolding and documentation
Add .env.example with configuration for database, Redis, security, SMTP, workers, and plugins. Add .gitignore for Python, Node.js, Next.js, Docker volumes, and IDE files. Add MIT License. Update README.md with feature overview, quick start guide, architecture description, plugin system documentation, security details, backup/restore instructions, and developer setup. Add Alembic configuration files and placeholder directories for API, web, worker, and plugin components
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1,86 @@
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from apps.api.src.config import settings
|
||||
from apps.api.src.models.base import Base
|
||||
|
||||
# Import all models so Alembic can detect them
|
||||
from apps.api.src.models import ( # noqa: F401
|
||||
audit_log,
|
||||
background_job,
|
||||
dashboard,
|
||||
notification,
|
||||
plugin,
|
||||
plugin_instance,
|
||||
plugin_log,
|
||||
plugin_version,
|
||||
role,
|
||||
secret,
|
||||
service_connection,
|
||||
session,
|
||||
system_setting,
|
||||
user,
|
||||
widget,
|
||||
api_token,
|
||||
)
|
||||
|
||||
config = context.config
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def get_url() -> str:
|
||||
return settings.DATABASE_URL
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
url = get_url()
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
configuration = config.get_section(config.config_ini_section, {})
|
||||
configuration["sqlalchemy.url"] = get_url()
|
||||
|
||||
connectable = async_engine_from_config(
|
||||
configuration,
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,371 @@
|
||||
"""initial
|
||||
|
||||
Revision ID: 20240620_0001
|
||||
Revises:
|
||||
Create Date: 2024-06-20 00:00:00
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "20240620_0001"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"roles",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("name", sa.String(length=50), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("is_system", sa.Boolean(), nullable=False),
|
||||
sa.Column("permissions", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("name"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"system_settings",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("key", sa.String(length=100), nullable=False),
|
||||
sa.Column("value", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("is_sensitive", sa.Boolean(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("key"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("email", sa.String(length=255), nullable=False),
|
||||
sa.Column("hashed_password", sa.String(length=255), nullable=False),
|
||||
sa.Column("first_name", sa.String(length=100), nullable=True),
|
||||
sa.Column("last_name", sa.String(length=100), nullable=True),
|
||||
sa.Column("avatar_url", sa.String(length=500), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("is_superuser", sa.Boolean(), nullable=False),
|
||||
sa.Column("is_owner", sa.Boolean(), nullable=False),
|
||||
sa.Column("email_verified", sa.Boolean(), nullable=False),
|
||||
sa.Column("locale", sa.String(length=10), nullable=False),
|
||||
sa.Column("theme", sa.String(length=20), nullable=False),
|
||||
sa.Column("timezone", sa.String(length=50), nullable=False),
|
||||
sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_login_ip", sa.String(length=45), nullable=True),
|
||||
sa.Column("login_attempts", sa.Integer(), nullable=False),
|
||||
sa.Column("locked_until", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("invite_token", sa.String(length=255), nullable=True),
|
||||
sa.Column("invite_token_expires", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("password_reset_token", sa.String(length=255), nullable=True),
|
||||
sa.Column("password_reset_expires", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("preferences", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column("role_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["role_id"], ["roles.id"], ondelete="SET NULL"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("email"),
|
||||
)
|
||||
op.create_index("ix_users_email", "users", ["email"], unique=False)
|
||||
op.create_index("ix_users_invite_token", "users", ["invite_token"], unique=False)
|
||||
op.create_index("ix_users_password_reset_token", "users", ["password_reset_token"], unique=False)
|
||||
op.create_index("ix_users_role_id", "users", ["role_id"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"api_tokens",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("name", sa.String(length=100), nullable=False),
|
||||
sa.Column("token_hash", sa.String(length=255), nullable=False),
|
||||
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_used_ip", sa.String(length=45), nullable=True),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("scopes", sa.Text(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("token_hash"),
|
||||
)
|
||||
op.create_index("ix_api_tokens_user_id", "api_tokens", ["user_id"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"audit_logs",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("action", sa.String(length=100), nullable=False),
|
||||
sa.Column("resource_type", sa.String(length=100), nullable=False),
|
||||
sa.Column("resource_id", sa.String(length=100), nullable=True),
|
||||
sa.Column("ip_address", sa.String(length=45), nullable=True),
|
||||
sa.Column("user_agent", sa.Text(), nullable=True),
|
||||
sa.Column("details", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column("severity", sa.String(length=20), nullable=False),
|
||||
sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_audit_logs_action", "audit_logs", ["action"], unique=False)
|
||||
op.create_index("ix_audit_logs_user_id", "audit_logs", ["user_id"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"encrypted_secrets",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("name", sa.String(length=200), nullable=False),
|
||||
sa.Column("owner_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("encrypted_value", sa.Text(), nullable=False),
|
||||
sa.Column("secret_type", sa.String(length=50), nullable=False),
|
||||
sa.Column("scope", sa.String(length=100), nullable=False),
|
||||
sa.Column("metadata", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], ondelete="SET NULL"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_encrypted_secrets_owner_id", "encrypted_secrets", ["owner_id"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"notifications",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("title", sa.String(length=200), nullable=False),
|
||||
sa.Column("message", sa.Text(), nullable=True),
|
||||
sa.Column("type", sa.String(length=50), nullable=False),
|
||||
sa.Column("is_read", sa.Boolean(), nullable=False),
|
||||
sa.Column("link", sa.String(length=500), nullable=True),
|
||||
sa.Column("metadata", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_notifications_user_id", "notifications", ["user_id"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"sessions",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("refresh_token_hash", sa.String(length=255), nullable=False),
|
||||
sa.Column("user_agent", sa.Text(), nullable=True),
|
||||
sa.Column("ip_address", sa.String(length=45), nullable=True),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("is_valid", sa.Boolean(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("refresh_token_hash"),
|
||||
)
|
||||
op.create_index("ix_sessions_user_id", "sessions", ["user_id"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"plugins",
|
||||
sa.Column("id", sa.String(length=100), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("name", sa.String(length=200), nullable=False),
|
||||
sa.Column("version", sa.String(length=50), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("author", sa.String(length=200), nullable=True),
|
||||
sa.Column("category", sa.String(length=100), nullable=False),
|
||||
sa.Column("icon", sa.String(length=100), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("is_builtin", sa.Boolean(), nullable=False),
|
||||
sa.Column("is_installed", sa.Boolean(), nullable=False),
|
||||
sa.Column("path", sa.String(length=500), nullable=False),
|
||||
sa.Column("permissions", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column("settings_schema", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column("credentials_schema", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column("widget_types", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column("healthcheck_definition", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column("api_routes", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
sa.Column("has_frontend", sa.Boolean(), nullable=False),
|
||||
sa.Column("manifest", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"dashboards",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("title", sa.String(length=200), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("icon", sa.String(length=50), nullable=True),
|
||||
sa.Column("folder", sa.String(length=100), nullable=False),
|
||||
sa.Column("is_favorite", sa.Boolean(), nullable=False),
|
||||
sa.Column("is_public", sa.Boolean(), nullable=False),
|
||||
sa.Column("owner_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("layout", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column("layouts_by_breakpoint", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column("refresh_interval_seconds", sa.Integer(), nullable=True),
|
||||
sa.Column("order_index", sa.Integer(), nullable=False),
|
||||
sa.Column("tags", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_dashboards_folder", "dashboards", ["folder"], unique=False)
|
||||
op.create_index("ix_dashboards_owner_id", "dashboards", ["owner_id"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"plugin_versions",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("plugin_id", sa.String(length=100), nullable=False),
|
||||
sa.Column("version", sa.String(length=50), nullable=False),
|
||||
sa.Column("changelog", sa.String(), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("package_path", sa.String(length=500), nullable=True),
|
||||
sa.Column("manifest", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.ForeignKeyConstraint(["plugin_id"], ["plugins.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_plugin_versions_plugin_id", "plugin_versions", ["plugin_id"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"service_connections",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("name", sa.String(length=200), nullable=False),
|
||||
sa.Column("plugin_id", sa.String(length=100), nullable=False),
|
||||
sa.Column("base_url", sa.String(length=500), nullable=False),
|
||||
sa.Column("verify_tls", sa.Boolean(), nullable=False),
|
||||
sa.Column("timeout_seconds", sa.Integer(), nullable=False),
|
||||
sa.Column("credentials_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("is_enabled", sa.Boolean(), nullable=False),
|
||||
sa.Column("health_status", sa.String(length=20), nullable=False),
|
||||
sa.Column("health_message", sa.Text(), nullable=True),
|
||||
sa.Column("extra_headers", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.ForeignKeyConstraint(["credentials_id"], ["encrypted_secrets.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["plugin_id"], ["plugins.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_service_connections_credentials_id", "service_connections", ["credentials_id"], unique=False)
|
||||
op.create_index("ix_service_connections_plugin_id", "service_connections", ["plugin_id"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"plugin_instances",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("plugin_id", sa.String(length=100), nullable=False),
|
||||
sa.Column("service_connection_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("name", sa.String(length=200), nullable=False),
|
||||
sa.Column("settings", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column("is_enabled", sa.Boolean(), nullable=False),
|
||||
sa.Column("health_status", sa.String(length=20), nullable=False),
|
||||
sa.Column("health_message", sa.Text(), nullable=True),
|
||||
sa.Column("last_sync_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_error", sa.Text(), nullable=True),
|
||||
sa.Column("error_count", sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["plugin_id"], ["plugins.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["service_connection_id"], ["service_connections.id"], ondelete="SET NULL"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_plugin_instances_plugin_id", "plugin_instances", ["plugin_id"], unique=False)
|
||||
op.create_index("ix_plugin_instances_service_connection_id", "plugin_instances", ["service_connection_id"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"background_jobs",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("task_name", sa.String(length=100), nullable=False),
|
||||
sa.Column("status", sa.String(length=20), nullable=False),
|
||||
sa.Column("payload", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column("result", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column("error", sa.Text(), nullable=True),
|
||||
sa.Column("scheduled_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("worker_id", sa.String(length=100), nullable=True),
|
||||
sa.Column("plugin_id", sa.String(length=100), nullable=True),
|
||||
sa.Column("instance_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["instance_id"], ["plugin_instances.id"], ondelete="SET NULL"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_background_jobs_instance_id", "background_jobs", ["instance_id"], unique=False)
|
||||
op.create_index("ix_background_jobs_plugin_id", "background_jobs", ["plugin_id"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"dashboard_widgets",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("dashboard_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("plugin_id", sa.String(length=100), nullable=False),
|
||||
sa.Column("widget_type", sa.String(length=100), nullable=False),
|
||||
sa.Column("title", sa.String(length=200), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("position_x", sa.Integer(), nullable=False),
|
||||
sa.Column("position_y", sa.Integer(), nullable=False),
|
||||
sa.Column("width", sa.Integer(), nullable=False),
|
||||
sa.Column("height", sa.Integer(), nullable=False),
|
||||
sa.Column("min_width", sa.Integer(), nullable=True),
|
||||
sa.Column("min_height", sa.Integer(), nullable=True),
|
||||
sa.Column("max_width", sa.Integer(), nullable=True),
|
||||
sa.Column("max_height", sa.Integer(), nullable=True),
|
||||
sa.Column("order_index", sa.Integer(), nullable=False),
|
||||
sa.Column("settings", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column("instance_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("is_visible", sa.Boolean(), nullable=False),
|
||||
sa.Column("is_static", sa.Boolean(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["dashboard_id"], ["dashboards.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["instance_id"], ["plugin_instances.id"], ondelete="SET NULL"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_dashboard_widgets_dashboard_id", "dashboard_widgets", ["dashboard_id"], unique=False)
|
||||
op.create_index("ix_dashboard_widgets_instance_id", "dashboard_widgets", ["instance_id"], unique=False)
|
||||
op.create_index("ix_dashboard_widgets_plugin_id", "dashboard_widgets", ["plugin_id"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"plugin_logs",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("plugin_id", sa.String(length=100), nullable=False),
|
||||
sa.Column("instance_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("level", sa.String(length=20), nullable=False),
|
||||
sa.Column("message", sa.Text(), nullable=False),
|
||||
sa.Column("context", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["instance_id"], ["plugin_instances.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["plugin_id"], ["plugins.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_plugin_logs_instance_id", "plugin_logs", ["instance_id"], unique=False)
|
||||
op.create_index("ix_plugin_logs_plugin_id", "plugin_logs", ["plugin_id"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("plugin_logs")
|
||||
op.drop_table("dashboard_widgets")
|
||||
op.drop_table("background_jobs")
|
||||
op.drop_table("plugin_instances")
|
||||
op.drop_table("service_connections")
|
||||
op.drop_table("plugin_versions")
|
||||
op.drop_table("dashboards")
|
||||
op.drop_table("plugins")
|
||||
op.drop_table("sessions")
|
||||
op.drop_table("notifications")
|
||||
op.drop_table("encrypted_secrets")
|
||||
op.drop_table("audit_logs")
|
||||
op.drop_table("api_tokens")
|
||||
op.drop_table("users")
|
||||
op.drop_table("system_settings")
|
||||
op.drop_table("roles")
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@nexadash/api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "python -m uvicorn apps.api.src.main:app --reload",
|
||||
"start": "python -m uvicorn apps.api.src.main:app",
|
||||
"db:upgrade": "alembic upgrade head",
|
||||
"db:seed": "python -m apps.api.src.seed",
|
||||
"test": "pytest"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from apps.api.src.database import get_db
|
||||
from apps.api.src.models.user import User
|
||||
from apps.api.src.schemas import audit_log as schemas
|
||||
from apps.api.src.security.dependencies import PermissionRequired
|
||||
from apps.api.src.services import audit_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[schemas.AuditLogResponse])
|
||||
async def list_audit_logs(
|
||||
user: User = Depends(PermissionRequired("audit:read")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
limit: int = 100,
|
||||
):
|
||||
return await audit_service.list_audit_logs(db, limit=limit)
|
||||
@@ -0,0 +1,143 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from apps.api.src.database import get_db
|
||||
from apps.api.src.models.user import User
|
||||
from apps.api.src.schemas import auth as schemas
|
||||
from apps.api.src.security import encryption, tokens
|
||||
from apps.api.src.security.audit import log_audit
|
||||
from apps.api.src.security.dependencies import get_current_user
|
||||
from apps.api.src.security.password import verify_password
|
||||
from apps.api.src.security.permissions import Permission
|
||||
from apps.api.src.services import user_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/setup-status", response_model=schemas.SetupStatusResponse)
|
||||
async def setup_status(db: AsyncSession = Depends(get_db)):
|
||||
return {"setup_required": await user_service.setup_required(db)}
|
||||
|
||||
|
||||
@router.post("/setup", response_model=schemas.TokenResponse)
|
||||
async def setup(
|
||||
data: schemas.SetupRequest,
|
||||
response: Response,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if not await user_service.setup_required(db):
|
||||
raise HTTPException(status_code=400, detail="Setup already completed")
|
||||
user = await user_service.create_owner(db, data)
|
||||
return await _create_token_response(user, response)
|
||||
|
||||
|
||||
@router.post("/login", response_model=schemas.TokenResponse)
|
||||
async def login(
|
||||
data: schemas.LoginRequest,
|
||||
response: Response,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
user = await user_service.get_user_by_email(db, data.email)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
if user.locked_until and user.locked_until > datetime.now(timezone.utc):
|
||||
raise HTTPException(status_code=401, detail="Account locked due to failed attempts")
|
||||
if not verify_password(data.password, user.hashed_password):
|
||||
await user_service.record_failed_login(db, user)
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
await user_service.update_last_login(db, user, request.client.host if request.client else None)
|
||||
await log_audit(
|
||||
db,
|
||||
action="login",
|
||||
resource_type="user",
|
||||
user_id=user.id,
|
||||
ip_address=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
return await _create_token_response(user, response)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=schemas.TokenResponse)
|
||||
async def refresh(
|
||||
data: schemas.RefreshRequest,
|
||||
response: Response,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
payload = tokens.decode_token_safe(data.refresh_token)
|
||||
if not payload or payload.get("type") != "refresh":
|
||||
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
||||
import uuid
|
||||
|
||||
user_id = uuid.UUID(payload["sub"])
|
||||
user = await user_service.get_user_by_id(db, user_id)
|
||||
if not user or not user.is_active:
|
||||
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
||||
return await _create_token_response(user, response)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(
|
||||
response: Response,
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
response.delete_cookie("access_token")
|
||||
response.delete_cookie("refresh_token")
|
||||
return {"message": "Logged out"}
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
async def change_password(
|
||||
data: schemas.PasswordChangeRequest,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await user_service.change_password(db, user, data.current_password, data.new_password)
|
||||
return {"message": "Password changed"}
|
||||
|
||||
|
||||
@router.post("/password-reset-request")
|
||||
async def password_reset_request(
|
||||
data: schemas.PasswordResetRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
token = await user_service.create_password_reset(db, data.email)
|
||||
# TODO: send email with token
|
||||
return {"message": "If the email exists, a reset link was sent"}
|
||||
|
||||
|
||||
@router.post("/password-reset-confirm")
|
||||
async def password_reset_confirm(
|
||||
data: schemas.PasswordResetConfirm,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await user_service.reset_password(db, data.token, data.new_password)
|
||||
return {"message": "Password reset successfully"}
|
||||
|
||||
|
||||
async def _create_token_response(user: User, response: Response) -> schemas.TokenResponse:
|
||||
access = tokens.create_access_token(str(user.id))
|
||||
refresh, expires = tokens.create_refresh_token(str(user.id))
|
||||
response.set_cookie(
|
||||
key="access_token",
|
||||
value=access,
|
||||
httponly=True,
|
||||
secure=True,
|
||||
samesite="lax",
|
||||
max_age=60 * 15,
|
||||
)
|
||||
response.set_cookie(
|
||||
key="refresh_token",
|
||||
value=refresh,
|
||||
httponly=True,
|
||||
secure=True,
|
||||
samesite="lax",
|
||||
max_age=60 * 60 * 24 * 7,
|
||||
)
|
||||
return schemas.TokenResponse(
|
||||
access_token=access,
|
||||
refresh_token=refresh,
|
||||
expires_at=expires,
|
||||
)
|
||||
@@ -0,0 +1,80 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from apps.api.src.database import get_db
|
||||
from apps.api.src.models.user import User
|
||||
from apps.api.src.schemas import dashboard as schemas
|
||||
from apps.api.src.security.dependencies import PermissionRequired, get_current_user
|
||||
from apps.api.src.services import dashboard_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[schemas.DashboardResponse])
|
||||
async def list_dashboards(
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await dashboard_service.list_dashboards(db, user)
|
||||
|
||||
|
||||
@router.post("", response_model=schemas.DashboardResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_dashboard(
|
||||
data: schemas.DashboardCreate,
|
||||
user: User = Depends(PermissionRequired("dashboard:write")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await dashboard_service.create_dashboard(db, data, user)
|
||||
|
||||
|
||||
@router.get("/{dashboard_id}", response_model=schemas.DashboardResponse)
|
||||
async def get_dashboard(
|
||||
dashboard_id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
dashboard = await dashboard_service.get_dashboard(db, dashboard_id)
|
||||
if not dashboard or dashboard.owner_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Dashboard not found")
|
||||
return dashboard
|
||||
|
||||
|
||||
@router.put("/{dashboard_id}", response_model=schemas.DashboardResponse)
|
||||
async def update_dashboard(
|
||||
dashboard_id: uuid.UUID,
|
||||
data: schemas.DashboardUpdate,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await dashboard_service.update_dashboard(db, dashboard_id, data, user)
|
||||
|
||||
|
||||
@router.delete("/{dashboard_id}")
|
||||
async def delete_dashboard(
|
||||
dashboard_id: uuid.UUID,
|
||||
user: User = Depends(PermissionRequired("dashboard:delete")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await dashboard_service.delete_dashboard(db, dashboard_id, user)
|
||||
return {"message": "Dashboard deleted"}
|
||||
|
||||
|
||||
@router.post("/{dashboard_id}/duplicate", response_model=schemas.DashboardResponse)
|
||||
async def duplicate_dashboard(
|
||||
dashboard_id: uuid.UUID,
|
||||
user: User = Depends(PermissionRequired("dashboard:write")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await dashboard_service.duplicate_dashboard(db, dashboard_id, user)
|
||||
|
||||
|
||||
@router.put("/{dashboard_id}/layout", response_model=schemas.DashboardResponse)
|
||||
async def update_layout(
|
||||
dashboard_id: uuid.UUID,
|
||||
data: schemas.DashboardLayoutUpdate,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await dashboard_service.update_layout(db, dashboard_id, data, user)
|
||||
@@ -0,0 +1,41 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from apps.api.src.database import get_db
|
||||
from apps.api.src.models.user import User
|
||||
from apps.api.src.schemas import notification as schemas
|
||||
from apps.api.src.security.dependencies import get_current_user
|
||||
from apps.api.src.services import notification_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[schemas.NotificationResponse])
|
||||
async def list_notifications(
|
||||
unread_only: bool = False,
|
||||
limit: int = 50,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await notification_service.list_notifications(db, user, unread_only=unread_only, limit=limit)
|
||||
|
||||
|
||||
@router.post("/{notification_id}/read")
|
||||
async def mark_read(
|
||||
notification_id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await notification_service.mark_as_read(db, user, notification_id)
|
||||
return {"message": "Marked as read"}
|
||||
|
||||
|
||||
@router.post("/read-all")
|
||||
async def mark_all_read(
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await notification_service.mark_as_read(db, user)
|
||||
return {"message": "All marked as read"}
|
||||
@@ -0,0 +1,114 @@
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from apps.api.src.config import settings
|
||||
from apps.api.src.database import get_db
|
||||
from apps.api.src.models.user import User
|
||||
from apps.api.src.schemas import plugin as schemas
|
||||
from apps.api.src.security.dependencies import PermissionRequired
|
||||
from apps.api.src.services import plugin_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[schemas.PluginResponse])
|
||||
async def list_plugins(
|
||||
active_only: bool = False,
|
||||
user: User = Depends(PermissionRequired("plugin:read")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await plugin_service.list_plugins(db, active_only=active_only)
|
||||
|
||||
|
||||
@router.get("/{plugin_id}", response_model=schemas.PluginResponse)
|
||||
async def get_plugin(
|
||||
plugin_id: str,
|
||||
user: User = Depends(PermissionRequired("plugin:read")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
plugin = await plugin_service.get_plugin_by_id(db, plugin_id)
|
||||
if not plugin:
|
||||
raise HTTPException(status_code=404, detail="Plugin not found")
|
||||
return plugin
|
||||
|
||||
|
||||
@router.put("/{plugin_id}", response_model=schemas.PluginResponse)
|
||||
async def update_plugin(
|
||||
plugin_id: str,
|
||||
data: schemas.PluginUpdate,
|
||||
user: User = Depends(PermissionRequired("plugin:write")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await plugin_service.update_plugin(db, plugin_id, data)
|
||||
|
||||
|
||||
@router.delete("/{plugin_id}")
|
||||
async def delete_plugin(
|
||||
plugin_id: str,
|
||||
user: User = Depends(PermissionRequired("plugin:delete")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await plugin_service.delete_plugin(db, plugin_id)
|
||||
return {"message": "Plugin deleted"}
|
||||
|
||||
|
||||
@router.post("/{plugin_id}/instances", response_model=schemas.PluginInstanceResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_instance(
|
||||
plugin_id: str,
|
||||
data: schemas.PluginInstanceCreate,
|
||||
user: User = Depends(PermissionRequired("plugin:write")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await plugin_service.create_instance(db, plugin_id, data)
|
||||
|
||||
|
||||
@router.get("/{plugin_id}/instances", response_model=list[schemas.PluginInstanceResponse])
|
||||
async def list_instances(
|
||||
plugin_id: str,
|
||||
user: User = Depends(PermissionRequired("plugin:read")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await plugin_service.list_instances(db, plugin_id=plugin_id)
|
||||
|
||||
|
||||
@router.put("/instances/{instance_id}", response_model=schemas.PluginInstanceResponse)
|
||||
async def update_instance(
|
||||
instance_id: uuid.UUID,
|
||||
data: schemas.PluginInstanceUpdate,
|
||||
user: User = Depends(PermissionRequired("plugin:write")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await plugin_service.update_instance(db, instance_id, data)
|
||||
|
||||
|
||||
@router.delete("/instances/{instance_id}")
|
||||
async def delete_instance(
|
||||
instance_id: uuid.UUID,
|
||||
user: User = Depends(PermissionRequired("plugin:write")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await plugin_service.delete_instance(db, instance_id)
|
||||
return {"message": "Instance deleted"}
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_plugin(
|
||||
file: UploadFile = File(...),
|
||||
user: User = Depends(PermissionRequired("plugin:install")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
plugin = await plugin_service.install_plugin_from_zip(db, file, settings.PLUGIN_DIR)
|
||||
return {"plugin_id": plugin.id, "name": plugin.name, "version": plugin.version}
|
||||
|
||||
|
||||
@router.get("/{plugin_id}/logs")
|
||||
async def list_logs(
|
||||
plugin_id: str,
|
||||
limit: int = 100,
|
||||
user: User = Depends(PermissionRequired("plugin:read")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await plugin_service.list_plugin_logs(db, plugin_id=plugin_id, limit=limit)
|
||||
@@ -0,0 +1,61 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from apps.api.src.database import get_db
|
||||
from apps.api.src.models.user import User
|
||||
from apps.api.src.schemas import role as schemas
|
||||
from apps.api.src.security.dependencies import PermissionRequired
|
||||
from apps.api.src.services import role_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[schemas.RoleResponse])
|
||||
async def list_roles(
|
||||
user: User = Depends(PermissionRequired("role:read")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await role_service.list_roles(db)
|
||||
|
||||
|
||||
@router.post("", response_model=schemas.RoleResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_role(
|
||||
data: schemas.RoleCreate,
|
||||
user: User = Depends(PermissionRequired("role:write")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await role_service.create_role(db, data)
|
||||
|
||||
|
||||
@router.get("/{role_id}", response_model=schemas.RoleResponse)
|
||||
async def get_role(
|
||||
role_id: uuid.UUID,
|
||||
user: User = Depends(PermissionRequired("role:read")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
role = await role_service.get_role_by_id(db, role_id)
|
||||
if not role:
|
||||
raise HTTPException(status_code=404, detail="Role not found")
|
||||
return role
|
||||
|
||||
|
||||
@router.put("/{role_id}", response_model=schemas.RoleResponse)
|
||||
async def update_role(
|
||||
role_id: uuid.UUID,
|
||||
data: schemas.RoleUpdate,
|
||||
user: User = Depends(PermissionRequired("role:write")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await role_service.update_role(db, role_id, data)
|
||||
|
||||
|
||||
@router.delete("/{role_id}")
|
||||
async def delete_role(
|
||||
role_id: uuid.UUID,
|
||||
user: User = Depends(PermissionRequired("role:write")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await role_service.delete_role(db, role_id)
|
||||
return {"message": "Role deleted"}
|
||||
@@ -0,0 +1,70 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from apps.api.src.database import get_db
|
||||
from apps.api.src.models.user import User
|
||||
from apps.api.src.schemas import service_connection as schemas
|
||||
from apps.api.src.security.dependencies import PermissionRequired, get_current_user
|
||||
from apps.api.src.services import service_connection_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[schemas.ServiceConnectionResponse])
|
||||
async def list_connections(
|
||||
plugin_id: str | None = None,
|
||||
user: User = Depends(PermissionRequired("connection:read")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await service_connection_service.list_connections(db, plugin_id=plugin_id)
|
||||
|
||||
|
||||
@router.post("", response_model=schemas.ServiceConnectionResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_connection(
|
||||
data: schemas.ServiceConnectionCreate,
|
||||
user: User = Depends(PermissionRequired("connection:write")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await service_connection_service.create_connection(db, data, user.id)
|
||||
|
||||
|
||||
@router.get("/{connection_id}", response_model=schemas.ServiceConnectionResponse)
|
||||
async def get_connection(
|
||||
connection_id: uuid.UUID,
|
||||
user: User = Depends(PermissionRequired("connection:read")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
conn = await service_connection_service.get_connection(db, connection_id)
|
||||
if not conn:
|
||||
raise HTTPException(status_code=404, detail="Connection not found")
|
||||
return conn
|
||||
|
||||
|
||||
@router.put("/{connection_id}", response_model=schemas.ServiceConnectionResponse)
|
||||
async def update_connection(
|
||||
connection_id: uuid.UUID,
|
||||
data: schemas.ServiceConnectionUpdate,
|
||||
user: User = Depends(PermissionRequired("connection:write")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await service_connection_service.update_connection(db, connection_id, data)
|
||||
|
||||
|
||||
@router.delete("/{connection_id}")
|
||||
async def delete_connection(
|
||||
connection_id: uuid.UUID,
|
||||
user: User = Depends(PermissionRequired("connection:delete")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await service_connection_service.delete_connection(db, connection_id)
|
||||
return {"message": "Connection deleted"}
|
||||
|
||||
|
||||
@router.post("/test")
|
||||
async def test_connection(
|
||||
data: schemas.ServiceConnectionTestRequest,
|
||||
user: User = Depends(PermissionRequired("connection:test")),
|
||||
):
|
||||
return await service_connection_service.test_connection(data)
|
||||
@@ -0,0 +1,40 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from apps.api.src.database import get_db
|
||||
from apps.api.src.models.user import User
|
||||
from apps.api.src.schemas import system_setting as schemas
|
||||
from apps.api.src.security.dependencies import PermissionRequired
|
||||
from apps.api.src.services import system_setting_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/{key}", response_model=schemas.SystemSettingResponse)
|
||||
async def get_setting(
|
||||
key: str,
|
||||
user: User = Depends(PermissionRequired("system:read")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
setting = await system_setting_service.get_setting(db, key)
|
||||
if not setting:
|
||||
raise HTTPException(status_code=404, detail="Setting not found")
|
||||
return setting
|
||||
|
||||
|
||||
@router.put("/{key}", response_model=schemas.SystemSettingResponse)
|
||||
async def update_setting(
|
||||
key: str,
|
||||
data: schemas.SystemSettingUpdate,
|
||||
user: User = Depends(PermissionRequired("system:write")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await system_setting_service.set_setting(db, key, data)
|
||||
|
||||
|
||||
@router.get("", response_model=list[schemas.SystemSettingResponse])
|
||||
async def list_settings(
|
||||
user: User = Depends(PermissionRequired("system:read")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await system_setting_service.list_settings(db)
|
||||
@@ -0,0 +1,21 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from apps.api.src.models.user import User
|
||||
from apps.api.src.security.dependencies import PermissionRequired
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/metrics")
|
||||
async def metrics(user: User = Depends(PermissionRequired("system:read"))):
|
||||
return {"metrics": "placeholder"}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def stats(user: User = Depends(PermissionRequired("system:read"))):
|
||||
return {"dashboards": 0, "plugins": 0, "users": 0}
|
||||
@@ -0,0 +1,137 @@
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from apps.api.src.database import get_db
|
||||
from apps.api.src.models.user import User
|
||||
from apps.api.src.schemas import user as schemas
|
||||
from apps.api.src.security.audit import log_audit
|
||||
from apps.api.src.security.dependencies import (
|
||||
PermissionRequired,
|
||||
get_current_user,
|
||||
get_user_permissions,
|
||||
)
|
||||
from apps.api.src.services import user_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/me", response_model=schemas.UserResponse)
|
||||
async def me(user: User = Depends(get_current_user)):
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/me/permissions")
|
||||
async def my_permissions(
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await get_user_permissions(user)
|
||||
|
||||
|
||||
@router.put("/me", response_model=schemas.UserResponse)
|
||||
async def update_profile(
|
||||
data: schemas.ProfileUpdate,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(user, key, value)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/me/change-password")
|
||||
async def change_password(
|
||||
data: schemas.PasswordChangeRequest,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await user_service.change_password(db, user, data.current_password, data.new_password)
|
||||
return {"message": "Password changed"}
|
||||
|
||||
|
||||
@router.get("", response_model=list[schemas.UserResponse])
|
||||
async def list_users(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
user: User = Depends(PermissionRequired("user:read")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await user_service.list_users(db, skip=skip, limit=limit)
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=schemas.UserResponse)
|
||||
async def get_user(
|
||||
user_id: uuid.UUID,
|
||||
user: User = Depends(PermissionRequired("user:read")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
db_user = await user_service.get_user_by_id(db, user_id)
|
||||
if not db_user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return db_user
|
||||
|
||||
|
||||
@router.post("", response_model=schemas.UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_user(
|
||||
data: schemas.UserCreate,
|
||||
request: Request,
|
||||
user: User = Depends(PermissionRequired("user:write")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
new_user = await user_service.create_user(db, data, user)
|
||||
await log_audit(
|
||||
db,
|
||||
action="create_user",
|
||||
resource_type="user",
|
||||
user_id=user.id,
|
||||
resource_id=str(new_user.id),
|
||||
ip_address=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
return new_user
|
||||
|
||||
|
||||
@router.put("/{user_id}", response_model=schemas.UserResponse)
|
||||
async def update_user(
|
||||
user_id: uuid.UUID,
|
||||
data: schemas.UserUpdate,
|
||||
user: User = Depends(PermissionRequired("user:write")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await user_service.update_user(db, user_id, data, user)
|
||||
|
||||
|
||||
@router.delete("/{user_id}")
|
||||
async def delete_user(
|
||||
user_id: uuid.UUID,
|
||||
user: User = Depends(PermissionRequired("user:delete")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await user_service.delete_user(db, user_id, user)
|
||||
return {"message": "User deleted"}
|
||||
|
||||
|
||||
@router.post("/invite")
|
||||
async def invite_user(
|
||||
data: schemas.UserInviteRequest,
|
||||
user: User = Depends(PermissionRequired("user:invite")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
token = await user_service.create_invite(db, data.email, data.role_id)
|
||||
# TODO: send email
|
||||
return {"message": "Invitation sent", "token": token}
|
||||
|
||||
|
||||
@router.post("/invite/accept")
|
||||
async def accept_invite(
|
||||
data: schemas.UserInviteAccept,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await user_service.accept_invite(db, data.token, data.password)
|
||||
return {"message": "Invitation accepted"}
|
||||
@@ -0,0 +1,37 @@
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from apps.api.src.database import get_db
|
||||
from apps.api.src.models.user import User
|
||||
from apps.api.src.plugins import loader
|
||||
from apps.api.src.security.dependencies import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class WidgetDataRequest(BaseModel):
|
||||
instance_id: uuid.UUID
|
||||
widget_type: str
|
||||
settings: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
@router.post("/widget-data")
|
||||
async def widget_data(
|
||||
data: WidgetDataRequest,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await loader.fetch_widget_data(db, data.instance_id, data.widget_type, data.settings)
|
||||
|
||||
|
||||
@router.post("/widget-data/{instance_id}/healthcheck")
|
||||
async def healthcheck(
|
||||
instance_id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await loader.run_healthcheck(db, instance_id)
|
||||
@@ -0,0 +1,42 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from apps.api.src.database import get_db
|
||||
from apps.api.src.models.user import User
|
||||
from apps.api.src.schemas import dashboard as schemas
|
||||
from apps.api.src.security.dependencies import get_current_user
|
||||
from apps.api.src.services import dashboard_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/{dashboard_id}", response_model=schemas.DashboardWidgetResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_widget(
|
||||
dashboard_id: uuid.UUID,
|
||||
data: schemas.DashboardWidgetCreate,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await dashboard_service.create_widget(db, dashboard_id, data, user)
|
||||
|
||||
|
||||
@router.put("/{widget_id}", response_model=schemas.DashboardWidgetResponse)
|
||||
async def update_widget(
|
||||
widget_id: uuid.UUID,
|
||||
data: schemas.DashboardWidgetUpdate,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await dashboard_service.update_widget(db, widget_id, data, user)
|
||||
|
||||
|
||||
@router.delete("/{widget_id}")
|
||||
async def delete_widget(
|
||||
widget_id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await dashboard_service.delete_widget(db, widget_id, user)
|
||||
return {"message": "Widget deleted"}
|
||||
@@ -0,0 +1,31 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from apps.api.src.api.v1.endpoints import (
|
||||
audit,
|
||||
auth,
|
||||
dashboards,
|
||||
notifications,
|
||||
plugins,
|
||||
roles,
|
||||
service_connections,
|
||||
settings,
|
||||
system,
|
||||
users,
|
||||
widget_data,
|
||||
widgets,
|
||||
)
|
||||
|
||||
api_router = APIRouter(prefix="/api/v1")
|
||||
|
||||
api_router.include_router(auth.router, prefix="/auth", tags=["Auth"])
|
||||
api_router.include_router(users.router, prefix="/users", tags=["Users"])
|
||||
api_router.include_router(roles.router, prefix="/roles", tags=["Roles"])
|
||||
api_router.include_router(dashboards.router, prefix="/dashboards", tags=["Dashboards"])
|
||||
api_router.include_router(widgets.router, prefix="/widgets", tags=["Widgets"])
|
||||
api_router.include_router(plugins.router, prefix="/plugins", tags=["Plugins"])
|
||||
api_router.include_router(service_connections.router, prefix="/connections", tags=["Connections"])
|
||||
api_router.include_router(notifications.router, prefix="/notifications", tags=["Notifications"])
|
||||
api_router.include_router(audit.router, prefix="/audit", tags=["Audit"])
|
||||
api_router.include_router(settings.router, prefix="/settings", tags=["Settings"])
|
||||
api_router.include_router(system.router, prefix="/system", tags=["System"])
|
||||
api_router.include_router(widget_data.router, prefix="/widget-data", tags=["Widget Data"])
|
||||
@@ -0,0 +1,58 @@
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# General
|
||||
NODE_ENV: str = "production"
|
||||
NEXADASH_API_URL: str = "http://localhost:8000"
|
||||
NEXADASH_WEB_URL: str = "http://localhost:3000"
|
||||
NEXADASH_SECRET_KEY: str = "change-this-to-a-random-32-byte-secret-key!!!!"
|
||||
NEXADASH_ENCRYPTION_KEY: str = "change-this-to-a-random-32-byte-fernet-key!!"
|
||||
|
||||
# Database
|
||||
DATABASE_URL: str = "postgresql+asyncpg://nexadash:change-this-strong-password@localhost:5432/nexadash"
|
||||
|
||||
# Redis
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
CELERY_BROKER_URL: str = "redis://localhost:6379/1"
|
||||
CELERY_RESULT_BACKEND: str = "redis://localhost:6379/2"
|
||||
|
||||
# Security
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 15
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||
ARGON2_TIME_COST: int = 2
|
||||
ARGON2_MEMORY_COST: int = 65536
|
||||
ARGON2_PARALLELISM: int = 1
|
||||
MAX_LOGIN_ATTEMPTS: int = 5
|
||||
LOGIN_LOCKOUT_MINUTES: int = 15
|
||||
RATE_LIMIT_DEFAULT: int = 100
|
||||
RATE_LIMIT_LOGIN: int = 10
|
||||
|
||||
# Mail
|
||||
SMTP_HOST: str = ""
|
||||
SMTP_PORT: int = 587
|
||||
SMTP_TLS: bool = True
|
||||
SMTP_STARTTLS: bool = True
|
||||
SMTP_USER: str = ""
|
||||
SMTP_PASSWORD: str = ""
|
||||
SMTP_FROM: str = "nexadash@localhost"
|
||||
|
||||
# Worker
|
||||
WORKER_CONCURRENCY: int = 4
|
||||
|
||||
# Plugins
|
||||
PLUGIN_DIR: Path = Path("/app/plugins")
|
||||
PLUGIN_SANDBOX_ENABLED: bool = True
|
||||
|
||||
# Monitoring
|
||||
SENTRY_DSN: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = True
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,26 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from apps.api.src.config import settings
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=settings.NODE_ENV == "development",
|
||||
future=True,
|
||||
)
|
||||
|
||||
AsyncSessionLocal = sessionmaker(
|
||||
bind=engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=False,
|
||||
autocommit=False,
|
||||
)
|
||||
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
@@ -0,0 +1,81 @@
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.trustedhost import TrustedHostMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from apps.api.src.api.v1.router import api_router
|
||||
from apps.api.src.config import settings
|
||||
from apps.api.src.database import engine
|
||||
from apps.api.src.models.base import Base
|
||||
from apps.api.src.security.rate_limit import limiter
|
||||
|
||||
|
||||
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
response.headers["X-XSS-Protection"] = "0"
|
||||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||
response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()"
|
||||
response.headers["Content-Security-Policy"] = "default-src 'self'; frame-ancestors 'none';"
|
||||
return response
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup
|
||||
async with engine.begin() as conn:
|
||||
# Ensure tables exist (for dev convenience; migrations preferred in production)
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
from apps.api.src.seed import seed
|
||||
await seed()
|
||||
yield
|
||||
# Shutdown
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(
|
||||
title="NexaDash API",
|
||||
description="Plugin-based dashboard API",
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
docs_url="/api/docs" if settings.NODE_ENV == "development" else None,
|
||||
redoc_url="/api/redoc" if settings.NODE_ENV == "development" else None,
|
||||
)
|
||||
|
||||
app.state.limiter = limiter
|
||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[settings.NEXADASH_WEB_URL],
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.add_middleware(SecurityHeadersMiddleware)
|
||||
app.add_middleware(TrustedHostMiddleware, allowed_hosts=["*"])
|
||||
|
||||
app.include_router(api_router)
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok", "version": "0.1.0"}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
async def _rate_limit_exceeded_handler(request: Request, exc: RateLimitExceeded):
|
||||
return JSONResponse(
|
||||
status_code=429,
|
||||
content={"detail": "Too many requests"},
|
||||
)
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -0,0 +1,35 @@
|
||||
from apps.api.src.models.api_token import APIToken
|
||||
from apps.api.src.models.audit_log import AuditLog
|
||||
from apps.api.src.models.background_job import BackgroundJob
|
||||
from apps.api.src.models.dashboard import Dashboard
|
||||
from apps.api.src.models.notification import Notification
|
||||
from apps.api.src.models.plugin import Plugin
|
||||
from apps.api.src.models.plugin_instance import PluginInstance
|
||||
from apps.api.src.models.plugin_log import PluginLog
|
||||
from apps.api.src.models.plugin_version import PluginVersion
|
||||
from apps.api.src.models.role import Role
|
||||
from apps.api.src.models.secret import Secret
|
||||
from apps.api.src.models.service_connection import ServiceConnection
|
||||
from apps.api.src.models.session import Session
|
||||
from apps.api.src.models.system_setting import SystemSetting
|
||||
from apps.api.src.models.user import User
|
||||
from apps.api.src.models.widget import Widget
|
||||
|
||||
__all__ = [
|
||||
"APIToken",
|
||||
"AuditLog",
|
||||
"BackgroundJob",
|
||||
"Dashboard",
|
||||
"Notification",
|
||||
"Plugin",
|
||||
"PluginInstance",
|
||||
"PluginLog",
|
||||
"PluginVersion",
|
||||
"Role",
|
||||
"Secret",
|
||||
"ServiceConnection",
|
||||
"Session",
|
||||
"SystemSetting",
|
||||
"User",
|
||||
"Widget",
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text, Boolean
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from apps.api.src.models.base import Base
|
||||
|
||||
|
||||
class ApiToken(Base):
|
||||
__tablename__ = "api_tokens"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
token_hash: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
last_used_ip: Mapped[str | None] = mapped_column(String(45))
|
||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
scopes: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
user: Mapped["User"] = relationship(back_populates="api_tokens")
|
||||
@@ -0,0 +1,26 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from apps.api.src.models.base import Base
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"), index=True
|
||||
)
|
||||
action: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
resource_type: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
resource_id: Mapped[str | None] = mapped_column(String(100))
|
||||
ip_address: Mapped[str | None] = mapped_column(String(45))
|
||||
user_agent: Mapped[str | None] = mapped_column(Text)
|
||||
details: Mapped[dict] = mapped_column(JSONB, default=dict, nullable=False)
|
||||
severity: Mapped[str] = mapped_column(String(20), default="info", nullable=False)
|
||||
timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
user: Mapped["User"] = relationship(back_populates="audit_logs")
|
||||
@@ -0,0 +1,28 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from apps.api.src.models.base import Base
|
||||
|
||||
|
||||
class BackgroundJob(Base):
|
||||
__tablename__ = "background_jobs"
|
||||
|
||||
task_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(20), default="pending", nullable=False)
|
||||
payload: Mapped[dict] = mapped_column(JSONB, default=dict, nullable=False)
|
||||
result: Mapped[dict] = mapped_column(JSONB, default=dict, nullable=False)
|
||||
error: Mapped[str | None] = mapped_column(Text)
|
||||
scheduled_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
worker_id: Mapped[str | None] = mapped_column(String(100))
|
||||
plugin_id: Mapped[str | None] = mapped_column(String(100), index=True)
|
||||
instance_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("plugin_instances.id", ondelete="SET NULL"), index=True
|
||||
)
|
||||
|
||||
instance: Mapped["PluginInstance"] = relationship()
|
||||
@@ -0,0 +1,29 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
__abstract__ = True
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
unique=True,
|
||||
index=True,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from apps.api.src.models.base import Base
|
||||
|
||||
|
||||
class Dashboard(Base):
|
||||
__tablename__ = "dashboards"
|
||||
|
||||
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
icon: Mapped[str | None] = mapped_column(String(50))
|
||||
folder: Mapped[str] = mapped_column(String(100), default="default", nullable=False, index=True)
|
||||
is_favorite: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
is_public: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
owner_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False
|
||||
)
|
||||
layout: Mapped[dict] = mapped_column(JSONB, default=dict, nullable=False)
|
||||
layouts_by_breakpoint: Mapped[dict] = mapped_column(
|
||||
JSONB,
|
||||
default=lambda: {"lg": [], "md": [], "sm": [], "xs": [], "xxs": []},
|
||||
nullable=False,
|
||||
)
|
||||
refresh_interval_seconds: Mapped[int | None] = mapped_column(Integer, default=60)
|
||||
order_index: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
tags: Mapped[list[str]] = mapped_column(JSONB, default=list)
|
||||
|
||||
owner: Mapped["User"] = relationship(back_populates="dashboards")
|
||||
widgets: Mapped[list["Widget"]] = relationship(
|
||||
back_populates="dashboard", cascade="all, delete-orphan", order_by="Widget.order_index"
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from apps.api.src.models.base import Base
|
||||
|
||||
|
||||
class Notification(Base):
|
||||
__tablename__ = "notifications"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
message: Mapped[str | None] = mapped_column(Text)
|
||||
type: Mapped[str] = mapped_column(String(50), nullable=False) # info, success, warning, error
|
||||
is_read: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
link: Mapped[str | None] = mapped_column(String(500))
|
||||
metadata: Mapped[dict] = mapped_column(JSONB, default=dict, nullable=False)
|
||||
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
user: Mapped["User"] = relationship()
|
||||
@@ -0,0 +1,42 @@
|
||||
from sqlalchemy import Boolean, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from apps.api.src.models.base import Base
|
||||
|
||||
|
||||
class Plugin(Base):
|
||||
__tablename__ = "plugins"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(100), primary_key=True, nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
version: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
author: Mapped[str | None] = mapped_column(String(200))
|
||||
category: Mapped[str] = mapped_column(String(100), default="Generic", nullable=False)
|
||||
icon: Mapped[str | None] = mapped_column(String(100))
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
is_builtin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
is_installed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
path: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
permissions: Mapped[list[str]] = mapped_column(JSONB, default=list, nullable=False)
|
||||
settings_schema: Mapped[dict] = mapped_column(JSONB, default=dict, nullable=False)
|
||||
credentials_schema: Mapped[dict] = mapped_column(JSONB, default=dict, nullable=False)
|
||||
widget_types: Mapped[list[dict]] = mapped_column(JSONB, default=list, nullable=False)
|
||||
healthcheck_definition: Mapped[dict] = mapped_column(JSONB, default=dict, nullable=False)
|
||||
api_routes: Mapped[list[str]] = mapped_column(JSONB, default=list)
|
||||
has_frontend: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
manifest: Mapped[dict] = mapped_column(JSONB, default=dict, nullable=False)
|
||||
|
||||
instances: Mapped[list["PluginInstance"]] = relationship(
|
||||
back_populates="plugin", cascade="all, delete-orphan"
|
||||
)
|
||||
service_connections: Mapped[list["ServiceConnection"]] = relationship(
|
||||
back_populates="plugin", cascade="all, delete-orphan"
|
||||
)
|
||||
versions: Mapped[list["PluginVersion"]] = relationship(
|
||||
back_populates="plugin", cascade="all, delete-orphan"
|
||||
)
|
||||
logs: Mapped[list["PluginLog"]] = relationship(
|
||||
back_populates="plugin", cascade="all, delete-orphan"
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from apps.api.src.models.base import Base
|
||||
|
||||
|
||||
class PluginInstance(Base):
|
||||
__tablename__ = "plugin_instances"
|
||||
|
||||
plugin_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("plugins.id", ondelete="CASCADE"), index=True, nullable=False
|
||||
)
|
||||
service_connection_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("service_connections.id", ondelete="SET NULL"), index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
settings: Mapped[dict] = mapped_column(JSONB, default=dict, nullable=False)
|
||||
is_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
health_status: Mapped[str] = mapped_column(String(20), default="unknown", nullable=False)
|
||||
health_message: Mapped[str | None] = mapped_column(Text)
|
||||
last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # type: ignore[name-defined]
|
||||
last_error: Mapped[str | None] = mapped_column(Text)
|
||||
error_count: Mapped[int] = mapped_column(default=0, nullable=False)
|
||||
|
||||
plugin: Mapped["Plugin"] = relationship(back_populates="instances")
|
||||
service_connection: Mapped["ServiceConnection"] = relationship(back_populates="instances")
|
||||
widgets: Mapped[list["Widget"]] = relationship(back_populates="instance")
|
||||
@@ -0,0 +1,26 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from apps.api.src.models.base import Base
|
||||
|
||||
|
||||
class PluginLog(Base):
|
||||
__tablename__ = "plugin_logs"
|
||||
|
||||
plugin_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("plugins.id", ondelete="CASCADE"), index=True, nullable=False
|
||||
)
|
||||
instance_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("plugin_instances.id", ondelete="SET NULL"), index=True
|
||||
)
|
||||
level: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
message: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
context: Mapped[dict] = mapped_column(JSONB, default=dict, nullable=False)
|
||||
timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
plugin: Mapped["Plugin"] = relationship(back_populates="logs")
|
||||
instance: Mapped["PluginInstance"] = relationship()
|
||||
@@ -0,0 +1,22 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Boolean, ForeignKey, String
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from apps.api.src.models.base import Base
|
||||
|
||||
|
||||
class PluginVersion(Base):
|
||||
__tablename__ = "plugin_versions"
|
||||
|
||||
plugin_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("plugins.id", ondelete="CASCADE"), index=True, nullable=False
|
||||
)
|
||||
version: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
changelog: Mapped[str | None] = mapped_column(String)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
package_path: Mapped[str | None] = mapped_column(String(500))
|
||||
manifest: Mapped[dict] = mapped_column(JSONB, default=dict, nullable=False)
|
||||
|
||||
plugin: Mapped["Plugin"] = relationship(back_populates="versions")
|
||||
@@ -0,0 +1,19 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import String, Text, Boolean
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from apps.api.src.models.base import Base
|
||||
|
||||
|
||||
class Role(Base):
|
||||
__tablename__ = "roles"
|
||||
|
||||
name: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
is_system: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
# Permission list: e.g. ["user:read", "dashboard:write", "plugin:admin"]
|
||||
permissions: Mapped[list[str]] = mapped_column(JSONB, default=list, nullable=False)
|
||||
|
||||
users: Mapped[list["User"]] = relationship(back_populates="role")
|
||||
@@ -0,0 +1,22 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from apps.api.src.models.base import Base
|
||||
|
||||
|
||||
class Secret(Base):
|
||||
__tablename__ = "encrypted_secrets"
|
||||
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
owner_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"), index=True
|
||||
)
|
||||
encrypted_value: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
secret_type: Mapped[str] = mapped_column(String(50), nullable=False) # api_token, password, etc.
|
||||
scope: Mapped[str] = mapped_column(String(100), nullable=False) # plugin, system, user
|
||||
metadata: Mapped[dict] = mapped_column(JSONB, default=dict, nullable=False)
|
||||
|
||||
service_connections: Mapped[list["ServiceConnection"]] = relationship(back_populates="credentials")
|
||||
@@ -0,0 +1,30 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Boolean, ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from apps.api.src.models.base import Base
|
||||
|
||||
|
||||
class ServiceConnection(Base):
|
||||
__tablename__ = "service_connections"
|
||||
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
plugin_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("plugins.id", ondelete="CASCADE"), index=True, nullable=False
|
||||
)
|
||||
base_url: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
verify_tls: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
timeout_seconds: Mapped[int] = mapped_column(default=30, nullable=False)
|
||||
credentials_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("encrypted_secrets.id", ondelete="SET NULL"), index=True
|
||||
)
|
||||
is_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
health_status: Mapped[str] = mapped_column(String(20), default="unknown", nullable=False)
|
||||
health_message: Mapped[str | None] = mapped_column(Text)
|
||||
extra_headers: Mapped[dict] = mapped_column(JSONB, default=dict, nullable=False)
|
||||
|
||||
plugin: Mapped["Plugin"] = relationship(back_populates="service_connections")
|
||||
credentials: Mapped["Secret"] = relationship(back_populates="service_connections")
|
||||
instances: Mapped[list["PluginInstance"]] = relationship(back_populates="service_connection")
|
||||
@@ -0,0 +1,23 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text, Boolean
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from apps.api.src.models.base import Base
|
||||
|
||||
|
||||
class Session(Base):
|
||||
__tablename__ = "sessions"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False
|
||||
)
|
||||
refresh_token_hash: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||
user_agent: Mapped[str | None] = mapped_column(Text)
|
||||
ip_address: Mapped[str | None] = mapped_column(String(45))
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
is_valid: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
||||
user: Mapped["User"] = relationship(back_populates="sessions")
|
||||
@@ -0,0 +1,14 @@
|
||||
from sqlalchemy import String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from apps.api.src.models.base import Base
|
||||
|
||||
|
||||
class SystemSetting(Base):
|
||||
__tablename__ = "system_settings"
|
||||
|
||||
key: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
|
||||
value: Mapped[dict] = mapped_column(JSONB, nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
is_sensitive: Mapped[bool] = mapped_column(default=False, nullable=False)
|
||||
@@ -0,0 +1,43 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from apps.api.src.models.base import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
|
||||
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
first_name: Mapped[str | None] = mapped_column(String(100))
|
||||
last_name: Mapped[str | None] = mapped_column(String(100))
|
||||
avatar_url: Mapped[str | None] = mapped_column(String(500))
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
is_superuser: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
is_owner: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
email_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
locale: Mapped[str] = mapped_column(String(10), default="en", nullable=False)
|
||||
theme: Mapped[str] = mapped_column(String(20), default="system", nullable=False)
|
||||
timezone: Mapped[str] = mapped_column(String(50), default="UTC", nullable=False)
|
||||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
last_login_ip: Mapped[str | None] = mapped_column(String(45))
|
||||
login_attempts: Mapped[int] = mapped_column(default=0, nullable=False)
|
||||
locked_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
invite_token: Mapped[str | None] = mapped_column(String(255), index=True)
|
||||
invite_token_expires: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
password_reset_token: Mapped[str | None] = mapped_column(String(255), index=True)
|
||||
password_reset_expires: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
preferences: Mapped[dict] = mapped_column(JSONB, default=dict, nullable=False)
|
||||
role_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("roles.id", ondelete="SET NULL"), index=True
|
||||
)
|
||||
|
||||
role: Mapped["Role"] = relationship(back_populates="users")
|
||||
sessions: Mapped[list["Session"]] = relationship(back_populates="user", cascade="all, delete-orphan")
|
||||
dashboards: Mapped[list["Dashboard"]] = relationship(back_populates="owner")
|
||||
api_tokens: Mapped[list["ApiToken"]] = relationship(back_populates="user", cascade="all, delete-orphan")
|
||||
audit_logs: Mapped[list["AuditLog"]] = relationship(back_populates="user")
|
||||
@@ -0,0 +1,37 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from apps.api.src.models.base import Base
|
||||
|
||||
|
||||
class Widget(Base):
|
||||
__tablename__ = "dashboard_widgets"
|
||||
|
||||
dashboard_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("dashboards.id", ondelete="CASCADE"), index=True, nullable=False
|
||||
)
|
||||
plugin_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
widget_type: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
position_x: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
position_y: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
width: Mapped[int] = mapped_column(Integer, default=2, nullable=False)
|
||||
height: Mapped[int] = mapped_column(Integer, default=2, nullable=False)
|
||||
min_width: Mapped[int | None] = mapped_column(Integer)
|
||||
min_height: Mapped[int | None] = mapped_column(Integer)
|
||||
max_width: Mapped[int | None] = mapped_column(Integer)
|
||||
max_height: Mapped[int | None] = mapped_column(Integer)
|
||||
order_index: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
settings: Mapped[dict] = mapped_column(JSONB, default=dict, nullable=False)
|
||||
instance_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("plugin_instances.id", ondelete="SET NULL"), index=True
|
||||
)
|
||||
is_visible: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
is_static: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
dashboard: Mapped["Dashboard"] = relationship(back_populates="widgets")
|
||||
instance: Mapped["PluginInstance"] = relationship(back_populates="widgets")
|
||||
@@ -0,0 +1,68 @@
|
||||
import uuid
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from apps.api.src.models.plugin_instance import PluginInstance
|
||||
from apps.api.src.models.service_connection import ServiceConnection
|
||||
from apps.api.src.security.encryption import decrypt_dict
|
||||
from apps.api.src.services import secret_service
|
||||
|
||||
|
||||
class PluginContext:
|
||||
def __init__(
|
||||
self,
|
||||
instance: PluginInstance,
|
||||
connection: ServiceConnection | None,
|
||||
credentials: dict[str, Any] | None,
|
||||
http_client: httpx.AsyncClient,
|
||||
):
|
||||
self.instance = instance
|
||||
self.connection = connection
|
||||
self.credentials = credentials or {}
|
||||
self.http_client = http_client
|
||||
|
||||
|
||||
class PluginConnector(ABC):
|
||||
id: str = ""
|
||||
name: str = ""
|
||||
default_timeout: float = 30.0
|
||||
|
||||
def __init__(self, context: PluginContext):
|
||||
self.context = context
|
||||
|
||||
async def get_credentials(self) -> dict[str, Any]:
|
||||
return self.context.credentials
|
||||
|
||||
def get_client(self) -> httpx.AsyncClient:
|
||||
return self.context.http_client
|
||||
|
||||
@abstractmethod
|
||||
async def healthcheck(self) -> dict[str, Any]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def fetch_widget_data(self, widget_type: str, settings: dict[str, Any]) -> dict[str, Any]:
|
||||
pass
|
||||
|
||||
async def close(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
async def build_context(
|
||||
db,
|
||||
instance: PluginInstance,
|
||||
) -> PluginContext:
|
||||
connection = instance.service_connection
|
||||
credentials = None
|
||||
if connection and connection.credentials_id:
|
||||
secret_value = await secret_service.decrypt_secret_value(db, connection.credentials_id)
|
||||
credentials = decrypt_dict(secret_value)
|
||||
|
||||
client = httpx.AsyncClient(
|
||||
verify=connection.verify_tls if connection else True,
|
||||
timeout=connection.timeout_seconds if connection else 30,
|
||||
follow_redirects=False,
|
||||
)
|
||||
return PluginContext(instance, connection, credentials, client)
|
||||
@@ -0,0 +1,21 @@
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
async def safe_json(response: httpx.Response) -> dict[str, Any]:
|
||||
try:
|
||||
return response.json()
|
||||
except Exception:
|
||||
return {"status": response.status_code, "text": response.text[:500]}
|
||||
|
||||
|
||||
def build_auth_headers(credentials: dict[str, Any]) -> dict[str, str]:
|
||||
headers = {}
|
||||
if credentials.get("api_token"):
|
||||
headers["Authorization"] = f"PVEAPIToken={credentials['api_token']}"
|
||||
elif credentials.get("token"):
|
||||
headers["Authorization"] = f"Bearer {credentials['token']}"
|
||||
elif credentials.get("api_key"):
|
||||
headers["Authorization"] = credentials["api_key"]
|
||||
return headers
|
||||
@@ -0,0 +1,57 @@
|
||||
from typing import Any
|
||||
|
||||
from apps.api.src.plugins.base import PluginConnector
|
||||
from apps.api.src.plugins.connectors._shared import safe_json
|
||||
|
||||
|
||||
class AdGuardHomeConnector(PluginConnector):
|
||||
id = "adguard-home"
|
||||
name = "AdGuard Home"
|
||||
|
||||
def _url(self, path: str) -> str:
|
||||
base = self.context.connection.base_url.rstrip("/") if self.context.connection else ""
|
||||
return f"{base}/control{path}"
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
headers = {}
|
||||
creds = self.context.credentials
|
||||
if creds.get("username") and creds.get("password"):
|
||||
from base64 import b64encode
|
||||
token = b64encode(f"{creds['username']}:{creds['password']}".encode()).decode()
|
||||
headers["Authorization"] = f"Basic {token}"
|
||||
return headers
|
||||
|
||||
async def healthcheck(self) -> dict[str, Any]:
|
||||
try:
|
||||
resp = await self.get_client().get(self._url("/status"), headers=self._headers())
|
||||
ok = resp.status_code == 200
|
||||
return {"status": "ok" if ok else "error", "status_code": resp.status_code}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
async def fetch_widget_data(self, widget_type: str, settings: dict[str, Any]) -> dict[str, Any]:
|
||||
if widget_type == "dns-overview":
|
||||
return await self._get_stats()
|
||||
if widget_type == "block-rate-chart":
|
||||
return await self._get_stats()
|
||||
if widget_type == "top-clients":
|
||||
return await self._get_top_clients()
|
||||
if widget_type == "top-blocked-domains":
|
||||
return await self._get_top_blocked()
|
||||
return {"error": "Unsupported widget type"}
|
||||
|
||||
async def _get_stats(self) -> dict[str, Any]:
|
||||
resp = await self.get_client().get(self._url("/stats"), headers=self._headers())
|
||||
return await safe_json(resp)
|
||||
|
||||
async def _get_top_clients(self) -> dict[str, Any]:
|
||||
resp = await self.get_client().get(
|
||||
self._url("/stats/top_clients?limit=10"), headers=self._headers()
|
||||
)
|
||||
return await safe_json(resp)
|
||||
|
||||
async def _get_top_blocked(self) -> dict[str, Any]:
|
||||
resp = await self.get_client().get(
|
||||
self._url("/stats/top_blocked_domains?limit=10"), headers=self._headers()
|
||||
)
|
||||
return await safe_json(resp)
|
||||
@@ -0,0 +1,51 @@
|
||||
from typing import Any
|
||||
|
||||
from apps.api.src.plugins.base import PluginConnector
|
||||
from apps.api.src.plugins.connectors._shared import safe_json
|
||||
|
||||
|
||||
class AuthentikConnector(PluginConnector):
|
||||
id = "authentik"
|
||||
name = "authentik"
|
||||
|
||||
def _url(self, path: str) -> str:
|
||||
base = self.context.connection.base_url.rstrip("/") if self.context.connection else ""
|
||||
return f"{base}/api/v3{path}"
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
headers = {"Accept": "application/json"}
|
||||
creds = self.context.credentials
|
||||
if creds.get("api_token"):
|
||||
headers["Authorization"] = f"Bearer {creds['api_token']}"
|
||||
return headers
|
||||
|
||||
async def healthcheck(self) -> dict[str, Any]:
|
||||
try:
|
||||
resp = await self.get_client().get(self._url("/core/users/"), headers=self._headers())
|
||||
ok = resp.status_code == 200
|
||||
return {"status": "ok" if ok else "error", "status_code": resp.status_code}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
async def fetch_widget_data(self, widget_type: str, settings: dict[str, Any]) -> dict[str, Any]:
|
||||
if widget_type == "auth-overview":
|
||||
return await self._get_users()
|
||||
if widget_type == "recent-logins":
|
||||
return await self._get_events()
|
||||
if widget_type == "app-status":
|
||||
return await self._get_applications()
|
||||
return {"error": "Unsupported widget type"}
|
||||
|
||||
async def _get_users(self) -> dict[str, Any]:
|
||||
resp = await self.get_client().get(self._url("/core/users/"), headers=self._headers())
|
||||
return await safe_json(resp)
|
||||
|
||||
async def _get_events(self) -> dict[str, Any]:
|
||||
resp = await self.get_client().get(
|
||||
self._url("/events/events/?ordering=-created&page_size=20"), headers=self._headers()
|
||||
)
|
||||
return await safe_json(resp)
|
||||
|
||||
async def _get_applications(self) -> dict[str, Any]:
|
||||
resp = await self.get_client().get(self._url("/core/applications/"), headers=self._headers())
|
||||
return await safe_json(resp)
|
||||
@@ -0,0 +1,49 @@
|
||||
from typing import Any
|
||||
|
||||
from apps.api.src.plugins.base import PluginConnector
|
||||
from apps.api.src.plugins.connectors._shared import safe_json
|
||||
|
||||
|
||||
class BeszelConnector(PluginConnector):
|
||||
id = "beszel"
|
||||
name = "Beszel"
|
||||
|
||||
def _url(self, path: str) -> str:
|
||||
base = self.context.connection.base_url.rstrip("/") if self.context.connection else ""
|
||||
return f"{base}/api{path}"
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
headers = {"Accept": "application/json"}
|
||||
creds = self.context.credentials
|
||||
if creds.get("api_token"):
|
||||
headers["Authorization"] = f"Bearer {creds['api_token']}"
|
||||
return headers
|
||||
|
||||
async def healthcheck(self) -> dict[str, Any]:
|
||||
try:
|
||||
resp = await self.get_client().get(self._url("/systems"), headers=self._headers())
|
||||
ok = resp.status_code == 200
|
||||
return {"status": "ok" if ok else "error", "status_code": resp.status_code}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
async def fetch_widget_data(self, widget_type: str, settings: dict[str, Any]) -> dict[str, Any]:
|
||||
if widget_type == "system-overview":
|
||||
return await self._get_systems()
|
||||
if widget_type == "resource-chart":
|
||||
return await self._get_system_stats(settings.get("system_id"))
|
||||
if widget_type == "host-cards":
|
||||
return await self._get_systems()
|
||||
return {"error": "Unsupported widget type"}
|
||||
|
||||
async def _get_systems(self) -> dict[str, Any]:
|
||||
resp = await self.get_client().get(self._url("/systems"), headers=self._headers())
|
||||
return await safe_json(resp)
|
||||
|
||||
async def _get_system_stats(self, system_id: str | None) -> dict[str, Any]:
|
||||
if not system_id:
|
||||
return {"error": "system_id required"}
|
||||
resp = await self.get_client().get(
|
||||
self._url(f"/systems/{system_id}/stats"), headers=self._headers()
|
||||
)
|
||||
return await safe_json(resp)
|
||||
@@ -0,0 +1,72 @@
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from apps.api.src.plugins.base import PluginConnector
|
||||
from apps.api.src.plugins.connectors._shared import safe_json
|
||||
|
||||
|
||||
class GenericHTTPConnector(PluginConnector):
|
||||
id = "generic-http"
|
||||
name = "Generic HTTP"
|
||||
|
||||
async def healthcheck(self) -> dict[str, Any]:
|
||||
return await self._check(self.context.instance.settings)
|
||||
|
||||
async def fetch_widget_data(self, widget_type: str, settings: dict[str, Any]) -> dict[str, Any]:
|
||||
return await self._check(settings)
|
||||
|
||||
async def _check(self, settings: dict[str, Any]) -> dict[str, Any]:
|
||||
url = settings.get("url")
|
||||
if not url:
|
||||
return {"error": "url required"}
|
||||
method = settings.get("method", "GET").upper()
|
||||
headers = settings.get("headers", {})
|
||||
body = settings.get("body")
|
||||
expected_status = settings.get("expected_status", 200)
|
||||
json_path = settings.get("json_path")
|
||||
regex = settings.get("regex")
|
||||
|
||||
try:
|
||||
if method == "GET":
|
||||
resp = await self.get_client().get(url, headers=headers)
|
||||
elif method == "POST":
|
||||
resp = await self.get_client().post(url, headers=headers, json=body)
|
||||
else:
|
||||
return {"error": "Unsupported method"}
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"status": "ok" if resp.status_code == expected_status else "error",
|
||||
"status_code": resp.status_code,
|
||||
"latency_ms": int(resp.elapsed.total_seconds() * 1000),
|
||||
}
|
||||
|
||||
if resp.status_code == expected_status:
|
||||
text = resp.text
|
||||
if json_path:
|
||||
try:
|
||||
data = resp.json()
|
||||
result["extracted"] = self._extract_json_path(data, json_path)
|
||||
except Exception:
|
||||
result["extracted"] = None
|
||||
if regex:
|
||||
match = re.search(regex, text)
|
||||
result["regex_match"] = bool(match)
|
||||
if match:
|
||||
result["regex_groups"] = match.groups()
|
||||
if not json_path and not regex:
|
||||
result["body_preview"] = text[:500]
|
||||
return result
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
def _extract_json_path(self, data: Any, path: str) -> Any:
|
||||
parts = path.replace("[", ".").replace("]", "").split(".")
|
||||
for part in parts:
|
||||
if isinstance(data, dict):
|
||||
data = data.get(part)
|
||||
elif isinstance(data, list) and part.isdigit():
|
||||
data = data[int(part)] if int(part) < len(data) else None
|
||||
else:
|
||||
return None
|
||||
return data
|
||||
@@ -0,0 +1,65 @@
|
||||
from typing import Any
|
||||
|
||||
from apps.api.src.plugins.base import PluginConnector
|
||||
from apps.api.src.plugins.connectors._shared import safe_json
|
||||
|
||||
|
||||
class HomeAssistantConnector(PluginConnector):
|
||||
id = "home-assistant"
|
||||
name = "Home Assistant"
|
||||
|
||||
def _url(self, path: str) -> str:
|
||||
base = self.context.connection.base_url.rstrip("/") if self.context.connection else ""
|
||||
return f"{base}/api{path}"
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
creds = self.context.credentials
|
||||
if creds.get("long_lived_token"):
|
||||
headers["Authorization"] = f"Bearer {creds['long_lived_token']}"
|
||||
return headers
|
||||
|
||||
async def healthcheck(self) -> dict[str, Any]:
|
||||
try:
|
||||
resp = await self.get_client().get(self._url("/"), headers=self._headers())
|
||||
ok = resp.status_code == 200
|
||||
return {"status": "ok" if ok else "error", "status_code": resp.status_code}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
async def fetch_widget_data(self, widget_type: str, settings: dict[str, Any]) -> dict[str, Any]:
|
||||
if widget_type == "entity-card":
|
||||
return await self._get_entity(settings.get("entity_id"))
|
||||
if widget_type == "sensor-chart":
|
||||
return await self._get_history(settings.get("entity_id"))
|
||||
if widget_type == "home-status":
|
||||
return await self._get_states()
|
||||
if widget_type == "automation-status":
|
||||
return await self._get_automations()
|
||||
return {"error": "Unsupported widget type"}
|
||||
|
||||
async def _get_entity(self, entity_id: str | None) -> dict[str, Any]:
|
||||
if not entity_id:
|
||||
return {"error": "entity_id required"}
|
||||
resp = await self.get_client().get(self._url(f"/states/{entity_id}"), headers=self._headers())
|
||||
return await safe_json(resp)
|
||||
|
||||
async def _get_history(self, entity_id: str | None) -> dict[str, Any]:
|
||||
if not entity_id:
|
||||
return {"error": "entity_id required"}
|
||||
resp = await self.get_client().get(
|
||||
self._url(f"/history/period?filter_entity_id={entity_id}&minimal_response"),
|
||||
headers=self._headers(),
|
||||
)
|
||||
return await safe_json(resp)
|
||||
|
||||
async def _get_states(self) -> dict[str, Any]:
|
||||
resp = await self.get_client().get(self._url("/states"), headers=self._headers())
|
||||
return await safe_json(resp)
|
||||
|
||||
async def _get_automations(self) -> dict[str, Any]:
|
||||
resp = await self.get_client().get(self._url("/states"), headers=self._headers())
|
||||
data = await safe_json(resp)
|
||||
if isinstance(data, dict) and "data" in data:
|
||||
data["data"] = [s for s in data["data"] if s.get("entity_id", "").startswith("automation.")]
|
||||
return data
|
||||
@@ -0,0 +1,51 @@
|
||||
from typing import Any
|
||||
|
||||
from apps.api.src.plugins.base import PluginConnector
|
||||
from apps.api.src.plugins.connectors._shared import safe_json
|
||||
|
||||
|
||||
class JellyfinConnector(PluginConnector):
|
||||
id = "jellyfin"
|
||||
name = "Jellyfin"
|
||||
|
||||
def _url(self, path: str) -> str:
|
||||
base = self.context.connection.base_url.rstrip("/") if self.context.connection else ""
|
||||
return f"{base}{path}"
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
headers = {"Accept": "application/json"}
|
||||
creds = self.context.credentials
|
||||
if creds.get("api_key"):
|
||||
headers["X-Emby-Token"] = creds["api_key"]
|
||||
elif creds.get("token"):
|
||||
headers["Authorization"] = f"MediaBrowser Token={creds['token']}"
|
||||
return headers
|
||||
|
||||
async def healthcheck(self) -> dict[str, Any]:
|
||||
try:
|
||||
resp = await self.get_client().get(self._url("/System/Info/Public"), headers=self._headers())
|
||||
ok = resp.status_code == 200
|
||||
return {"status": "ok" if ok else "error", "status_code": resp.status_code}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
async def fetch_widget_data(self, widget_type: str, settings: dict[str, Any]) -> dict[str, Any]:
|
||||
if widget_type == "jellyfin-overview":
|
||||
return await self._get_system_info()
|
||||
if widget_type == "active-streams":
|
||||
return await self._get_sessions()
|
||||
if widget_type == "library-card":
|
||||
return await self._get_libraries()
|
||||
return {"error": "Unsupported widget type"}
|
||||
|
||||
async def _get_system_info(self) -> dict[str, Any]:
|
||||
resp = await self.get_client().get(self._url("/System/Info"), headers=self._headers())
|
||||
return await safe_json(resp)
|
||||
|
||||
async def _get_sessions(self) -> dict[str, Any]:
|
||||
resp = await self.get_client().get(self._url("/Sessions"), headers=self._headers())
|
||||
return await safe_json(resp)
|
||||
|
||||
async def _get_libraries(self) -> dict[str, Any]:
|
||||
resp = await self.get_client().get(self._url("/Library/VirtualFolders"), headers=self._headers())
|
||||
return await safe_json(resp)
|
||||
@@ -0,0 +1,55 @@
|
||||
from typing import Any
|
||||
|
||||
from apps.api.src.plugins.base import PluginConnector
|
||||
from apps.api.src.plugins.connectors._shared import build_auth_headers, safe_json
|
||||
|
||||
|
||||
class ProxmoxBackupServerConnector(PluginConnector):
|
||||
id = "proxmox-backup-server"
|
||||
name = "Proxmox Backup Server"
|
||||
|
||||
def _url(self, path: str) -> str:
|
||||
base = self.context.connection.base_url.rstrip("/") if self.context.connection else ""
|
||||
return f"{base}/api2/json{path}"
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
headers = build_auth_headers(self.context.credentials)
|
||||
headers.setdefault("Accept", "application/json")
|
||||
return headers
|
||||
|
||||
async def healthcheck(self) -> dict[str, Any]:
|
||||
try:
|
||||
resp = await self.get_client().get(self._url("/status"), headers=self._headers())
|
||||
ok = resp.status_code == 200
|
||||
return {"status": "ok" if ok else "error", "status_code": resp.status_code}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
async def fetch_widget_data(self, widget_type: str, settings: dict[str, Any]) -> dict[str, Any]:
|
||||
if widget_type == "pbs-overview":
|
||||
return await self._get_status()
|
||||
if widget_type == "backup-job-status":
|
||||
return await self._get_jobs()
|
||||
if widget_type == "datastore-usage":
|
||||
return await self._get_datastores()
|
||||
if widget_type == "failed-backups":
|
||||
return await self._get_failed_jobs()
|
||||
return {"error": "Unsupported widget type"}
|
||||
|
||||
async def _get_status(self) -> dict[str, Any]:
|
||||
resp = await self.get_client().get(self._url("/status"), headers=self._headers())
|
||||
return await safe_json(resp)
|
||||
|
||||
async def _get_jobs(self) -> dict[str, Any]:
|
||||
resp = await self.get_client().get(self._url("/admin/datastore"), headers=self._headers())
|
||||
return await safe_json(resp)
|
||||
|
||||
async def _get_datastores(self) -> dict[str, Any]:
|
||||
resp = await self.get_client().get(self._url("/admin/datastore"), headers=self._headers())
|
||||
return await safe_json(resp)
|
||||
|
||||
async def _get_failed_jobs(self) -> dict[str, Any]:
|
||||
resp = await self.get_client().get(
|
||||
self._url("/admin/datastore?failed-only=true"), headers=self._headers()
|
||||
)
|
||||
return await safe_json(resp)
|
||||
@@ -0,0 +1,65 @@
|
||||
from typing import Any
|
||||
|
||||
from apps.api.src.plugins.base import PluginConnector
|
||||
from apps.api.src.plugins.connectors._shared import build_auth_headers, safe_json
|
||||
|
||||
|
||||
class ProxmoxVEConnector(PluginConnector):
|
||||
id = "proxmox-ve"
|
||||
name = "Proxmox VE"
|
||||
|
||||
def _client(self) -> Any:
|
||||
return self.get_client()
|
||||
|
||||
def _url(self, path: str) -> str:
|
||||
base = self.context.connection.base_url.rstrip("/") if self.context.connection else ""
|
||||
return f"{base}/api2/json{path}"
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
headers = build_auth_headers(self.context.credentials)
|
||||
headers.setdefault("Accept", "application/json")
|
||||
return headers
|
||||
|
||||
async def healthcheck(self) -> dict[str, Any]:
|
||||
try:
|
||||
resp = await self._client().get(self._url("/cluster/status"), headers=self._headers())
|
||||
data = await safe_json(resp)
|
||||
ok = resp.status_code == 200 and "data" in data
|
||||
return {"status": "ok" if ok else "error", "status_code": resp.status_code, "data": data}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
async def fetch_widget_data(self, widget_type: str, settings: dict[str, Any]) -> dict[str, Any]:
|
||||
if widget_type == "cluster-overview":
|
||||
return await self._get_cluster_status()
|
||||
if widget_type == "node-card":
|
||||
return await self._get_node(settings.get("node"))
|
||||
if widget_type == "vm-lxc-list":
|
||||
return await self._get_vms_and_lxc()
|
||||
if widget_type == "storage-usage":
|
||||
return await self._get_storage()
|
||||
if widget_type == "resource-graph":
|
||||
return await self._get_resources()
|
||||
return {"error": "Unsupported widget type"}
|
||||
|
||||
async def _get_cluster_status(self) -> dict[str, Any]:
|
||||
resp = await self._client().get(self._url("/cluster/status"), headers=self._headers())
|
||||
return await safe_json(resp)
|
||||
|
||||
async def _get_node(self, node: str | None) -> dict[str, Any]:
|
||||
if not node:
|
||||
return {"error": "node required"}
|
||||
resp = await self._client().get(self._url(f"/nodes/{node}/status"), headers=self._headers())
|
||||
return await safe_json(resp)
|
||||
|
||||
async def _get_vms_and_lxc(self) -> dict[str, Any]:
|
||||
resp = await self._client().get(self._url("/cluster/resources?type=vm"), headers=self._headers())
|
||||
return await safe_json(resp)
|
||||
|
||||
async def _get_storage(self) -> dict[str, Any]:
|
||||
resp = await self._client().get(self._url("/cluster/resources?type=storage"), headers=self._headers())
|
||||
return await safe_json(resp)
|
||||
|
||||
async def _get_resources(self) -> dict[str, Any]:
|
||||
resp = await self._client().get(self._url("/cluster/resources"), headers=self._headers())
|
||||
return await safe_json(resp)
|
||||
@@ -0,0 +1,49 @@
|
||||
from typing import Any
|
||||
|
||||
from apps.api.src.plugins.base import PluginConnector
|
||||
from apps.api.src.plugins.connectors._shared import safe_json
|
||||
|
||||
|
||||
class ZoraxyConnector(PluginConnector):
|
||||
id = "zoraxy"
|
||||
name = "Zoraxy"
|
||||
|
||||
def _url(self, path: str) -> str:
|
||||
base = self.context.connection.base_url.rstrip("/") if self.context.connection else ""
|
||||
return f"{base}/api{path}"
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
headers = {}
|
||||
creds = self.context.credentials
|
||||
if creds.get("api_key"):
|
||||
headers["Authorization"] = f"Bearer {creds['api_key']}"
|
||||
return headers
|
||||
|
||||
async def healthcheck(self) -> dict[str, Any]:
|
||||
try:
|
||||
resp = await self.get_client().get(self._url("/status"), headers=self._headers())
|
||||
ok = resp.status_code in {200, 204}
|
||||
return {"status": "ok" if ok else "error", "status_code": resp.status_code}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
async def fetch_widget_data(self, widget_type: str, settings: dict[str, Any]) -> dict[str, Any]:
|
||||
if widget_type == "proxy-overview":
|
||||
return await self._get_proxy_hosts()
|
||||
if widget_type == "tls-expiry":
|
||||
return await self._get_tls_status()
|
||||
if widget_type == "backend-health":
|
||||
return await self._get_backend_health()
|
||||
return {"error": "Unsupported widget type"}
|
||||
|
||||
async def _get_proxy_hosts(self) -> dict[str, Any]:
|
||||
resp = await self.get_client().get(self._url("/hosts"), headers=self._headers())
|
||||
return await safe_json(resp)
|
||||
|
||||
async def _get_tls_status(self) -> dict[str, Any]:
|
||||
resp = await self.get_client().get(self._url("/tls"), headers=self._headers())
|
||||
return await safe_json(resp)
|
||||
|
||||
async def _get_backend_health(self) -> dict[str, Any]:
|
||||
resp = await self.get_client().get(self._url("/health"), headers=self._headers())
|
||||
return await safe_json(resp)
|
||||
@@ -0,0 +1,113 @@
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from apps.api.src.models.plugin import Plugin
|
||||
from apps.api.src.plugins import registry
|
||||
from apps.api.src.plugins.base import build_context
|
||||
from apps.api.src.services import plugin_service
|
||||
|
||||
|
||||
async def load_builtin_plugins(db: AsyncSession) -> None:
|
||||
"""Register built-in connectors in the database."""
|
||||
for plugin_id, connector_class in registry.list_connectors().items():
|
||||
existing = await plugin_service.get_plugin_by_id(db, plugin_id)
|
||||
if existing:
|
||||
existing.is_builtin = True
|
||||
existing.is_installed = True
|
||||
existing.name = connector_class.name
|
||||
await db.commit()
|
||||
continue
|
||||
|
||||
manifest = {
|
||||
"id": plugin_id,
|
||||
"name": connector_class.name,
|
||||
"version": "1.0.0",
|
||||
"nexadashPluginApi": "1.0",
|
||||
"description": f"Built-in connector for {connector_class.name}",
|
||||
"author": "NexaDash",
|
||||
"category": "Infrastructure",
|
||||
"permissions": ["network:outbound", "secrets:read"],
|
||||
"settingsSchema": {},
|
||||
"credentialsSchema": {},
|
||||
"widgets": [],
|
||||
"hasFrontend": True,
|
||||
}
|
||||
plugin = Plugin(
|
||||
id=plugin_id,
|
||||
name=connector_class.name,
|
||||
version="1.0.0",
|
||||
description=f"Built-in connector for {connector_class.name}",
|
||||
author="NexaDash",
|
||||
category="Infrastructure",
|
||||
is_builtin=True,
|
||||
is_installed=True,
|
||||
is_active=True,
|
||||
path="",
|
||||
permissions=["network:outbound", "secrets:read"],
|
||||
settings_schema={},
|
||||
credentials_schema={},
|
||||
widget_types=[],
|
||||
healthcheck_definition={},
|
||||
api_routes=[],
|
||||
has_frontend=True,
|
||||
manifest=manifest,
|
||||
)
|
||||
db.add(plugin)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def fetch_widget_data(
|
||||
db: AsyncSession,
|
||||
instance_id: uuid.UUID,
|
||||
widget_type: str,
|
||||
settings: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
instance = await plugin_service.get_instance(db, instance_id)
|
||||
if not instance:
|
||||
return {"error": "Instance not found"}
|
||||
connector_class = registry.get_connector(instance.plugin_id)
|
||||
if not connector_class:
|
||||
return {"error": f"Connector not found for plugin {instance.plugin_id}"}
|
||||
|
||||
ctx = await build_context(db, instance)
|
||||
connector = connector_class(ctx)
|
||||
try:
|
||||
data = await connector.fetch_widget_data(widget_type, settings)
|
||||
return {"status": "ok", "data": data}
|
||||
except Exception as e:
|
||||
await plugin_service.add_plugin_log(
|
||||
db, instance.plugin_id, "error", str(e), instance_id=instance.id
|
||||
)
|
||||
return {"status": "error", "message": str(e)}
|
||||
finally:
|
||||
await connector.close()
|
||||
|
||||
|
||||
async def run_healthcheck(
|
||||
db: AsyncSession,
|
||||
instance_id: uuid.UUID,
|
||||
) -> dict[str, Any]:
|
||||
instance = await plugin_service.get_instance(db, instance_id)
|
||||
if not instance:
|
||||
return {"error": "Instance not found"}
|
||||
connector_class = registry.get_connector(instance.plugin_id)
|
||||
if not connector_class:
|
||||
return {"error": f"Connector not found for plugin {instance.plugin_id}"}
|
||||
|
||||
ctx = await build_context(db, instance)
|
||||
connector = connector_class(ctx)
|
||||
try:
|
||||
result = await connector.healthcheck()
|
||||
instance.health_status = result.get("status", "unknown")
|
||||
instance.health_message = result.get("message")
|
||||
await db.commit()
|
||||
return result
|
||||
except Exception as e:
|
||||
instance.health_status = "error"
|
||||
instance.health_message = str(e)
|
||||
await db.commit()
|
||||
return {"status": "error", "message": str(e)}
|
||||
finally:
|
||||
await connector.close()
|
||||
@@ -0,0 +1,38 @@
|
||||
from typing import Any, Type
|
||||
|
||||
from apps.api.src.plugins.base import PluginConnector
|
||||
from apps.api.src.plugins.connectors import (
|
||||
adguard_home,
|
||||
authentik,
|
||||
beszel,
|
||||
generic_http,
|
||||
home_assistant,
|
||||
jellyfin,
|
||||
proxmox_backup_server,
|
||||
proxmox_ve,
|
||||
zoraxy,
|
||||
)
|
||||
|
||||
_CONNECTORS: dict[str, Type[PluginConnector]] = {
|
||||
"proxmox-ve": proxmox_ve.ProxmoxVEConnector,
|
||||
"proxmox-backup-server": proxmox_backup_server.ProxmoxBackupServerConnector,
|
||||
"adguard-home": adguard_home.AdGuardHomeConnector,
|
||||
"zoraxy": zoraxy.ZoraxyConnector,
|
||||
"jellyfin": jellyfin.JellyfinConnector,
|
||||
"home-assistant": home_assistant.HomeAssistantConnector,
|
||||
"authentik": authentik.AuthentikConnector,
|
||||
"beszel": beszel.BeszelConnector,
|
||||
"generic-http": generic_http.GenericHTTPConnector,
|
||||
}
|
||||
|
||||
|
||||
def get_connector(plugin_id: str) -> Type[PluginConnector] | None:
|
||||
return _CONNECTORS.get(plugin_id)
|
||||
|
||||
|
||||
def register_connector(plugin_id: str, connector: Type[PluginConnector]) -> None:
|
||||
_CONNECTORS[plugin_id] = connector
|
||||
|
||||
|
||||
def list_connectors() -> dict[str, Type[PluginConnector]]:
|
||||
return _CONNECTORS.copy()
|
||||
@@ -0,0 +1,22 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class AuditLogResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
user_id: uuid.UUID | None
|
||||
action: str
|
||||
resource_type: str
|
||||
resource_id: str | None
|
||||
ip_address: str | None
|
||||
user_agent: str | None
|
||||
details: dict[str, Any]
|
||||
severity: str
|
||||
timestamp: datetime
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,31 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class SetupRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
locale: str = "en"
|
||||
|
||||
|
||||
class SetupStatusResponse(BaseModel):
|
||||
setup_required: bool
|
||||
@@ -0,0 +1,22 @@
|
||||
from typing import Any, Generic, TypeVar
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class PaginatedResponse(BaseModel, Generic[T]):
|
||||
items: list[T]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
pages: int
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
detail: str
|
||||
errors: dict[str, Any] | None = None
|
||||
@@ -0,0 +1,105 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class DashboardWidgetBase(BaseModel):
|
||||
plugin_id: str
|
||||
widget_type: str
|
||||
title: str
|
||||
description: str | None = None
|
||||
position_x: int = 0
|
||||
position_y: int = 0
|
||||
width: int = 2
|
||||
height: int = 2
|
||||
min_width: int | None = None
|
||||
min_height: int | None = None
|
||||
max_width: int | None = None
|
||||
max_height: int | None = None
|
||||
order_index: int = 0
|
||||
settings: dict = Field(default_factory=dict)
|
||||
instance_id: uuid.UUID | None = None
|
||||
is_visible: bool = True
|
||||
is_static: bool = False
|
||||
|
||||
|
||||
class DashboardWidgetCreate(DashboardWidgetBase):
|
||||
pass
|
||||
|
||||
|
||||
class DashboardWidgetUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
position_x: int | None = None
|
||||
position_y: int | None = None
|
||||
width: int | None = None
|
||||
height: int | None = None
|
||||
min_width: int | None = None
|
||||
min_height: int | None = None
|
||||
max_width: int | None = None
|
||||
max_height: int | None = None
|
||||
order_index: int | None = None
|
||||
settings: dict | None = None
|
||||
instance_id: uuid.UUID | None = None
|
||||
is_visible: bool | None = None
|
||||
is_static: bool | None = None
|
||||
|
||||
|
||||
class DashboardWidgetResponse(DashboardWidgetBase):
|
||||
id: uuid.UUID
|
||||
dashboard_id: uuid.UUID
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class DashboardBase(BaseModel):
|
||||
title: str
|
||||
description: str | None = None
|
||||
icon: str | None = None
|
||||
folder: str = "default"
|
||||
is_favorite: bool = False
|
||||
is_public: bool = False
|
||||
refresh_interval_seconds: int | None = 60
|
||||
order_index: int = 0
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class DashboardCreate(DashboardBase):
|
||||
layout: dict = Field(default_factory=dict)
|
||||
layouts_by_breakpoint: dict = Field(default_factory=lambda: {"lg": [], "md": [], "sm": [], "xs": [], "xxs": []})
|
||||
|
||||
|
||||
class DashboardUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
icon: str | None = None
|
||||
folder: str | None = None
|
||||
is_favorite: bool | None = None
|
||||
is_public: bool | None = None
|
||||
refresh_interval_seconds: int | None = None
|
||||
order_index: int | None = None
|
||||
tags: list[str] | None = None
|
||||
layout: dict | None = None
|
||||
layouts_by_breakpoint: dict | None = None
|
||||
|
||||
|
||||
class DashboardResponse(DashboardBase):
|
||||
id: uuid.UUID
|
||||
owner_id: uuid.UUID
|
||||
layout: dict
|
||||
layouts_by_breakpoint: dict
|
||||
widgets: list[DashboardWidgetResponse]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class DashboardLayoutUpdate(BaseModel):
|
||||
layout: dict
|
||||
layouts_by_breakpoint: dict
|
||||
@@ -0,0 +1,32 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class NotificationBase(BaseModel):
|
||||
title: str
|
||||
message: str | None = None
|
||||
type: str = "info"
|
||||
link: str | None = None
|
||||
metadata: dict[str, Any] = {}
|
||||
|
||||
|
||||
class NotificationCreate(NotificationBase):
|
||||
pass
|
||||
|
||||
|
||||
class NotificationUpdate(BaseModel):
|
||||
is_read: bool
|
||||
|
||||
|
||||
class NotificationResponse(NotificationBase):
|
||||
id: uuid.UUID
|
||||
user_id: uuid.UUID
|
||||
is_read: bool
|
||||
read_at: datetime | None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,117 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PluginManifest(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
version: str
|
||||
nexadashPluginApi: str
|
||||
description: str | None = None
|
||||
author: str | None = None
|
||||
category: str = "Generic"
|
||||
permissions: list[str] = Field(default_factory=list)
|
||||
settingsSchema: dict = Field(default_factory=dict)
|
||||
credentialsSchema: dict = Field(default_factory=dict)
|
||||
widgets: list[dict] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PluginBase(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
version: str
|
||||
description: str | None = None
|
||||
author: str | None = None
|
||||
category: str = "Generic"
|
||||
icon: str | None = None
|
||||
is_active: bool = False
|
||||
is_builtin: bool = False
|
||||
is_installed: bool = False
|
||||
permissions: list[str] = Field(default_factory=list)
|
||||
settings_schema: dict = Field(default_factory=dict)
|
||||
credentials_schema: dict = Field(default_factory=dict)
|
||||
widget_types: list[dict] = Field(default_factory=list)
|
||||
healthcheck_definition: dict = Field(default_factory=dict)
|
||||
api_routes: list[str] = Field(default_factory=list)
|
||||
has_frontend: bool = True
|
||||
|
||||
|
||||
class PluginResponse(PluginBase):
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class PluginCreate(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
version: str
|
||||
description: str | None = None
|
||||
author: str | None = None
|
||||
category: str = "Generic"
|
||||
icon: str | None = None
|
||||
is_builtin: bool = False
|
||||
permissions: list[str] = Field(default_factory=list)
|
||||
settings_schema: dict = Field(default_factory=dict)
|
||||
credentials_schema: dict = Field(default_factory=dict)
|
||||
widget_types: list[dict] = Field(default_factory=list)
|
||||
healthcheck_definition: dict = Field(default_factory=dict)
|
||||
api_routes: list[str] = Field(default_factory=list)
|
||||
has_frontend: bool = True
|
||||
manifest: dict = Field(default_factory=dict)
|
||||
path: str
|
||||
|
||||
|
||||
class PluginUpdate(BaseModel):
|
||||
is_active: bool | None = None
|
||||
is_installed: bool | None = None
|
||||
|
||||
|
||||
class PluginInstanceBase(BaseModel):
|
||||
plugin_id: str
|
||||
service_connection_id: uuid.UUID | None = None
|
||||
name: str
|
||||
settings: dict = Field(default_factory=dict)
|
||||
is_enabled: bool = True
|
||||
|
||||
|
||||
class PluginInstanceCreate(PluginInstanceBase):
|
||||
pass
|
||||
|
||||
|
||||
class PluginInstanceUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
service_connection_id: uuid.UUID | None = None
|
||||
settings: dict | None = None
|
||||
is_enabled: bool | None = None
|
||||
|
||||
|
||||
class PluginInstanceResponse(PluginInstanceBase):
|
||||
id: uuid.UUID
|
||||
health_status: str
|
||||
health_message: str | None = None
|
||||
last_sync_at: datetime | None
|
||||
last_error: str | None
|
||||
error_count: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class PluginLogResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
plugin_id: str
|
||||
instance_id: uuid.UUID | None
|
||||
level: str
|
||||
message: str
|
||||
context: dict
|
||||
timestamp: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,27 @@
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class RoleBase(BaseModel):
|
||||
name: str
|
||||
description: str | None = None
|
||||
permissions: list[str]
|
||||
|
||||
|
||||
class RoleCreate(RoleBase):
|
||||
pass
|
||||
|
||||
|
||||
class RoleUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
permissions: list[str] | None = None
|
||||
|
||||
|
||||
class RoleResponse(RoleBase):
|
||||
id: uuid.UUID
|
||||
is_system: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,31 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class SecretBase(BaseModel):
|
||||
name: str
|
||||
secret_type: str
|
||||
scope: str
|
||||
metadata: dict[str, Any] = {}
|
||||
|
||||
|
||||
class SecretCreate(SecretBase):
|
||||
value: str
|
||||
|
||||
|
||||
class SecretUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
value: str | None = None
|
||||
metadata: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class SecretResponse(SecretBase):
|
||||
id: uuid.UUID
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,49 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ServiceConnectionBase(BaseModel):
|
||||
name: str
|
||||
plugin_id: str
|
||||
base_url: str
|
||||
verify_tls: bool = True
|
||||
timeout_seconds: int = 30
|
||||
extra_headers: dict = Field(default_factory=dict)
|
||||
is_enabled: bool = True
|
||||
|
||||
|
||||
class ServiceConnectionCreate(ServiceConnectionBase):
|
||||
credentials: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ServiceConnectionUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
base_url: str | None = None
|
||||
verify_tls: bool | None = None
|
||||
timeout_seconds: int | None = None
|
||||
extra_headers: dict | None = None
|
||||
is_enabled: bool | None = None
|
||||
credentials: dict | None = None
|
||||
|
||||
|
||||
class ServiceConnectionResponse(ServiceConnectionBase):
|
||||
id: uuid.UUID
|
||||
credentials_id: uuid.UUID | None
|
||||
health_status: str
|
||||
health_message: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ServiceConnectionTestRequest(BaseModel):
|
||||
plugin_id: str
|
||||
base_url: str
|
||||
verify_tls: bool = True
|
||||
timeout_seconds: int = 30
|
||||
credentials: dict = Field(default_factory=dict)
|
||||
extra_headers: dict = Field(default_factory=dict)
|
||||
@@ -0,0 +1,19 @@
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class SystemSettingBase(BaseModel):
|
||||
key: str
|
||||
value: dict[str, Any]
|
||||
description: str | None = None
|
||||
is_sensitive: bool = False
|
||||
|
||||
|
||||
class SystemSettingUpdate(BaseModel):
|
||||
value: dict[str, Any]
|
||||
|
||||
|
||||
class SystemSettingResponse(SystemSettingBase):
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,79 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
|
||||
class UserBase(BaseModel):
|
||||
email: EmailStr
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
locale: str = "en"
|
||||
theme: str = "system"
|
||||
timezone: str = "UTC"
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
password: str = Field(..., min_length=12, max_length=128)
|
||||
role_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
email: EmailStr | None = None
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
locale: str | None = None
|
||||
theme: str | None = None
|
||||
timezone: str | None = None
|
||||
role_id: uuid.UUID | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class UserResponse(UserBase):
|
||||
id: uuid.UUID
|
||||
is_active: bool
|
||||
is_superuser: bool
|
||||
is_owner: bool
|
||||
email_verified: bool
|
||||
last_login_at: datetime | None
|
||||
avatar_url: str | None
|
||||
role: "RoleResponse" | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class UserInviteRequest(BaseModel):
|
||||
email: EmailStr
|
||||
role_id: uuid.UUID
|
||||
|
||||
|
||||
class UserInviteAccept(BaseModel):
|
||||
token: str
|
||||
password: str = Field(..., min_length=12, max_length=128)
|
||||
|
||||
|
||||
class PasswordChangeRequest(BaseModel):
|
||||
current_password: str
|
||||
new_password: str = Field(..., min_length=12, max_length=128)
|
||||
|
||||
|
||||
class PasswordResetRequest(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class PasswordResetConfirm(BaseModel):
|
||||
token: str
|
||||
new_password: str = Field(..., min_length=12, max_length=128)
|
||||
|
||||
|
||||
class ProfileUpdate(BaseModel):
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
locale: str | None = None
|
||||
theme: str | None = None
|
||||
timezone: str | None = None
|
||||
preferences: dict[str, Any] | None = None
|
||||
@@ -0,0 +1,33 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from apps.api.src.models.audit_log import AuditLog
|
||||
|
||||
|
||||
async def log_audit(
|
||||
db: AsyncSession,
|
||||
action: str,
|
||||
resource_type: str,
|
||||
user_id: uuid.UUID | None = None,
|
||||
resource_id: str | None = None,
|
||||
ip_address: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
details: dict[str, Any] | None = None,
|
||||
severity: str = "info",
|
||||
) -> None:
|
||||
entry = AuditLog(
|
||||
user_id=user_id,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
details=details or {},
|
||||
severity=severity,
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(entry)
|
||||
await db.commit()
|
||||
@@ -0,0 +1,94 @@
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, Security, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer, OAuth2PasswordBearer
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from apps.api.src.database import get_db
|
||||
from apps.api.src.models.role import Role
|
||||
from apps.api.src.models.user import User
|
||||
from apps.api.src.security import tokens
|
||||
from apps.api.src.security.permissions import has_permission
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login", auto_error=False)
|
||||
http_bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
async def get_current_user_from_token(
|
||||
token: str | None,
|
||||
db: AsyncSession,
|
||||
) -> User | None:
|
||||
if not token:
|
||||
return None
|
||||
payload = tokens.decode_token_safe(token)
|
||||
if not payload or payload.get("type") != "access":
|
||||
return None
|
||||
try:
|
||||
user_id = uuid.UUID(payload["sub"])
|
||||
except (KeyError, ValueError):
|
||||
return None
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user or not user.is_active:
|
||||
return None
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
credentials: HTTPAuthorizationCredentials | None = Security(http_bearer),
|
||||
) -> User:
|
||||
token = None
|
||||
if credentials:
|
||||
token = credentials.credentials
|
||||
if not token:
|
||||
token = request.cookies.get("access_token")
|
||||
if not token:
|
||||
token = await oauth2_scheme(request)
|
||||
user = await get_current_user_from_token(token, db)
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
return user
|
||||
|
||||
|
||||
async def get_optional_user(
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User | None:
|
||||
token = request.cookies.get("access_token")
|
||||
if not token:
|
||||
auth = request.headers.get("authorization")
|
||||
if auth and auth.startswith("Bearer "):
|
||||
token = auth[7:]
|
||||
return await get_current_user_from_token(token, db)
|
||||
|
||||
|
||||
class PermissionRequired:
|
||||
def __init__(self, permission: str) -> None:
|
||||
self.permission = permission
|
||||
|
||||
async def __call__(self, user: User = Depends(get_current_user)) -> User:
|
||||
perms = await get_user_permissions(user)
|
||||
if not has_permission(perms, self.permission):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Missing permission: {self.permission}",
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
async def get_user_permissions(user: User) -> list[str]:
|
||||
if user.is_owner or user.is_superuser:
|
||||
return ["*"]
|
||||
if user.role and user.role.permissions:
|
||||
return user.role.permissions
|
||||
return []
|
||||
|
||||
|
||||
async def load_user_role(user: User, db: AsyncSession) -> None:
|
||||
if user.role_id and not hasattr(user, "role"):
|
||||
result = await db.execute(select(Role).where(Role.id == user.role_id))
|
||||
user.role = result.scalar_one_or_none()
|
||||
@@ -0,0 +1,45 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from apps.api.src.config import settings
|
||||
|
||||
|
||||
def _get_fernet() -> Fernet:
|
||||
key = base64.urlsafe_b64encode(
|
||||
hashlib.sha256(settings.NEXADASH_ENCRYPTION_KEY.encode()).digest()[:32]
|
||||
)
|
||||
return Fernet(key)
|
||||
|
||||
|
||||
def encrypt_secret(plaintext: str) -> str:
|
||||
return _get_fernet().encrypt(plaintext.encode()).decode()
|
||||
|
||||
|
||||
def decrypt_secret(ciphertext: str) -> str:
|
||||
return _get_fernet().decrypt(ciphertext.encode()).decode()
|
||||
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
def generate_random_token(length: int = 32) -> str:
|
||||
return secrets.token_urlsafe(length)
|
||||
|
||||
|
||||
def mask_secret(value: str, visible: int = 4) -> str:
|
||||
if len(value) <= visible:
|
||||
return "*" * len(value)
|
||||
return "*" * (len(value) - visible) + value[-visible:]
|
||||
|
||||
|
||||
def encrypt_dict(data: dict) -> str:
|
||||
return encrypt_secret(json.dumps(data))
|
||||
|
||||
|
||||
def decrypt_dict(ciphertext: str) -> dict:
|
||||
return json.loads(decrypt_secret(ciphertext))
|
||||
@@ -0,0 +1,27 @@
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError
|
||||
|
||||
from apps.api.src.config import settings
|
||||
|
||||
ph = PasswordHasher(
|
||||
time_cost=settings.ARGON2_TIME_COST,
|
||||
memory_cost=settings.ARGON2_MEMORY_COST,
|
||||
parallelism=settings.ARGON2_PARALLELISM,
|
||||
hash_len=32,
|
||||
salt_len=16,
|
||||
)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return ph.hash(password)
|
||||
|
||||
|
||||
def verify_password(password: str, hashed: str) -> bool:
|
||||
try:
|
||||
return ph.verify(hashed, password)
|
||||
except VerifyMismatchError:
|
||||
return False
|
||||
|
||||
|
||||
def check_password_needs_rehash(hashed: str) -> bool:
|
||||
return ph.check_needs_rehash(hashed)
|
||||
@@ -0,0 +1,99 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class Permission(str, Enum):
|
||||
# User management
|
||||
USER_READ = "user:read"
|
||||
USER_WRITE = "user:write"
|
||||
USER_DELETE = "user:delete"
|
||||
USER_INVITE = "user:invite"
|
||||
|
||||
# Roles
|
||||
ROLE_READ = "role:read"
|
||||
ROLE_WRITE = "role:write"
|
||||
|
||||
# Dashboards
|
||||
DASHBOARD_READ = "dashboard:read"
|
||||
DASHBOARD_WRITE = "dashboard:write"
|
||||
DASHBOARD_DELETE = "dashboard:delete"
|
||||
DASHBOARD_SHARE = "dashboard:share"
|
||||
|
||||
# Plugins
|
||||
PLUGIN_READ = "plugin:read"
|
||||
PLUGIN_WRITE = "plugin:write"
|
||||
PLUGIN_DELETE = "plugin:delete"
|
||||
PLUGIN_ADMIN = "plugin:admin"
|
||||
PLUGIN_INSTALL = "plugin:install"
|
||||
|
||||
# Service connections
|
||||
CONNECTION_READ = "connection:read"
|
||||
CONNECTION_WRITE = "connection:write"
|
||||
CONNECTION_DELETE = "connection:delete"
|
||||
CONNECTION_TEST = "connection:test"
|
||||
|
||||
# System
|
||||
SYSTEM_READ = "system:read"
|
||||
SYSTEM_WRITE = "system:write"
|
||||
AUDIT_READ = "audit:read"
|
||||
BACKUP_RESTORE = "backup:restore"
|
||||
|
||||
# API tokens
|
||||
API_TOKEN_READ = "api_token:read"
|
||||
API_TOKEN_WRITE = "api_token:write"
|
||||
|
||||
|
||||
ROLE_PERMISSIONS = {
|
||||
"owner": [p.value for p in Permission],
|
||||
"admin": [
|
||||
Permission.USER_READ.value,
|
||||
Permission.USER_WRITE.value,
|
||||
Permission.USER_INVITE.value,
|
||||
Permission.ROLE_READ.value,
|
||||
Permission.DASHBOARD_READ.value,
|
||||
Permission.DASHBOARD_WRITE.value,
|
||||
Permission.DASHBOARD_DELETE.value,
|
||||
Permission.DASHBOARD_SHARE.value,
|
||||
Permission.PLUGIN_READ.value,
|
||||
Permission.PLUGIN_WRITE.value,
|
||||
Permission.PLUGIN_INSTALL.value,
|
||||
Permission.CONNECTION_READ.value,
|
||||
Permission.CONNECTION_WRITE.value,
|
||||
Permission.CONNECTION_DELETE.value,
|
||||
Permission.CONNECTION_TEST.value,
|
||||
Permission.SYSTEM_READ.value,
|
||||
Permission.SYSTEM_WRITE.value,
|
||||
Permission.AUDIT_READ.value,
|
||||
Permission.BACKUP_RESTORE.value,
|
||||
Permission.API_TOKEN_READ.value,
|
||||
Permission.API_TOKEN_WRITE.value,
|
||||
],
|
||||
"editor": [
|
||||
Permission.USER_READ.value,
|
||||
Permission.DASHBOARD_READ.value,
|
||||
Permission.DASHBOARD_WRITE.value,
|
||||
Permission.DASHBOARD_SHARE.value,
|
||||
Permission.PLUGIN_READ.value,
|
||||
Permission.CONNECTION_READ.value,
|
||||
Permission.CONNECTION_TEST.value,
|
||||
Permission.API_TOKEN_READ.value,
|
||||
Permission.API_TOKEN_WRITE.value,
|
||||
],
|
||||
"viewer": [
|
||||
Permission.USER_READ.value,
|
||||
Permission.DASHBOARD_READ.value,
|
||||
Permission.PLUGIN_READ.value,
|
||||
Permission.CONNECTION_READ.value,
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def has_permission(user_permissions: list[str], required: str) -> bool:
|
||||
return required in user_permissions or "*" in user_permissions
|
||||
|
||||
|
||||
def has_any_permission(user_permissions: list[str], required: list[str]) -> bool:
|
||||
return any(has_permission(user_permissions, p) for p in required)
|
||||
|
||||
|
||||
def has_all_permissions(user_permissions: list[str], required: list[str]) -> bool:
|
||||
return all(has_permission(user_permissions, p) for p in required)
|
||||
@@ -0,0 +1,9 @@
|
||||
from slowapi import Limiter, _rate_limit_exceeded_handler
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
from apps.api.src.config import settings
|
||||
|
||||
limiter = Limiter(
|
||||
key_func=get_remote_address,
|
||||
default_limits=[f"{settings.RATE_LIMIT_DEFAULT} per minute"],
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
BLOCKED_SCHEMES = {"file", "gopher", "ftp", "dict", "ldap", "tftp"}
|
||||
BLOCKED_HOSTS = {
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
"0.0.0.0",
|
||||
"::1",
|
||||
"169.254.169.254", # AWS metadata
|
||||
}
|
||||
|
||||
|
||||
def is_safe_url(url: str) -> bool:
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
except Exception:
|
||||
return False
|
||||
if parsed.scheme not in {"http", "https"}:
|
||||
return False
|
||||
hostname = parsed.hostname
|
||||
if not hostname:
|
||||
return False
|
||||
if hostname.lower() in BLOCKED_HOSTS:
|
||||
return False
|
||||
# Block internal IP ranges
|
||||
parts = hostname.split(".")
|
||||
if len(parts) == 4 and all(p.isdigit() for p in parts):
|
||||
first = int(parts[0])
|
||||
if first in {10, 127} or (first == 192 and parts[1] == "168"):
|
||||
return False
|
||||
return True
|
||||
@@ -0,0 +1,46 @@
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import jwt
|
||||
from jwt import PyJWTError
|
||||
|
||||
from apps.api.src.config import settings
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
|
||||
def create_access_token(subject: str, expires_delta: timedelta | None = None) -> str:
|
||||
if expires_delta:
|
||||
expire = datetime.now(timezone.utc) + expires_delta
|
||||
else:
|
||||
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
payload = {
|
||||
"sub": str(subject),
|
||||
"exp": expire,
|
||||
"iat": datetime.now(timezone.utc),
|
||||
"type": "access",
|
||||
}
|
||||
return jwt.encode(payload, settings.NEXADASH_SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def create_refresh_token(subject: str) -> tuple[str, datetime]:
|
||||
expire = datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
payload = {
|
||||
"sub": str(subject),
|
||||
"exp": expire,
|
||||
"iat": datetime.now(timezone.utc),
|
||||
"type": "refresh",
|
||||
"jti": str(uuid.uuid4()),
|
||||
}
|
||||
return jwt.encode(payload, settings.NEXADASH_SECRET_KEY, algorithm=ALGORITHM), expire
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict:
|
||||
return jwt.decode(token, settings.NEXADASH_SECRET_KEY, algorithms=[ALGORITHM])
|
||||
|
||||
|
||||
def decode_token_safe(token: str) -> dict | None:
|
||||
try:
|
||||
return decode_token(token)
|
||||
except PyJWTError:
|
||||
return None
|
||||
@@ -0,0 +1,46 @@
|
||||
from apps.api.src.database import AsyncSessionLocal
|
||||
from apps.api.src.plugins.loader import load_builtin_plugins
|
||||
from apps.api.src.services.role_service import seed_roles
|
||||
from apps.api.src.services.system_setting_service import set_setting
|
||||
|
||||
|
||||
async def seed() -> None:
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
await seed_roles(db)
|
||||
await load_builtin_plugins(db)
|
||||
# Seed default system settings
|
||||
await set_setting(
|
||||
db,
|
||||
"registration",
|
||||
{"enabled": False, "invite_only": True},
|
||||
)
|
||||
await set_setting(
|
||||
db,
|
||||
"mail",
|
||||
{
|
||||
"host": "",
|
||||
"port": 587,
|
||||
"tls": True,
|
||||
"starttls": True,
|
||||
"user": "",
|
||||
"from": "nexadash@localhost",
|
||||
},
|
||||
)
|
||||
await set_setting(
|
||||
db,
|
||||
"security",
|
||||
{
|
||||
"max_login_attempts": 5,
|
||||
"lockout_minutes": 15,
|
||||
"require_strong_passwords": True,
|
||||
},
|
||||
)
|
||||
await set_setting(
|
||||
db,
|
||||
"backup",
|
||||
{"enabled": False, "schedule": "0 2 * * *", "retention_days": 30},
|
||||
)
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
raise e
|
||||
@@ -0,0 +1,11 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from apps.api.src.models.audit_log import AuditLog
|
||||
|
||||
|
||||
async def list_audit_logs(db: AsyncSession, limit: int = 100) -> list[AuditLog]:
|
||||
result = await db.execute(
|
||||
select(AuditLog).order_by(AuditLog.timestamp.desc()).limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
@@ -0,0 +1,174 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from apps.api.src.models.dashboard import Dashboard
|
||||
from apps.api.src.models.widget import Widget
|
||||
from apps.api.src.schemas import dashboard as schemas
|
||||
from apps.api.src.models.user import User
|
||||
|
||||
|
||||
async def create_dashboard(db: AsyncSession, data: schemas.DashboardCreate, owner: User) -> Dashboard:
|
||||
dashboard = Dashboard(
|
||||
**data.model_dump(),
|
||||
owner_id=owner.id,
|
||||
)
|
||||
db.add(dashboard)
|
||||
await db.commit()
|
||||
await db.refresh(dashboard)
|
||||
return dashboard
|
||||
|
||||
|
||||
async def get_dashboard(db: AsyncSession, dashboard_id: uuid.UUID) -> Dashboard | None:
|
||||
result = await db.execute(
|
||||
select(Dashboard)
|
||||
.where(Dashboard.id == dashboard_id)
|
||||
.options(selectinload(Dashboard.widgets))
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_dashboards(db: AsyncSession, owner: User) -> list[Dashboard]:
|
||||
result = await db.execute(
|
||||
select(Dashboard)
|
||||
.where(Dashboard.owner_id == owner.id)
|
||||
.order_by(Dashboard.order_index, Dashboard.created_at)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def update_dashboard(
|
||||
db: AsyncSession,
|
||||
dashboard_id: uuid.UUID,
|
||||
data: schemas.DashboardUpdate,
|
||||
owner: User,
|
||||
) -> Dashboard:
|
||||
dashboard = await get_dashboard(db, dashboard_id)
|
||||
if not dashboard or dashboard.owner_id != owner.id:
|
||||
raise HTTPException(status_code=404, detail="Dashboard not found")
|
||||
for key, value in data.model_dump(exclude_unset=True).items():
|
||||
setattr(dashboard, key, value)
|
||||
await db.commit()
|
||||
await db.refresh(dashboard)
|
||||
return dashboard
|
||||
|
||||
|
||||
async def delete_dashboard(db: AsyncSession, dashboard_id: uuid.UUID, owner: User) -> None:
|
||||
dashboard = await get_dashboard(db, dashboard_id)
|
||||
if not dashboard or dashboard.owner_id != owner.id:
|
||||
raise HTTPException(status_code=404, detail="Dashboard not found")
|
||||
await db.delete(dashboard)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def duplicate_dashboard(db: AsyncSession, dashboard_id: uuid.UUID, owner: User) -> Dashboard:
|
||||
dashboard = await get_dashboard(db, dashboard_id)
|
||||
if not dashboard or dashboard.owner_id != owner.id:
|
||||
raise HTTPException(status_code=404, detail="Dashboard not found")
|
||||
|
||||
new_dashboard = Dashboard(
|
||||
title=f"{dashboard.title} (Copy)",
|
||||
description=dashboard.description,
|
||||
icon=dashboard.icon,
|
||||
folder=dashboard.folder,
|
||||
owner_id=owner.id,
|
||||
layout=dashboard.layout,
|
||||
layouts_by_breakpoint=dashboard.layouts_by_breakpoint,
|
||||
refresh_interval_seconds=dashboard.refresh_interval_seconds,
|
||||
tags=dashboard.tags,
|
||||
)
|
||||
db.add(new_dashboard)
|
||||
await db.flush()
|
||||
await db.refresh(new_dashboard)
|
||||
|
||||
for widget in dashboard.widgets:
|
||||
new_widget = Widget(
|
||||
dashboard_id=new_dashboard.id,
|
||||
plugin_id=widget.plugin_id,
|
||||
widget_type=widget.widget_type,
|
||||
title=widget.title,
|
||||
description=widget.description,
|
||||
position_x=widget.position_x,
|
||||
position_y=widget.position_y,
|
||||
width=widget.width,
|
||||
height=widget.height,
|
||||
min_width=widget.min_width,
|
||||
min_height=widget.min_height,
|
||||
max_width=widget.max_width,
|
||||
max_height=widget.max_height,
|
||||
order_index=widget.order_index,
|
||||
settings=widget.settings,
|
||||
instance_id=widget.instance_id,
|
||||
is_visible=widget.is_visible,
|
||||
is_static=widget.is_static,
|
||||
)
|
||||
db.add(new_widget)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(new_dashboard)
|
||||
return new_dashboard
|
||||
|
||||
|
||||
async def create_widget(
|
||||
db: AsyncSession,
|
||||
dashboard_id: uuid.UUID,
|
||||
data: schemas.DashboardWidgetCreate,
|
||||
owner: User,
|
||||
) -> Widget:
|
||||
dashboard = await get_dashboard(db, dashboard_id)
|
||||
if not dashboard or dashboard.owner_id != owner.id:
|
||||
raise HTTPException(status_code=404, detail="Dashboard not found")
|
||||
widget = Widget(dashboard_id=dashboard_id, **data.model_dump())
|
||||
db.add(widget)
|
||||
await db.commit()
|
||||
await db.refresh(widget)
|
||||
return widget
|
||||
|
||||
|
||||
async def update_widget(
|
||||
db: AsyncSession,
|
||||
widget_id: uuid.UUID,
|
||||
data: schemas.DashboardWidgetUpdate,
|
||||
owner: User,
|
||||
) -> Widget:
|
||||
result = await db.execute(
|
||||
select(Widget).where(Widget.id == widget_id).join(Dashboard).where(Dashboard.owner_id == owner.id)
|
||||
)
|
||||
widget = result.scalar_one_or_none()
|
||||
if not widget:
|
||||
raise HTTPException(status_code=404, detail="Widget not found")
|
||||
for key, value in data.model_dump(exclude_unset=True).items():
|
||||
setattr(widget, key, value)
|
||||
await db.commit()
|
||||
await db.refresh(widget)
|
||||
return widget
|
||||
|
||||
|
||||
async def delete_widget(db: AsyncSession, widget_id: uuid.UUID, owner: User) -> None:
|
||||
result = await db.execute(
|
||||
select(Widget).where(Widget.id == widget_id).join(Dashboard).where(Dashboard.owner_id == owner.id)
|
||||
)
|
||||
widget = result.scalar_one_or_none()
|
||||
if not widget:
|
||||
raise HTTPException(status_code=404, detail="Widget not found")
|
||||
await db.delete(widget)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def update_layout(
|
||||
db: AsyncSession,
|
||||
dashboard_id: uuid.UUID,
|
||||
data: schemas.DashboardLayoutUpdate,
|
||||
owner: User,
|
||||
) -> Dashboard:
|
||||
dashboard = await get_dashboard(db, dashboard_id)
|
||||
if not dashboard or dashboard.owner_id != owner.id:
|
||||
raise HTTPException(status_code=404, detail="Dashboard not found")
|
||||
dashboard.layout = data.layout
|
||||
dashboard.layouts_by_breakpoint = data.layouts_by_breakpoint
|
||||
await db.commit()
|
||||
await db.refresh(dashboard)
|
||||
return dashboard
|
||||
@@ -0,0 +1,54 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from apps.api.src.models.notification import Notification
|
||||
from apps.api.src.models.user import User
|
||||
|
||||
|
||||
async def create_notification(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
title: str,
|
||||
message: str | None,
|
||||
type: str = "info",
|
||||
link: str | None = None,
|
||||
metadata: dict | None = None,
|
||||
) -> Notification:
|
||||
notif = Notification(
|
||||
user_id=user.id,
|
||||
title=title,
|
||||
message=message,
|
||||
type=type,
|
||||
link=link,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
db.add(notif)
|
||||
await db.commit()
|
||||
await db.refresh(notif)
|
||||
return notif
|
||||
|
||||
|
||||
async def list_notifications(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
unread_only: bool = False,
|
||||
limit: int = 50,
|
||||
) -> list[Notification]:
|
||||
stmt = select(Notification).where(Notification.user_id == user.id)
|
||||
if unread_only:
|
||||
stmt = stmt.where(Notification.is_read == False)
|
||||
stmt = stmt.order_by(Notification.created_at.desc()).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def mark_as_read(db: AsyncSession, user: User, notification_id: uuid.UUID | None = None) -> None:
|
||||
stmt = update(Notification).where(Notification.user_id == user.id)
|
||||
if notification_id:
|
||||
stmt = stmt.where(Notification.id == notification_id)
|
||||
stmt = stmt.values(is_read=True, read_at=datetime.now(timezone.utc)) # type: ignore
|
||||
await db.execute(stmt)
|
||||
await db.commit()
|
||||
@@ -0,0 +1,245 @@
|
||||
import json
|
||||
import uuid
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import HTTPException, UploadFile
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from apps.api.src.models.plugin import Plugin
|
||||
from apps.api.src.models.plugin_instance import PluginInstance
|
||||
from apps.api.src.models.plugin_log import PluginLog
|
||||
from apps.api.src.models.plugin_version import PluginVersion
|
||||
from apps.api.src.schemas import plugin as schemas
|
||||
from apps.api.src.security.permissions import Permission
|
||||
|
||||
|
||||
class PluginValidationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
async def get_plugin_by_id(db: AsyncSession, plugin_id: str) -> Plugin | None:
|
||||
result = await db.execute(
|
||||
select(Plugin).where(Plugin.id == plugin_id).options(selectinload(Plugin.instances))
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_plugins(db: AsyncSession, active_only: bool = False) -> list[Plugin]:
|
||||
stmt = select(Plugin)
|
||||
if active_only:
|
||||
stmt = stmt.where(Plugin.is_active == True)
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def create_plugin(db: AsyncSession, data: schemas.PluginCreate) -> Plugin:
|
||||
existing = await get_plugin_by_id(db, data.id)
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Plugin already exists")
|
||||
plugin = Plugin(**data.model_dump())
|
||||
db.add(plugin)
|
||||
await db.commit()
|
||||
await db.refresh(plugin)
|
||||
return plugin
|
||||
|
||||
|
||||
async def update_plugin(
|
||||
db: AsyncSession,
|
||||
plugin_id: str,
|
||||
data: schemas.PluginUpdate,
|
||||
) -> Plugin:
|
||||
plugin = await get_plugin_by_id(db, plugin_id)
|
||||
if not plugin:
|
||||
raise HTTPException(status_code=404, detail="Plugin not found")
|
||||
for key, value in data.model_dump(exclude_unset=True).items():
|
||||
setattr(plugin, key, value)
|
||||
await db.commit()
|
||||
await db.refresh(plugin)
|
||||
return plugin
|
||||
|
||||
|
||||
async def delete_plugin(db: AsyncSession, plugin_id: str) -> None:
|
||||
plugin = await get_plugin_by_id(db, plugin_id)
|
||||
if not plugin:
|
||||
raise HTTPException(status_code=404, detail="Plugin not found")
|
||||
if plugin.is_builtin:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete built-in plugin")
|
||||
await db.delete(plugin)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def create_instance(
|
||||
db: AsyncSession,
|
||||
plugin_id: str,
|
||||
data: schemas.PluginInstanceCreate,
|
||||
) -> PluginInstance:
|
||||
plugin = await get_plugin_by_id(db, plugin_id)
|
||||
if not plugin:
|
||||
raise HTTPException(status_code=404, detail="Plugin not found")
|
||||
instance = PluginInstance(plugin_id=plugin_id, **data.model_dump())
|
||||
db.add(instance)
|
||||
await db.commit()
|
||||
await db.refresh(instance)
|
||||
return instance
|
||||
|
||||
|
||||
async def get_instance(db: AsyncSession, instance_id: uuid.UUID) -> PluginInstance | None:
|
||||
result = await db.execute(
|
||||
select(PluginInstance).where(PluginInstance.id == instance_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_instance(
|
||||
db: AsyncSession,
|
||||
instance_id: uuid.UUID,
|
||||
data: schemas.PluginInstanceUpdate,
|
||||
) -> PluginInstance:
|
||||
instance = await get_instance(db, instance_id)
|
||||
if not instance:
|
||||
raise HTTPException(status_code=404, detail="Plugin instance not found")
|
||||
for key, value in data.model_dump(exclude_unset=True).items():
|
||||
setattr(instance, key, value)
|
||||
await db.commit()
|
||||
await db.refresh(instance)
|
||||
return instance
|
||||
|
||||
|
||||
async def delete_instance(db: AsyncSession, instance_id: uuid.UUID) -> None:
|
||||
instance = await get_instance(db, instance_id)
|
||||
if not instance:
|
||||
raise HTTPException(status_code=404, detail="Plugin instance not found")
|
||||
await db.delete(instance)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def list_instances(db: AsyncSession, plugin_id: str | None = None) -> list[PluginInstance]:
|
||||
stmt = select(PluginInstance)
|
||||
if plugin_id:
|
||||
stmt = stmt.where(PluginInstance.plugin_id == plugin_id)
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def add_plugin_log(
|
||||
db: AsyncSession,
|
||||
plugin_id: str,
|
||||
level: str,
|
||||
message: str,
|
||||
instance_id: uuid.UUID | None = None,
|
||||
context: dict | None = None,
|
||||
) -> PluginLog:
|
||||
log = PluginLog(
|
||||
plugin_id=plugin_id,
|
||||
instance_id=instance_id,
|
||||
level=level,
|
||||
message=message,
|
||||
context=context or {},
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(log)
|
||||
await db.commit()
|
||||
await db.refresh(log)
|
||||
return log
|
||||
|
||||
|
||||
async def list_plugin_logs(
|
||||
db: AsyncSession,
|
||||
plugin_id: str | None = None,
|
||||
limit: int = 100,
|
||||
) -> list[PluginLog]:
|
||||
stmt = select(PluginLog)
|
||||
if plugin_id:
|
||||
stmt = stmt.where(PluginLog.plugin_id == plugin_id)
|
||||
stmt = stmt.order_by(PluginLog.timestamp.desc()).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
def validate_manifest(manifest: dict) -> None:
|
||||
required = ["id", "name", "version", "nexadashPluginApi"]
|
||||
for key in required:
|
||||
if not manifest.get(key):
|
||||
raise PluginValidationError(f"Missing required manifest field: {key}")
|
||||
if not isinstance(manifest.get("widgets", []), list):
|
||||
raise PluginValidationError("widgets must be a list")
|
||||
|
||||
|
||||
async def install_plugin_from_zip(db: AsyncSession, file: UploadFile, plugin_dir: Path) -> Plugin:
|
||||
import tempfile
|
||||
|
||||
temp_path = Path(tempfile.gettempdir()) / f"nexadash_plugin_{file.filename}"
|
||||
with temp_path.open("wb") as f:
|
||||
content = await file.read()
|
||||
f.write(content)
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(temp_path, "r") as z:
|
||||
names = z.namelist()
|
||||
manifest_names = [n for n in names if n.endswith("plugin.manifest.json")]
|
||||
if not manifest_names:
|
||||
raise PluginValidationError("plugin.manifest.json not found in archive")
|
||||
manifest_name = manifest_names[0]
|
||||
with z.open(manifest_name) as mf:
|
||||
manifest = json.load(mf)
|
||||
validate_manifest(manifest)
|
||||
plugin_id = manifest["id"]
|
||||
target_dir = plugin_dir / plugin_id
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
z.extractall(target_dir)
|
||||
except (json.JSONDecodeError, zipfile.BadZipFile) as e:
|
||||
raise PluginValidationError(f"Invalid plugin package: {e}")
|
||||
finally:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
|
||||
existing = await get_plugin_by_id(db, plugin_id)
|
||||
if existing:
|
||||
existing.is_installed = True
|
||||
existing.version = manifest["version"]
|
||||
existing.manifest = manifest
|
||||
existing.settings_schema = manifest.get("settingsSchema", {})
|
||||
existing.credentials_schema = manifest.get("credentialsSchema", {})
|
||||
existing.widget_types = manifest.get("widgets", [])
|
||||
existing.permissions = manifest.get("permissions", [])
|
||||
await db.commit()
|
||||
await db.refresh(existing)
|
||||
return existing
|
||||
|
||||
plugin = Plugin(
|
||||
id=plugin_id,
|
||||
name=manifest["name"],
|
||||
version=manifest["version"],
|
||||
description=manifest.get("description"),
|
||||
author=manifest.get("author"),
|
||||
category=manifest.get("category", "Generic"),
|
||||
is_installed=True,
|
||||
is_active=False,
|
||||
path=str(target_dir),
|
||||
manifest=manifest,
|
||||
settings_schema=manifest.get("settingsSchema", {}),
|
||||
credentials_schema=manifest.get("credentialsSchema", {}),
|
||||
widget_types=manifest.get("widgets", []),
|
||||
permissions=manifest.get("permissions", []),
|
||||
healthcheck_definition=manifest.get("healthcheck", {}),
|
||||
api_routes=manifest.get("apiRoutes", []),
|
||||
has_frontend=manifest.get("hasFrontend", True),
|
||||
)
|
||||
db.add(plugin)
|
||||
await db.commit()
|
||||
await db.refresh(plugin)
|
||||
|
||||
# Record version
|
||||
version = PluginVersion(
|
||||
plugin_id=plugin_id,
|
||||
version=manifest["version"],
|
||||
is_active=True,
|
||||
manifest=manifest,
|
||||
)
|
||||
db.add(version)
|
||||
await db.commit()
|
||||
|
||||
return plugin
|
||||
@@ -0,0 +1,69 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from apps.api.src.models.role import Role
|
||||
from apps.api.src.schemas import role as schemas
|
||||
|
||||
|
||||
async def seed_roles(db: AsyncSession) -> None:
|
||||
from apps.api.src.security.permissions import ROLE_PERMISSIONS
|
||||
|
||||
for name, permissions in ROLE_PERMISSIONS.items():
|
||||
result = await db.execute(select(Role).where(Role.name == name))
|
||||
if not result.scalar_one_or_none():
|
||||
db.add(
|
||||
Role(
|
||||
name=name,
|
||||
is_system=True,
|
||||
permissions=permissions,
|
||||
description=f"System role: {name}",
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def create_role(db: AsyncSession, data: schemas.RoleCreate) -> Role:
|
||||
existing = await db.execute(select(Role).where(Role.name == data.name))
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="Role already exists")
|
||||
role = Role(**data.model_dump())
|
||||
db.add(role)
|
||||
await db.commit()
|
||||
await db.refresh(role)
|
||||
return role
|
||||
|
||||
|
||||
async def get_role_by_id(db: AsyncSession, role_id: uuid.UUID) -> Role | None:
|
||||
result = await db.execute(select(Role).where(Role.id == role_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_roles(db: AsyncSession, skip: int = 0, limit: int = 100) -> list[Role]:
|
||||
result = await db.execute(select(Role).offset(skip).limit(limit))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def update_role(db: AsyncSession, role_id: uuid.UUID, data: schemas.RoleUpdate) -> Role:
|
||||
role = await get_role_by_id(db, role_id)
|
||||
if not role:
|
||||
raise HTTPException(status_code=404, detail="Role not found")
|
||||
if role.is_system:
|
||||
raise HTTPException(status_code=400, detail="Cannot modify system role")
|
||||
for key, value in data.model_dump(exclude_unset=True).items():
|
||||
setattr(role, key, value)
|
||||
await db.commit()
|
||||
await db.refresh(role)
|
||||
return role
|
||||
|
||||
|
||||
async def delete_role(db: AsyncSession, role_id: uuid.UUID) -> None:
|
||||
role = await get_role_by_id(db, role_id)
|
||||
if not role:
|
||||
raise HTTPException(status_code=404, detail="Role not found")
|
||||
if role.is_system:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete system role")
|
||||
await db.delete(role)
|
||||
await db.commit()
|
||||
@@ -0,0 +1,65 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from apps.api.src.models.secret import Secret
|
||||
from apps.api.src.models.user import User
|
||||
from apps.api.src.schemas import secret as schemas
|
||||
from apps.api.src.security.encryption import decrypt_secret, encrypt_secret
|
||||
|
||||
|
||||
async def create_secret(db: AsyncSession, data: schemas.SecretCreate, owner: User) -> Secret:
|
||||
secret = Secret(
|
||||
name=data.name,
|
||||
owner_id=owner.id,
|
||||
encrypted_value=encrypt_secret(data.value),
|
||||
secret_type=data.secret_type,
|
||||
scope=data.scope,
|
||||
metadata=data.metadata,
|
||||
)
|
||||
db.add(secret)
|
||||
await db.commit()
|
||||
await db.refresh(secret)
|
||||
return secret
|
||||
|
||||
|
||||
async def get_secret_by_id(db: AsyncSession, secret_id: uuid.UUID) -> Secret | None:
|
||||
result = await db.execute(select(Secret).where(Secret.id == secret_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_secrets(db: AsyncSession, owner: User) -> list[Secret]:
|
||||
result = await db.execute(select(Secret).where(Secret.owner_id == owner.id))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def update_secret(db: AsyncSession, secret_id: uuid.UUID, data: schemas.SecretUpdate, owner: User) -> Secret:
|
||||
secret = await get_secret_by_id(db, secret_id)
|
||||
if not secret or secret.owner_id != owner.id:
|
||||
raise HTTPException(status_code=404, detail="Secret not found")
|
||||
if data.name is not None:
|
||||
secret.name = data.name
|
||||
if data.value is not None:
|
||||
secret.encrypted_value = encrypt_secret(data.value)
|
||||
if data.metadata is not None:
|
||||
secret.metadata = data.metadata
|
||||
await db.commit()
|
||||
await db.refresh(secret)
|
||||
return secret
|
||||
|
||||
|
||||
async def delete_secret(db: AsyncSession, secret_id: uuid.UUID, owner: User) -> None:
|
||||
secret = await get_secret_by_id(db, secret_id)
|
||||
if not secret or secret.owner_id != owner.id:
|
||||
raise HTTPException(status_code=404, detail="Secret not found")
|
||||
await db.delete(secret)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def decrypt_secret_value(db: AsyncSession, secret_id: uuid.UUID) -> str:
|
||||
secret = await get_secret_by_id(db, secret_id)
|
||||
if not secret:
|
||||
raise HTTPException(status_code=404, detail="Secret not found")
|
||||
return decrypt_secret(secret.encrypted_value)
|
||||
@@ -0,0 +1,134 @@
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from apps.api.src.models.secret import Secret
|
||||
from apps.api.src.models.service_connection import ServiceConnection
|
||||
from apps.api.src.schemas import service_connection as schemas
|
||||
from apps.api.src.security import encryption
|
||||
from apps.api.src.security.ssrf import is_safe_url
|
||||
|
||||
|
||||
async def create_connection(
|
||||
db: AsyncSession,
|
||||
data: schemas.ServiceConnectionCreate,
|
||||
owner_id: uuid.UUID,
|
||||
) -> ServiceConnection:
|
||||
if not is_safe_url(data.base_url):
|
||||
raise HTTPException(status_code=400, detail="URL is not allowed")
|
||||
|
||||
secret_id = None
|
||||
if data.credentials:
|
||||
secret = Secret(
|
||||
name=f"{data.name} credentials",
|
||||
owner_id=owner_id,
|
||||
encrypted_value=encryption.encrypt_dict(data.credentials),
|
||||
secret_type="plugin_credentials",
|
||||
scope=f"plugin:{data.plugin_id}",
|
||||
)
|
||||
db.add(secret)
|
||||
await db.flush()
|
||||
secret_id = secret.id
|
||||
|
||||
conn = ServiceConnection(
|
||||
name=data.name,
|
||||
plugin_id=data.plugin_id,
|
||||
base_url=data.base_url,
|
||||
verify_tls=data.verify_tls,
|
||||
timeout_seconds=data.timeout_seconds,
|
||||
credentials_id=secret_id,
|
||||
is_enabled=data.is_enabled,
|
||||
extra_headers=data.extra_headers,
|
||||
)
|
||||
db.add(conn)
|
||||
await db.commit()
|
||||
await db.refresh(conn)
|
||||
return conn
|
||||
|
||||
|
||||
async def get_connection(db: AsyncSession, connection_id: uuid.UUID) -> ServiceConnection | None:
|
||||
result = await db.execute(select(ServiceConnection).where(ServiceConnection.id == connection_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_connections(db: AsyncSession, plugin_id: str | None = None) -> list[ServiceConnection]:
|
||||
stmt = select(ServiceConnection)
|
||||
if plugin_id:
|
||||
stmt = stmt.where(ServiceConnection.plugin_id == plugin_id)
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def update_connection(
|
||||
db: AsyncSession,
|
||||
connection_id: uuid.UUID,
|
||||
data: schemas.ServiceConnectionUpdate,
|
||||
) -> ServiceConnection:
|
||||
conn = await get_connection(db, connection_id)
|
||||
if not conn:
|
||||
raise HTTPException(status_code=404, detail="Connection not found")
|
||||
|
||||
if data.base_url is not None and not is_safe_url(data.base_url):
|
||||
raise HTTPException(status_code=400, detail="URL is not allowed")
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
credentials = update_data.pop("credentials", None)
|
||||
for key, value in update_data.items():
|
||||
setattr(conn, key, value)
|
||||
|
||||
if credentials is not None:
|
||||
if conn.credentials_id:
|
||||
secret = await db.get(Secret, conn.credentials_id)
|
||||
if secret:
|
||||
secret.encrypted_value = encryption.encrypt_dict(credentials)
|
||||
else:
|
||||
secret = Secret(
|
||||
name=f"{conn.name} credentials",
|
||||
encrypted_value=encryption.encrypt_dict(credentials),
|
||||
secret_type="plugin_credentials",
|
||||
scope=f"plugin:{conn.plugin_id}",
|
||||
)
|
||||
db.add(secret)
|
||||
await db.flush()
|
||||
conn.credentials_id = secret.id
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(conn)
|
||||
return conn
|
||||
|
||||
|
||||
async def delete_connection(db: AsyncSession, connection_id: uuid.UUID) -> None:
|
||||
conn = await get_connection(db, connection_id)
|
||||
if not conn:
|
||||
raise HTTPException(status_code=404, detail="Connection not found")
|
||||
if conn.credentials_id:
|
||||
secret = await db.get(Secret, conn.credentials_id)
|
||||
if secret:
|
||||
await db.delete(secret)
|
||||
await db.delete(conn)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def test_connection(data: schemas.ServiceConnectionTestRequest) -> dict:
|
||||
if not is_safe_url(data.base_url):
|
||||
raise HTTPException(status_code=400, detail="URL is not allowed")
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
verify=data.verify_tls,
|
||||
timeout=float(data.timeout_seconds),
|
||||
follow_redirects=False,
|
||||
) as client:
|
||||
response = await client.get(
|
||||
data.base_url,
|
||||
headers=data.extra_headers,
|
||||
)
|
||||
return {
|
||||
"success": 200 <= response.status_code < 400,
|
||||
"status_code": response.status_code,
|
||||
"message": "Connection test completed",
|
||||
}
|
||||
except httpx.HTTPError as e:
|
||||
return {"success": False, "status_code": None, "message": str(e)}
|
||||
@@ -0,0 +1,35 @@
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from apps.api.src.models.system_setting import SystemSetting
|
||||
from apps.api.src.schemas import system_setting as schemas
|
||||
|
||||
|
||||
async def get_setting(db: AsyncSession, key: str) -> SystemSetting | None:
|
||||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_setting_value(db: AsyncSession, key: str, default: dict | None = None) -> dict:
|
||||
setting = await get_setting(db, key)
|
||||
if setting:
|
||||
return setting.value
|
||||
return default or {}
|
||||
|
||||
|
||||
async def set_setting(db: AsyncSession, key: str, data: schemas.SystemSettingUpdate) -> SystemSetting:
|
||||
setting = await get_setting(db, key)
|
||||
if setting:
|
||||
setting.value = data.value
|
||||
else:
|
||||
setting = SystemSetting(key=key, value=data.value, description="", is_sensitive=False)
|
||||
db.add(setting)
|
||||
await db.commit()
|
||||
await db.refresh(setting)
|
||||
return setting
|
||||
|
||||
|
||||
async def list_settings(db: AsyncSession) -> list[SystemSetting]:
|
||||
result = await db.execute(select(SystemSetting))
|
||||
return list(result.scalars().all())
|
||||
@@ -0,0 +1,204 @@
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from apps.api.src.models.role import Role
|
||||
from apps.api.src.models.user import User
|
||||
from apps.api.src.schemas import user as schemas
|
||||
from apps.api.src.security import encryption, password
|
||||
from apps.api.src.security.permissions import ROLE_PERMISSIONS
|
||||
|
||||
|
||||
async def create_owner(db: AsyncSession, data: schemas.SetupRequest) -> User:
|
||||
existing = await db.execute(select(User))
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Setup already completed",
|
||||
)
|
||||
|
||||
# Create owner role if not exists
|
||||
role_result = await db.execute(select(Role).where(Role.name == "owner"))
|
||||
owner_role = role_result.scalar_one_or_none()
|
||||
if not owner_role:
|
||||
owner_role = Role(
|
||||
name="owner",
|
||||
description="System owner with full access",
|
||||
is_system=True,
|
||||
permissions=ROLE_PERMISSIONS["owner"],
|
||||
)
|
||||
db.add(owner_role)
|
||||
await db.flush()
|
||||
|
||||
user = User(
|
||||
email=data.email,
|
||||
hashed_password=password.hash_password(data.password),
|
||||
first_name=data.first_name,
|
||||
last_name=data.last_name,
|
||||
locale=data.locale,
|
||||
is_active=True,
|
||||
is_superuser=True,
|
||||
is_owner=True,
|
||||
email_verified=True,
|
||||
role_id=owner_role.id,
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
async def setup_required(db: AsyncSession) -> bool:
|
||||
result = await db.execute(select(User))
|
||||
return result.scalar_one_or_none() is None
|
||||
|
||||
|
||||
async def create_user(db: AsyncSession, data: schemas.UserCreate, creator: User) -> User:
|
||||
existing = await db.execute(select(User).where(User.email == data.email))
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="Email already registered")
|
||||
|
||||
user = User(
|
||||
email=data.email,
|
||||
hashed_password=password.hash_password(data.password),
|
||||
first_name=data.first_name,
|
||||
last_name=data.last_name,
|
||||
locale=data.locale,
|
||||
is_active=True,
|
||||
role_id=data.role_id,
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
async def get_user_by_email(db: AsyncSession, email: str) -> User | None:
|
||||
result = await db.execute(select(User).where(User.email == email))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_user_by_id(db: AsyncSession, user_id: uuid.UUID) -> User | None:
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_users(db: AsyncSession, skip: int = 0, limit: int = 100) -> list[User]:
|
||||
result = await db.execute(select(User).offset(skip).limit(limit))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def update_user(
|
||||
db: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
data: schemas.UserUpdate,
|
||||
current_user: User,
|
||||
) -> User:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if user.is_owner and not current_user.is_owner:
|
||||
raise HTTPException(status_code=403, detail="Cannot modify owner")
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(user, key, value)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
async def delete_user(db: AsyncSession, user_id: uuid.UUID, current_user: User) -> None:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if user.is_owner:
|
||||
raise HTTPException(status_code=403, detail="Cannot delete owner")
|
||||
if user.id == current_user.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete yourself")
|
||||
await db.delete(user)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def change_password(db: AsyncSession, user: User, current_password: str, new_password: str) -> None:
|
||||
if not password.verify_password(current_password, user.hashed_password):
|
||||
raise HTTPException(status_code=400, detail="Current password is incorrect")
|
||||
user.hashed_password = password.hash_password(new_password)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def create_invite(db: AsyncSession, email: str, role_id: uuid.UUID) -> str:
|
||||
user = await get_user_by_email(db, email)
|
||||
if user:
|
||||
raise HTTPException(status_code=400, detail="User already exists")
|
||||
|
||||
token = encryption.generate_random_token(32)
|
||||
expires = datetime.now(timezone.utc) + timedelta(hours=48) # type: ignore
|
||||
|
||||
user = User(
|
||||
email=email,
|
||||
hashed_password="",
|
||||
role_id=role_id,
|
||||
invite_token=encryption.hash_token(token),
|
||||
invite_token_expires=expires,
|
||||
is_active=False,
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
return token
|
||||
|
||||
|
||||
async def accept_invite(db: AsyncSession, token: str, new_password: str) -> User:
|
||||
hashed = encryption.hash_token(token)
|
||||
result = await db.execute(select(User).where(User.invite_token == hashed))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user or not user.invite_token_expires or user.invite_token_expires < datetime.now(timezone.utc):
|
||||
raise HTTPException(status_code=400, detail="Invalid or expired invite token")
|
||||
user.hashed_password = password.hash_password(new_password)
|
||||
user.is_active = True
|
||||
user.invite_token = None
|
||||
user.invite_token_expires = None
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
async def create_password_reset(db: AsyncSession, email: str) -> str:
|
||||
user = await get_user_by_email(db, email)
|
||||
if not user:
|
||||
return ""
|
||||
token = encryption.generate_random_token(32)
|
||||
user.password_reset_token = encryption.hash_token(token)
|
||||
user.password_reset_expires = datetime.now(timezone.utc) + timedelta(hours=24) # type: ignore
|
||||
await db.commit()
|
||||
return token
|
||||
|
||||
|
||||
async def reset_password(db: AsyncSession, token: str, new_password: str) -> None:
|
||||
hashed = encryption.hash_token(token)
|
||||
result = await db.execute(select(User).where(User.password_reset_token == hashed))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user or not user.password_reset_expires or user.password_reset_expires < datetime.now(timezone.utc):
|
||||
raise HTTPException(status_code=400, detail="Invalid or expired reset token")
|
||||
user.hashed_password = password.hash_password(new_password)
|
||||
user.password_reset_token = None
|
||||
user.password_reset_expires = None
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def update_last_login(db: AsyncSession, user: User, ip: str | None) -> None:
|
||||
user.last_login_at = datetime.now(timezone.utc)
|
||||
user.last_login_ip = ip
|
||||
user.login_attempts = 0
|
||||
user.locked_until = None
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def record_failed_login(db: AsyncSession, user: User) -> None:
|
||||
user.login_attempts += 1
|
||||
if user.login_attempts >= 5:
|
||||
user.locked_until = datetime.now(timezone.utc) + timedelta(minutes=15) # type: ignore
|
||||
await db.commit()
|
||||
@@ -0,0 +1,39 @@
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from apps.api.src.database import Base, get_db
|
||||
from apps.api.src.main import app
|
||||
|
||||
TEST_DATABASE_URL = "postgresql+asyncpg://test:test@localhost:5432/nexadash_test"
|
||||
|
||||
engine = create_async_engine(TEST_DATABASE_URL, echo=False, future=True)
|
||||
TestingSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="session", autouse=True)
|
||||
async def prepare_db():
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db():
|
||||
async with TestingSessionLocal() as session:
|
||||
yield session
|
||||
await session.rollback()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(db):
|
||||
async def override_get_db():
|
||||
yield db
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
yield TestClient(app)
|
||||
app.dependency_overrides.clear()
|
||||
@@ -0,0 +1,32 @@
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup_response(client):
|
||||
return client.post("/api/v1/auth/setup", json={
|
||||
"email": "owner@nexadash.local",
|
||||
"password": "StrongPassword123!",
|
||||
"first_name": "Owner",
|
||||
"last_name": "User",
|
||||
})
|
||||
|
||||
|
||||
def test_setup_status(client):
|
||||
r = client.get("/api/v1/auth/setup-status")
|
||||
assert r.status_code == 200
|
||||
assert "setup_required" in r.json()
|
||||
|
||||
|
||||
def test_setup(client, setup_response):
|
||||
assert setup_response.status_code in (200, 400)
|
||||
if setup_response.status_code == 200:
|
||||
assert "access_token" in setup_response.json()
|
||||
|
||||
|
||||
def test_login(client, setup_response):
|
||||
r = client.post("/api/v1/auth/login", json={
|
||||
"email": "owner@nexadash.local",
|
||||
"password": "StrongPassword123!",
|
||||
})
|
||||
assert r.status_code == 200
|
||||
assert "access_token" in r.json()
|
||||
@@ -0,0 +1,21 @@
|
||||
import pytest
|
||||
from apps.api.src.plugins import registry
|
||||
from apps.api.src.plugins.connectors.proxmox_ve import ProxmoxVEConnector
|
||||
from apps.api.src.plugins.connectors.adguard_home import AdGuardHomeConnector
|
||||
from apps.api.src.plugins.connectors.generic_http import GenericHTTPConnector
|
||||
|
||||
|
||||
def test_builtin_connectors_registered():
|
||||
assert "proxmox-ve" in registry.list_connectors()
|
||||
assert "adguard-home" in registry.list_connectors()
|
||||
assert "generic-http" in registry.list_connectors()
|
||||
|
||||
|
||||
def test_connector_classes():
|
||||
assert registry.get_connector("proxmox-ve") is ProxmoxVEConnector
|
||||
assert registry.get_connector("adguard-home") is AdGuardHomeConnector
|
||||
assert registry.get_connector("generic-http") is GenericHTTPConnector
|
||||
|
||||
|
||||
def test_registry_missing():
|
||||
assert registry.get_connector("unknown") is None
|
||||
@@ -0,0 +1,29 @@
|
||||
from apps.api.src.security.password import hash_password, verify_password
|
||||
from apps.api.src.security.encryption import encrypt_value, decrypt_value, mask_secret
|
||||
from apps.api.src.security.ssrf import is_safe_url
|
||||
|
||||
|
||||
def test_password_hash():
|
||||
pw = "super-secret-password"
|
||||
hashed = hash_password(pw)
|
||||
assert verify_password(pw, hashed)
|
||||
assert not verify_password("wrong", hashed)
|
||||
|
||||
|
||||
def test_encryption():
|
||||
value = "api-token-secret"
|
||||
encrypted = encrypt_value(value)
|
||||
decrypted = decrypt_value(encrypted)
|
||||
assert decrypted == value
|
||||
|
||||
|
||||
def test_mask_secret():
|
||||
assert mask_secret("1234567890", 4) == "******7890"
|
||||
|
||||
|
||||
def test_ssrf_protection():
|
||||
assert not is_safe_url("http://localhost:8000")
|
||||
assert not is_safe_url("http://127.0.0.1/admin")
|
||||
assert not is_safe_url("http://169.254.169.254")
|
||||
assert not is_safe_url("ftp://example.com")
|
||||
assert is_safe_url("https://example.com/api")
|
||||
Reference in New Issue
Block a user