flowmine-studio 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flowmine_studio/__init__.py +0 -0
- flowmine_studio/api/__init__.py +0 -0
- flowmine_studio/api/audit.py +27 -0
- flowmine_studio/api/auth/__init__.py +0 -0
- flowmine_studio/api/auth/providers.py +36 -0
- flowmine_studio/api/cli.py +71 -0
- flowmine_studio/api/config.py +70 -0
- flowmine_studio/api/db.py +57 -0
- flowmine_studio/api/dbtypes.py +36 -0
- flowmine_studio/api/deps.py +65 -0
- flowmine_studio/api/errors.py +53 -0
- flowmine_studio/api/logging_setup.py +25 -0
- flowmine_studio/api/main.py +134 -0
- flowmine_studio/api/models.py +244 -0
- flowmine_studio/api/routers/__init__.py +0 -0
- flowmine_studio/api/routers/admin_users.py +96 -0
- flowmine_studio/api/routers/artifacts.py +31 -0
- flowmine_studio/api/routers/auth.py +71 -0
- flowmine_studio/api/routers/connections.py +191 -0
- flowmine_studio/api/routers/datasets.py +204 -0
- flowmine_studio/api/routers/health.py +26 -0
- flowmine_studio/api/routers/jobs.py +66 -0
- flowmine_studio/api/routers/projects.py +265 -0
- flowmine_studio/api/routers/workspaces.py +31 -0
- flowmine_studio/api/runner.py +53 -0
- flowmine_studio/api/security.py +76 -0
- flowmine_studio/api/static/assets/antd-Bd57aUix.js +460 -0
- flowmine_studio/api/static/assets/graph-BloDhLCu.js +356 -0
- flowmine_studio/api/static/assets/index-C1TGpmAW.js +39 -0
- flowmine_studio/api/static/assets/plotly-DZtPyeg0.js +4030 -0
- flowmine_studio/api/static/index.html +14 -0
- flowmine_studio/scripts/seed.py +163 -0
- flowmine_studio/shared/__init__.py +0 -0
- flowmine_studio/shared/enums.py +44 -0
- flowmine_studio/shared/graph_filter.py +67 -0
- flowmine_studio/shared/labels.py +33 -0
- flowmine_studio/shared/schemas.py +180 -0
- flowmine_studio/worker/__init__.py +0 -0
- flowmine_studio/worker/connectors/__init__.py +27 -0
- flowmine_studio/worker/connectors/base.py +30 -0
- flowmine_studio/worker/connectors/files.py +65 -0
- flowmine_studio/worker/connectors/sql_base.py +138 -0
- flowmine_studio/worker/connectors/sql_dialects.py +106 -0
- flowmine_studio/worker/connectors/xes.py +93 -0
- flowmine_studio/worker/engine/__init__.py +1 -0
- flowmine_studio/worker/engine/adapter.py +393 -0
- flowmine_studio/worker/jobs.py +61 -0
- flowmine_studio/worker/storage.py +81 -0
- flowmine_studio/worker/tasks/__init__.py +0 -0
- flowmine_studio/worker/tasks/analysis.py +140 -0
- flowmine_studio/worker/tasks/ingest.py +129 -0
- flowmine_studio-0.1.0.dist-info/METADATA +116 -0
- flowmine_studio-0.1.0.dist-info/RECORD +57 -0
- flowmine_studio-0.1.0.dist-info/WHEEL +5 -0
- flowmine_studio-0.1.0.dist-info/entry_points.txt +3 -0
- flowmine_studio-0.1.0.dist-info/licenses/LICENSE +661 -0
- flowmine_studio-0.1.0.dist-info/top_level.txt +1 -0
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
from uuid import UUID
|
|
3
|
+
|
|
4
|
+
from sqlalchemy.orm import Session
|
|
5
|
+
|
|
6
|
+
from flowmine_studio.api.models import AuditLog
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def audit(
|
|
10
|
+
db: Session,
|
|
11
|
+
action: str,
|
|
12
|
+
user_id: UUID | None = None,
|
|
13
|
+
workspace_id: UUID | None = None,
|
|
14
|
+
object_type: str | None = None,
|
|
15
|
+
object_id: UUID | None = None,
|
|
16
|
+
detail: dict[str, Any] | None = None,
|
|
17
|
+
) -> None:
|
|
18
|
+
db.add(
|
|
19
|
+
AuditLog(
|
|
20
|
+
user_id=user_id,
|
|
21
|
+
workspace_id=workspace_id,
|
|
22
|
+
action=action,
|
|
23
|
+
object_type=object_type,
|
|
24
|
+
object_id=object_id,
|
|
25
|
+
detail=detail,
|
|
26
|
+
)
|
|
27
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Auth providers. CE ships local username/password auth with workspace RBAC."""
|
|
2
|
+
from typing import Protocol
|
|
3
|
+
|
|
4
|
+
from sqlalchemy import select
|
|
5
|
+
from sqlalchemy.orm import Session
|
|
6
|
+
|
|
7
|
+
from flowmine_studio.api.errors import UnauthorizedError
|
|
8
|
+
from flowmine_studio.api.models import User
|
|
9
|
+
from flowmine_studio.api.security import verify_password
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AuthProvider(Protocol):
|
|
13
|
+
name: str
|
|
14
|
+
|
|
15
|
+
def authenticate(self, db: Session, username: str, password: str) -> User: ...
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class LocalProvider:
|
|
19
|
+
name = "local"
|
|
20
|
+
|
|
21
|
+
def authenticate(self, db: Session, username: str, password: str) -> User:
|
|
22
|
+
user = db.scalar(select(User).where(User.username == username))
|
|
23
|
+
if user is None or user.password_hash is None:
|
|
24
|
+
raise UnauthorizedError("Invalid username or password")
|
|
25
|
+
if not verify_password(password, user.password_hash):
|
|
26
|
+
raise UnauthorizedError("Invalid username or password")
|
|
27
|
+
if not user.is_active:
|
|
28
|
+
raise UnauthorizedError("Account is disabled")
|
|
29
|
+
return user
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_PROVIDERS: dict[str, AuthProvider] = {"local": LocalProvider()}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_provider() -> AuthProvider:
|
|
36
|
+
return _PROVIDERS["local"]
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""The `flowmine-studio` command: zero-config launch on http://localhost:8080.
|
|
2
|
+
|
|
3
|
+
First run: creates the SQLite database and artifact store in the user data
|
|
4
|
+
directory, seeds the admin account (admin/admin) and the demo dataset, runs the
|
|
5
|
+
demo analysis, then starts the server and opens the browser.
|
|
6
|
+
"""
|
|
7
|
+
import argparse
|
|
8
|
+
import threading
|
|
9
|
+
import webbrowser
|
|
10
|
+
|
|
11
|
+
from flowmine_studio.api.config import data_dir, get_settings
|
|
12
|
+
from flowmine_studio.api.logging_setup import get_logger, setup_logging
|
|
13
|
+
|
|
14
|
+
log = get_logger("cli")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _first_run_setup() -> None:
|
|
18
|
+
from flowmine_studio.api.db import init_db
|
|
19
|
+
|
|
20
|
+
init_db()
|
|
21
|
+
from flowmine_studio.scripts.seed import main as seed_main
|
|
22
|
+
|
|
23
|
+
seed_main()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _open_browser(url: str) -> None:
|
|
27
|
+
try:
|
|
28
|
+
webbrowser.open(url)
|
|
29
|
+
except Exception: # noqa: BLE001 — headless environments have no browser
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def main() -> None:
|
|
34
|
+
parser = argparse.ArgumentParser(
|
|
35
|
+
prog="flowmine-studio",
|
|
36
|
+
description="FlowMine Studio — process mining. Starts the app on localhost.",
|
|
37
|
+
)
|
|
38
|
+
parser.add_argument("--host", default=None, help="bind host (default 127.0.0.1)")
|
|
39
|
+
parser.add_argument("--port", type=int, default=None, help="port (default 8080)")
|
|
40
|
+
parser.add_argument("--no-browser", action="store_true", help="do not open the browser")
|
|
41
|
+
parser.add_argument("--no-seed", action="store_true", help="skip demo data seeding")
|
|
42
|
+
args = parser.parse_args()
|
|
43
|
+
|
|
44
|
+
setup_logging()
|
|
45
|
+
settings = get_settings()
|
|
46
|
+
host = args.host or settings.host
|
|
47
|
+
port = args.port or settings.port
|
|
48
|
+
|
|
49
|
+
log.info("data_dir", path=str(data_dir()))
|
|
50
|
+
if args.no_seed:
|
|
51
|
+
from flowmine_studio.api.db import init_db
|
|
52
|
+
|
|
53
|
+
init_db()
|
|
54
|
+
else:
|
|
55
|
+
_first_run_setup()
|
|
56
|
+
|
|
57
|
+
url = f"http://{'localhost' if host in ('0.0.0.0', '127.0.0.1') else host}:{port}" # noqa: S104
|
|
58
|
+
print() # noqa: T201 — deliberate console UX
|
|
59
|
+
print(f" FlowMine Studio → {url} (admin / admin)") # noqa: T201
|
|
60
|
+
print(f" Данные: {data_dir()}") # noqa: T201
|
|
61
|
+
print() # noqa: T201
|
|
62
|
+
if not args.no_browser:
|
|
63
|
+
threading.Timer(1.5, _open_browser, args=(url,)).start()
|
|
64
|
+
|
|
65
|
+
import uvicorn
|
|
66
|
+
|
|
67
|
+
uvicorn.run("flowmine_studio.api.main:app", host=host, port=port, log_level="warning")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
if __name__ == "__main__":
|
|
71
|
+
main()
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Settings. Zero-config by default: everything lives under the user data directory
|
|
2
|
+
(~/.flowmine-studio or the OS equivalent). Every knob can be overridden with a
|
|
3
|
+
STUDIO_* environment variable."""
|
|
4
|
+
import secrets
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from platformdirs import user_data_dir
|
|
9
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def data_dir() -> Path:
|
|
13
|
+
d = Path(user_data_dir("flowmine-studio", appauthor=False))
|
|
14
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
15
|
+
return d
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _default_secret_key() -> str:
|
|
19
|
+
"""Persist a generated key on first run so encrypted secrets survive restarts."""
|
|
20
|
+
key_file = data_dir() / "secret_key"
|
|
21
|
+
if key_file.exists():
|
|
22
|
+
return key_file.read_text().strip()
|
|
23
|
+
key = secrets.token_urlsafe(48)
|
|
24
|
+
key_file.write_text(key)
|
|
25
|
+
try:
|
|
26
|
+
key_file.chmod(0o600)
|
|
27
|
+
except OSError: # Windows
|
|
28
|
+
pass
|
|
29
|
+
return key
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Settings(BaseSettings):
|
|
33
|
+
model_config = SettingsConfigDict(env_prefix="STUDIO_")
|
|
34
|
+
|
|
35
|
+
database_url: str = "" # default resolved to sqlite in data_dir (see resolved_database_url)
|
|
36
|
+
secret_key: str = "" # default resolved from persisted key file
|
|
37
|
+
|
|
38
|
+
access_token_ttl_seconds: int = 900
|
|
39
|
+
refresh_token_ttl_days: int = 7
|
|
40
|
+
|
|
41
|
+
artifact_local_path: str = "" # default resolved to data_dir/artifacts
|
|
42
|
+
|
|
43
|
+
host: str = "127.0.0.1"
|
|
44
|
+
port: int = 8080
|
|
45
|
+
upload_max_mb: int = 200
|
|
46
|
+
cors_origins: list[str] = []
|
|
47
|
+
|
|
48
|
+
job_workers: int = 2
|
|
49
|
+
jobs_sync: bool = False # inline job execution (tests, first-run seed)
|
|
50
|
+
|
|
51
|
+
environment: str = "dev"
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def resolved_database_url(self) -> str:
|
|
55
|
+
if self.database_url:
|
|
56
|
+
return self.database_url
|
|
57
|
+
return f"sqlite:///{data_dir() / 'studio.db'}"
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def resolved_secret_key(self) -> str:
|
|
61
|
+
return self.secret_key or _default_secret_key()
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def resolved_artifact_path(self) -> str:
|
|
65
|
+
return self.artifact_local_path or str(data_dir() / "artifacts")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@lru_cache
|
|
69
|
+
def get_settings() -> Settings:
|
|
70
|
+
return Settings()
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from fastapi import Request
|
|
2
|
+
from sqlalchemy import create_engine
|
|
3
|
+
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
|
4
|
+
|
|
5
|
+
from flowmine_studio.api.config import get_settings
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Base(DeclarativeBase):
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _engine(): # type: ignore[no-untyped-def]
|
|
13
|
+
url = get_settings().resolved_database_url
|
|
14
|
+
if url.startswith("sqlite"):
|
|
15
|
+
engine = create_engine(url, connect_args={"check_same_thread": False})
|
|
16
|
+
from sqlalchemy import event
|
|
17
|
+
|
|
18
|
+
@event.listens_for(engine, "connect")
|
|
19
|
+
def _sqlite_pragmas(dbapi_conn, _record): # type: ignore[no-untyped-def]
|
|
20
|
+
cursor = dbapi_conn.cursor()
|
|
21
|
+
cursor.execute("PRAGMA foreign_keys=ON")
|
|
22
|
+
cursor.execute("PRAGMA journal_mode=WAL")
|
|
23
|
+
cursor.close()
|
|
24
|
+
|
|
25
|
+
return engine
|
|
26
|
+
return create_engine(url, pool_pre_ping=True)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_engine_instance = None
|
|
30
|
+
_session_factory: sessionmaker[Session] | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_session_factory() -> sessionmaker[Session]:
|
|
34
|
+
global _engine_instance, _session_factory
|
|
35
|
+
if _session_factory is None:
|
|
36
|
+
_engine_instance = _engine()
|
|
37
|
+
_session_factory = sessionmaker(bind=_engine_instance, expire_on_commit=False)
|
|
38
|
+
return _session_factory
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_db(request: Request) -> Session:
|
|
42
|
+
"""Request-scoped session. Committed/rolled back and closed by the db middleware
|
|
43
|
+
in api.main BEFORE the response is sent — never in dependency teardown, which
|
|
44
|
+
FastAPI runs after the response and which therefore races follow-up requests."""
|
|
45
|
+
db = getattr(request.state, "db", None)
|
|
46
|
+
if db is None:
|
|
47
|
+
db = get_session_factory()()
|
|
48
|
+
request.state.db = db
|
|
49
|
+
return db # type: ignore[no-any-return]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def init_db() -> None:
|
|
53
|
+
"""Create all tables (idempotent). CE bootstraps the schema at startup."""
|
|
54
|
+
from flowmine_studio.api import models # noqa: F401 — register tables
|
|
55
|
+
|
|
56
|
+
factory = get_session_factory()
|
|
57
|
+
Base.metadata.create_all(factory.kw["bind"])
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Dialect-agnostic column types: identical behavior on SQLite and PostgreSQL."""
|
|
2
|
+
from datetime import UTC, datetime
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import sqlalchemy as sa
|
|
6
|
+
from sqlalchemy.types import TypeDecorator
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TZDateTime(TypeDecorator):
|
|
10
|
+
"""Stores UTC-naive, returns UTC-aware — safe on SQLite, correct on Postgres."""
|
|
11
|
+
|
|
12
|
+
impl = sa.DateTime
|
|
13
|
+
cache_ok = True
|
|
14
|
+
|
|
15
|
+
def process_bind_param(self, value: datetime | None, dialect: Any) -> datetime | None:
|
|
16
|
+
if value is None:
|
|
17
|
+
return None
|
|
18
|
+
if value.tzinfo is not None:
|
|
19
|
+
value = value.astimezone(UTC).replace(tzinfo=None)
|
|
20
|
+
return value
|
|
21
|
+
|
|
22
|
+
def process_result_value(self, value: datetime | None, dialect: Any) -> datetime | None:
|
|
23
|
+
if value is None:
|
|
24
|
+
return None
|
|
25
|
+
return value.replace(tzinfo=UTC)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def StrEnumType(py_enum: type, name: str) -> sa.Enum: # noqa: N802 — type factory
|
|
29
|
+
"""Enum stored as VARCHAR + CHECK constraint: portable across SQLite/Postgres."""
|
|
30
|
+
return sa.Enum(
|
|
31
|
+
py_enum,
|
|
32
|
+
name=name,
|
|
33
|
+
native_enum=False,
|
|
34
|
+
length=32,
|
|
35
|
+
values_callable=lambda e: [m.value for m in e],
|
|
36
|
+
)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""FastAPI dependencies: current user, workspace resolution, RBAC guards."""
|
|
2
|
+
from collections.abc import Callable
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
from fastapi import Depends, Header, Request
|
|
7
|
+
from sqlalchemy import select
|
|
8
|
+
from sqlalchemy.orm import Session
|
|
9
|
+
|
|
10
|
+
from flowmine_studio.api.db import get_db
|
|
11
|
+
from flowmine_studio.api.errors import ForbiddenError, UnauthorizedError
|
|
12
|
+
from flowmine_studio.api.models import Membership, User, Workspace
|
|
13
|
+
from flowmine_studio.api.security import decode_access_token
|
|
14
|
+
from flowmine_studio.shared.enums import ROLE_ORDER, Role
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_current_user(request: Request, db: Session = Depends(get_db)) -> User:
|
|
18
|
+
auth = request.headers.get("Authorization", "")
|
|
19
|
+
if not auth.startswith("Bearer "):
|
|
20
|
+
raise UnauthorizedError("Missing bearer token")
|
|
21
|
+
user_id = decode_access_token(auth.removeprefix("Bearer ").strip())
|
|
22
|
+
user = db.get(User, user_id)
|
|
23
|
+
if user is None or not user.is_active:
|
|
24
|
+
raise UnauthorizedError("Unknown or disabled user")
|
|
25
|
+
return user
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class WorkspaceContext:
|
|
30
|
+
user: User
|
|
31
|
+
workspace: Workspace
|
|
32
|
+
role: Role
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_workspace_context(
|
|
36
|
+
x_workspace: str = Header(..., description="Workspace UUID"),
|
|
37
|
+
user: User = Depends(get_current_user),
|
|
38
|
+
db: Session = Depends(get_db),
|
|
39
|
+
) -> WorkspaceContext:
|
|
40
|
+
try:
|
|
41
|
+
ws_id = UUID(x_workspace)
|
|
42
|
+
except ValueError as exc:
|
|
43
|
+
raise ForbiddenError("X-Workspace must be a UUID") from exc
|
|
44
|
+
ws = db.get(Workspace, ws_id)
|
|
45
|
+
if ws is None:
|
|
46
|
+
raise ForbiddenError("Workspace not found or no access")
|
|
47
|
+
if user.is_superuser:
|
|
48
|
+
return WorkspaceContext(user=user, workspace=ws, role=Role.admin)
|
|
49
|
+
membership = db.scalar(
|
|
50
|
+
select(Membership).where(
|
|
51
|
+
Membership.user_id == user.id, Membership.workspace_id == ws_id
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
if membership is None:
|
|
55
|
+
raise ForbiddenError("Workspace not found or no access")
|
|
56
|
+
return WorkspaceContext(user=user, workspace=ws, role=membership.role)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def require_role(minimum: Role) -> Callable[..., WorkspaceContext]:
|
|
60
|
+
def guard(ctx: WorkspaceContext = Depends(get_workspace_context)) -> WorkspaceContext:
|
|
61
|
+
if ROLE_ORDER[ctx.role] < ROLE_ORDER[minimum]:
|
|
62
|
+
raise ForbiddenError(f"Requires role '{minimum.value}' or higher")
|
|
63
|
+
return ctx
|
|
64
|
+
|
|
65
|
+
return guard
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Typed error hierarchy mapped to the openapi error envelope by api.main handlers."""
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class StudioError(Exception):
|
|
6
|
+
code = "internal_error"
|
|
7
|
+
status = 500
|
|
8
|
+
|
|
9
|
+
def __init__(self, message: str, detail: Any = None):
|
|
10
|
+
super().__init__(message)
|
|
11
|
+
self.message = message
|
|
12
|
+
self.detail = detail
|
|
13
|
+
|
|
14
|
+
def envelope(self) -> dict[str, Any]:
|
|
15
|
+
body: dict[str, Any] = {"code": self.code, "message": self.message}
|
|
16
|
+
if self.detail is not None:
|
|
17
|
+
body["detail"] = self.detail
|
|
18
|
+
return {"error": body}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class NotFoundError(StudioError):
|
|
22
|
+
code = "not_found"
|
|
23
|
+
status = 404
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ForbiddenError(StudioError):
|
|
27
|
+
code = "forbidden"
|
|
28
|
+
status = 403
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class UnauthorizedError(StudioError):
|
|
32
|
+
code = "unauthorized"
|
|
33
|
+
status = 401
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ValidationFailed(StudioError):
|
|
37
|
+
code = "validation_error"
|
|
38
|
+
status = 422
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ConflictError(StudioError):
|
|
42
|
+
code = "conflict"
|
|
43
|
+
status = 409
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class NotImplementedFeature(StudioError):
|
|
47
|
+
code = "not_implemented"
|
|
48
|
+
status = 501
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ConnectorFailure(StudioError):
|
|
52
|
+
code = "connector_error"
|
|
53
|
+
status = 502
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""structlog JSON logging for api and worker processes."""
|
|
2
|
+
import logging
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import structlog
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def setup_logging(level: int = logging.INFO) -> None:
|
|
9
|
+
logging.basicConfig(stream=sys.stdout, level=level, format="%(message)s")
|
|
10
|
+
structlog.configure(
|
|
11
|
+
processors=[
|
|
12
|
+
structlog.contextvars.merge_contextvars,
|
|
13
|
+
structlog.processors.add_log_level,
|
|
14
|
+
structlog.processors.TimeStamper(fmt="iso", utc=True),
|
|
15
|
+
structlog.processors.StackInfoRenderer(),
|
|
16
|
+
structlog.processors.format_exc_info,
|
|
17
|
+
structlog.processors.JSONRenderer(),
|
|
18
|
+
],
|
|
19
|
+
wrapper_class=structlog.make_filtering_bound_logger(level),
|
|
20
|
+
cache_logger_on_first_use=True,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_logger(name: str) -> structlog.stdlib.BoundLogger:
|
|
25
|
+
return structlog.get_logger(name)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from fastapi import FastAPI, Request
|
|
4
|
+
from fastapi.exceptions import RequestValidationError
|
|
5
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
6
|
+
from fastapi.responses import FileResponse, JSONResponse
|
|
7
|
+
from fastapi.staticfiles import StaticFiles
|
|
8
|
+
|
|
9
|
+
from flowmine_studio.api.config import get_settings
|
|
10
|
+
from flowmine_studio.api.errors import StudioError
|
|
11
|
+
from flowmine_studio.api.logging_setup import get_logger, setup_logging
|
|
12
|
+
from flowmine_studio.api.routers import (
|
|
13
|
+
admin_users,
|
|
14
|
+
artifacts,
|
|
15
|
+
auth,
|
|
16
|
+
connections,
|
|
17
|
+
datasets,
|
|
18
|
+
health,
|
|
19
|
+
jobs,
|
|
20
|
+
projects,
|
|
21
|
+
workspaces,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
setup_logging()
|
|
25
|
+
log = get_logger("api")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
STATIC_DIR = Path(__file__).parent / "static"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def create_app() -> FastAPI:
|
|
32
|
+
from contextlib import asynccontextmanager
|
|
33
|
+
|
|
34
|
+
@asynccontextmanager
|
|
35
|
+
async def lifespan(app: FastAPI): # type: ignore[no-untyped-def]
|
|
36
|
+
from flowmine_studio.api import runner
|
|
37
|
+
from flowmine_studio.api.db import init_db
|
|
38
|
+
|
|
39
|
+
init_db()
|
|
40
|
+
yield
|
|
41
|
+
runner.shutdown()
|
|
42
|
+
|
|
43
|
+
app = FastAPI(
|
|
44
|
+
lifespan=lifespan,
|
|
45
|
+
title="FlowMine Studio API",
|
|
46
|
+
version="0.1.0",
|
|
47
|
+
openapi_url="/api/v1/openapi.json",
|
|
48
|
+
docs_url="/api/v1/docs",
|
|
49
|
+
)
|
|
50
|
+
settings = get_settings()
|
|
51
|
+
if settings.cors_origins:
|
|
52
|
+
app.add_middleware(
|
|
53
|
+
CORSMiddleware,
|
|
54
|
+
allow_origins=settings.cors_origins,
|
|
55
|
+
allow_methods=["*"],
|
|
56
|
+
allow_headers=["*"],
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
@app.middleware("http")
|
|
60
|
+
async def db_lifecycle(request: Request, call_next): # type: ignore[no-untyped-def]
|
|
61
|
+
try:
|
|
62
|
+
response = await call_next(request)
|
|
63
|
+
except Exception:
|
|
64
|
+
db = getattr(request.state, "db", None)
|
|
65
|
+
if db is not None:
|
|
66
|
+
db.rollback()
|
|
67
|
+
db.close()
|
|
68
|
+
raise
|
|
69
|
+
db = getattr(request.state, "db", None)
|
|
70
|
+
if db is not None:
|
|
71
|
+
try:
|
|
72
|
+
if response.status_code < 400:
|
|
73
|
+
db.commit()
|
|
74
|
+
else:
|
|
75
|
+
db.rollback()
|
|
76
|
+
finally:
|
|
77
|
+
db.close()
|
|
78
|
+
return response
|
|
79
|
+
|
|
80
|
+
prefix = "/api/v1"
|
|
81
|
+
for router in (
|
|
82
|
+
health.router,
|
|
83
|
+
auth.router,
|
|
84
|
+
workspaces.router,
|
|
85
|
+
connections.router,
|
|
86
|
+
datasets.router,
|
|
87
|
+
projects.router,
|
|
88
|
+
jobs.router,
|
|
89
|
+
artifacts.router,
|
|
90
|
+
admin_users.router,
|
|
91
|
+
):
|
|
92
|
+
app.include_router(router, prefix=prefix)
|
|
93
|
+
|
|
94
|
+
@app.exception_handler(StudioError)
|
|
95
|
+
async def studio_error_handler(request: Request, exc: StudioError) -> JSONResponse:
|
|
96
|
+
if exc.status >= 500:
|
|
97
|
+
log.error("request_failed", path=request.url.path, code=exc.code, msg=exc.message)
|
|
98
|
+
return JSONResponse(status_code=exc.status, content=exc.envelope())
|
|
99
|
+
|
|
100
|
+
# --- packaged UI: static SPA with /api passthrough ---
|
|
101
|
+
if (STATIC_DIR / "index.html").exists():
|
|
102
|
+
app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets")
|
|
103
|
+
|
|
104
|
+
@app.get("/{spa_path:path}", include_in_schema=False)
|
|
105
|
+
async def spa(spa_path: str): # type: ignore[no-untyped-def]
|
|
106
|
+
if spa_path.startswith(("api/", "api")) and spa_path.split("/")[0] == "api":
|
|
107
|
+
return JSONResponse(
|
|
108
|
+
status_code=404,
|
|
109
|
+
content={"error": {"code": "not_found", "message": "Unknown API path"}},
|
|
110
|
+
)
|
|
111
|
+
candidate = STATIC_DIR / spa_path
|
|
112
|
+
if spa_path and candidate.is_file():
|
|
113
|
+
return FileResponse(candidate)
|
|
114
|
+
return FileResponse(STATIC_DIR / "index.html")
|
|
115
|
+
|
|
116
|
+
@app.exception_handler(RequestValidationError)
|
|
117
|
+
async def validation_handler(
|
|
118
|
+
request: Request, exc: RequestValidationError
|
|
119
|
+
) -> JSONResponse:
|
|
120
|
+
return JSONResponse(
|
|
121
|
+
status_code=422,
|
|
122
|
+
content={
|
|
123
|
+
"error": {
|
|
124
|
+
"code": "validation_error",
|
|
125
|
+
"message": "Request validation failed",
|
|
126
|
+
"detail": exc.errors(),
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
return app
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
app = create_app()
|