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,78 @@
|
||||
# ============================================================
|
||||
# NexaDash Environment Configuration
|
||||
# ============================================================
|
||||
|
||||
# -----------------------------------------------------------
|
||||
# General
|
||||
# -----------------------------------------------------------
|
||||
NODE_ENV=production
|
||||
NEXADASH_API_URL=http://localhost:8000
|
||||
NEXADASH_WEB_URL=http://localhost:3000
|
||||
NEXADASH_SECRET_KEY=change-this-to-a-random-32-byte-secret-key!!!!
|
||||
NEXADASH_ENCRYPTION_KEY=change-this-to-a-random-32-byte-fernet-key!!
|
||||
|
||||
# -----------------------------------------------------------
|
||||
# Database
|
||||
# -----------------------------------------------------------
|
||||
POSTGRES_HOST=postgres
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_USER=nexadash
|
||||
POSTGRES_PASSWORD=change-this-strong-password
|
||||
POSTGRES_DB=nexadash
|
||||
DATABASE_URL=postgresql+asyncpg://nexadash:change-this-strong-password@postgres:5432/nexadash
|
||||
|
||||
# -----------------------------------------------------------
|
||||
# Redis
|
||||
# -----------------------------------------------------------
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=change-this-redis-password
|
||||
REDIS_URL=redis://:change-this-redis-password@redis:6379/0
|
||||
CELERY_BROKER_URL=redis://:change-this-redis-password@redis:6379/1
|
||||
CELERY_RESULT_BACKEND=redis://:change-this-redis-password@redis:6379/2
|
||||
|
||||
# -----------------------------------------------------------
|
||||
# Security
|
||||
# -----------------------------------------------------------
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=15
|
||||
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||
ARGON2_TIME_COST=2
|
||||
ARGON2_MEMORY_COST=65536
|
||||
ARGON2_PARALLELISM=1
|
||||
MAX_LOGIN_ATTEMPTS=5
|
||||
LOGIN_LOCKOUT_MINUTES=15
|
||||
RATE_LIMIT_DEFAULT=100
|
||||
RATE_LIMIT_LOGIN=10
|
||||
|
||||
# -----------------------------------------------------------
|
||||
# Mail (SMTP)
|
||||
# -----------------------------------------------------------
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_TLS=true
|
||||
SMTP_STARTTLS=true
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM=nexadash@localhost
|
||||
|
||||
# -----------------------------------------------------------
|
||||
# Worker
|
||||
# -----------------------------------------------------------
|
||||
WORKER_CONCURRENCY=4
|
||||
|
||||
# -----------------------------------------------------------
|
||||
# Plugins / Sandboxing
|
||||
# -----------------------------------------------------------
|
||||
PLUGIN_DIR=/app/plugins
|
||||
PLUGIN_SANDBOX_ENABLED=true
|
||||
|
||||
# -----------------------------------------------------------
|
||||
# Optional: Sentry / Monitoring
|
||||
# -----------------------------------------------------------
|
||||
SENTRY_DSN=
|
||||
|
||||
# -----------------------------------------------------------
|
||||
# Ports used by docker-compose
|
||||
# -----------------------------------------------------------
|
||||
API_PORT=8000
|
||||
WEB_PORT=3000
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
env/
|
||||
venv/
|
||||
.venv/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Next.js
|
||||
.next/
|
||||
out/
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Docker volumes
|
||||
postgres_data/
|
||||
redis_data/
|
||||
plugin_data/
|
||||
|
||||
# Test coverage
|
||||
.coverage/
|
||||
htmlcov/
|
||||
.pytest_cache/
|
||||
|
||||
# Alembic
|
||||
alembic.ini.bak
|
||||
|
||||
# Misc
|
||||
*.zip
|
||||
*.tar.gz
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 NexaDash Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,93 +1,172 @@
|
||||
# NexaDash
|
||||
|
||||
NexaDash is a modern, self-hosted, plugin-based dashboard for Homelab, server and infrastructure services. It connects to Proxmox, AdGuard Home, Jellyfin, Home Assistant, authentik, Beszel and many more through a secure, extensible plugin system — all configurable from the web UI, no CLI required.
|
||||
|
||||
## Features
|
||||
|
||||
## Getting started
|
||||
- **Plugin-based architecture**: Add services as plugins via the UI or ZIP upload.
|
||||
- **Modern UI**: Next.js, Tailwind CSS, shadcn/ui, dark mode first, glassmorphism design.
|
||||
- **Secure by default**: Argon2id, JWT sessions, RBAC, encrypted secrets, audit log, SSRF protection, security headers.
|
||||
- **Dashboard editor**: Drag & drop, resize, multiple breakpoints, favorites, folders, import/export.
|
||||
- **Multi-user**: Owner, Admin, Editor, Viewer roles with permission system.
|
||||
- **Background workers**: Celery for plugin syncs and scheduled jobs.
|
||||
- **Live updates**: WebSocket/SSE support and auto-refresh.
|
||||
- **Mail settings**: SMTP configuration via UI for invitations and password resets.
|
||||
- **Backup/restore**: Database dumps and restore documentation included.
|
||||
|
||||
To make it easy for you to get started with GitLab, here's a list of recommended next steps.
|
||||
## Quick Start
|
||||
|
||||
Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)!
|
||||
Requires Docker and Docker Compose.
|
||||
|
||||
## Add your files
|
||||
```bash
|
||||
# 1. Clone the repository
|
||||
git clone https://github.com/nexadash/nexadash.git
|
||||
cd nexadash
|
||||
|
||||
* [Create](https://docs.gitlab.com/user/project/repository/web_editor/#create-a-file) or [upload](https://docs.gitlab.com/user/project/repository/web_editor/#upload-a-file) files
|
||||
* [Add files using the command line](https://docs.gitlab.com/topics/git/add_files/#add-files-to-a-git-repository) or push an existing Git repository with the following command:
|
||||
# 2. Configure environment
|
||||
cp .env.example .env
|
||||
# Edit .env and set strong passwords
|
||||
|
||||
```
|
||||
cd existing_repo
|
||||
git remote add origin http://gitlab.nesterovic.cc/nessi/nexadash.git
|
||||
git branch -M main
|
||||
git push -uf origin main
|
||||
# 3. Start the stack
|
||||
docker compose up -d
|
||||
|
||||
# 4. Open the UI
|
||||
open http://localhost:3000
|
||||
```
|
||||
|
||||
## Integrate with your tools
|
||||
The initial setup wizard appears on first launch. Create the owner account, then add services from the plugin registry.
|
||||
|
||||
* [Set up project integrations](http://gitlab.nesterovic.cc/nessi/nexadash/-/settings/integrations)
|
||||
## Architecture
|
||||
|
||||
## Collaborate with your team
|
||||
```text
|
||||
nexadash/
|
||||
apps/
|
||||
web/ Next.js frontend
|
||||
api/ FastAPI backend
|
||||
worker/ Celery background worker
|
||||
packages/
|
||||
plugin-sdk/ TypeScript SDK for plugin developers
|
||||
shared/ Shared types and constants
|
||||
ui/ shadcn/ui component library
|
||||
plugins/ Built-in plugin manifests
|
||||
docs/ Documentation
|
||||
docker/ Dockerfiles
|
||||
compose.yaml
|
||||
.env.example
|
||||
```
|
||||
|
||||
* [Invite team members and collaborators](https://docs.gitlab.com/user/project/members/)
|
||||
* [Create a new merge request](https://docs.gitlab.com/user/project/merge_requests/creating_merge_requests/)
|
||||
* [Automatically close issues from merge requests](https://docs.gitlab.com/user/project/issues/managing_issues/#closing-issues-automatically)
|
||||
* [Enable merge request approvals](https://docs.gitlab.com/user/project/merge_requests/approvals/)
|
||||
* [Set auto-merge](https://docs.gitlab.com/user/project/merge_requests/auto_merge/)
|
||||
## Plugin System
|
||||
|
||||
## Test and Deploy
|
||||
NexaDash plugins are declarative packages consisting of:
|
||||
|
||||
Use the built-in continuous integration in GitLab.
|
||||
- `plugin.manifest.json` — metadata, schemas, widgets, permissions.
|
||||
- Backend connector — either a built-in connector or a registered Python class.
|
||||
- Optional frontend widget components.
|
||||
|
||||
* [Get started with GitLab CI/CD](https://docs.gitlab.com/ci/quick_start/)
|
||||
* [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/user/application_security/sast/)
|
||||
* [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/topics/autodevops/requirements/)
|
||||
* [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/user/clusters/agent/)
|
||||
* [Set up protected environments](https://docs.gitlab.com/ci/environments/protected_environments/)
|
||||
Plugins are installed, activated, configured and updated entirely from the web UI. Built-in plugins include:
|
||||
|
||||
***
|
||||
- Proxmox VE
|
||||
- Proxmox Backup Server
|
||||
- AdGuard Home
|
||||
- Zoraxy
|
||||
- Jellyfin
|
||||
- Home Assistant
|
||||
- authentik
|
||||
- Beszel
|
||||
- Generic HTTP
|
||||
|
||||
# Editing this README
|
||||
See `docs/plugins/README.md` for the full developer guide.
|
||||
|
||||
When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template.
|
||||
## Security
|
||||
|
||||
## Suggestions for a good README
|
||||
- Passwords hashed with Argon2id.
|
||||
- JWT access + refresh tokens in HTTP-only cookies.
|
||||
- Service credentials encrypted at rest with Fernet.
|
||||
- RBAC with Owner/Admin/Editor/Viewer.
|
||||
- Audit log for all critical actions.
|
||||
- SSRF protection for outbound service URLs.
|
||||
- Rate limiting and account lockout.
|
||||
- Security headers on all API responses.
|
||||
- No secrets exposed to the frontend.
|
||||
|
||||
Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
|
||||
See `docs/security/README.md` for details.
|
||||
|
||||
## Name
|
||||
Choose a self-explaining name for your project.
|
||||
## Backup and Restore
|
||||
|
||||
## Description
|
||||
Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
|
||||
```bash
|
||||
# Backup
|
||||
docker compose exec postgres pg_dump -U nexadash nexadash > nexadash-backup.sql
|
||||
|
||||
## Badges
|
||||
On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
|
||||
# Restore
|
||||
docker compose exec -T postgres psql -U nexadash nexadash < nexadash-backup.sql
|
||||
```
|
||||
|
||||
## Visuals
|
||||
Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
|
||||
Important volumes: `postgres_data`, `redis_data`, `plugin_data`.
|
||||
|
||||
## Installation
|
||||
Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
|
||||
## Update
|
||||
|
||||
## Usage
|
||||
Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
|
||||
```bash
|
||||
git pull
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
docker compose exec api alembic upgrade head
|
||||
```
|
||||
|
||||
## Support
|
||||
Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
|
||||
## Developer Setup
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
# Python 3.12+
|
||||
python -m venv .venv
|
||||
. .venv/bin/activate
|
||||
pip install poetry
|
||||
poetry install
|
||||
|
||||
# Run migrations
|
||||
alembic upgrade head
|
||||
|
||||
# Start API
|
||||
python -m uvicorn apps.api.src.main:app --reload
|
||||
|
||||
# Start worker (separate terminal)
|
||||
celery -A apps.worker.src.celery_app worker -l info
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
# Node 20+, pnpm 9+
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
pytest
|
||||
|
||||
# Frontend
|
||||
pnpm test
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
If you have ideas for releases in the future, it is a good idea to list them in the README.
|
||||
|
||||
- Public plugin marketplace.
|
||||
- Additional widget types (charts, maps, logs).
|
||||
- Webhook-triggered plugin actions.
|
||||
- OIDC/SAML authentication.
|
||||
- Mobile app.
|
||||
|
||||
## Contributing
|
||||
State if you are open to contributions and what your requirements are for accepting them.
|
||||
|
||||
For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
|
||||
|
||||
You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
|
||||
|
||||
## Authors and acknowledgment
|
||||
Show your appreciation to those who have contributed to the project.
|
||||
Contributions are welcome. Please follow the existing code style, add tests for new features and update the documentation.
|
||||
|
||||
## License
|
||||
For open source projects, say how it is licensed.
|
||||
|
||||
## Project status
|
||||
If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.
|
||||
MIT License — see `LICENSE`.
|
||||
|
||||
## Support
|
||||
|
||||
For questions and issues, open a GitHub issue or discussion.
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
[alembic]
|
||||
script_location = apps/api/alembic
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
sqlalchemy.url = %(DATABASE_URL)s
|
||||
|
||||
[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,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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user