feat(org): org creation wizard + app profile (v2 phase A1-2)
This commit is contained in:
@@ -21,6 +21,7 @@ class AuditAction:
|
||||
PASSWORD_CHANGE = "auth.password_change"
|
||||
ORG_CREATE = "organization.create"
|
||||
ORG_DELETE = "organization.delete"
|
||||
ORG_WIZARD_COMPLETE = "organization.wizard_complete"
|
||||
#: An organization changed hands because its owning manager was demoted or
|
||||
#: deleted — the platform owner takes over. Never automatic for any other
|
||||
#: reason.
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
"""The org-creation wizard's application catalog.
|
||||
|
||||
Pure Python, no I/O: the wizard frontend fetches it from ``GET /setup/catalog``
|
||||
and the backend uses ``defaults_for`` to prefill — and to *validate* — the app
|
||||
profile a user submits. Keeping the catalog in one place means the "Next.js"
|
||||
card and the server's idea of what "Next.js" means can never drift apart, and a
|
||||
typo in a framework name fails loudly at validation time instead of producing a
|
||||
half-built project.
|
||||
|
||||
The catalog is intentionally conservative: it lists what the platform actually
|
||||
knows how to build and run, not every language on earth. Each framework carries
|
||||
enough defaults that a user who accepts the suggestions gets a working app
|
||||
without touching a terminal.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
#: The kinds of app an organization can host. Mirrors ``APP_TYPES`` in
|
||||
#: ``app.domain.projects.profile``; declared again here so the catalog is the
|
||||
#: single source the frontend renders from.
|
||||
APP_TYPES: tuple[str, ...] = ("web_app", "api", "static_site", "bot", "worker", "other")
|
||||
|
||||
#: Display labels (Persian) keyed by app type, for the wizard cards.
|
||||
APP_TYPE_LABELS: dict[str, str] = {
|
||||
"web_app": "وباپلیکیشن",
|
||||
"api": "ایپیآی",
|
||||
"static_site": "سایت ایستا",
|
||||
"bot": "بات",
|
||||
"worker": "ورکر",
|
||||
"other": "سایر",
|
||||
}
|
||||
|
||||
GIT_SOURCES: tuple[str, ...] = ("platform", "external")
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True, kw_only=True)
|
||||
class ProfileDefaults:
|
||||
"""The values ``defaults_for`` hands back for a (type, language, framework)."""
|
||||
|
||||
app_type: str
|
||||
language: str
|
||||
framework: str
|
||||
runtime_version: str
|
||||
package_manager: str
|
||||
setup_command: str
|
||||
build_command: str | None
|
||||
test_command: str | None
|
||||
start_command: str
|
||||
app_port: int
|
||||
healthcheck_path: str
|
||||
docker_image: str
|
||||
|
||||
@property
|
||||
def needs_build(self) -> bool:
|
||||
return self.build_command is not None and self.build_command.strip() != ""
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True, kw_only=True)
|
||||
class FrameworkEntry:
|
||||
"""One selectable framework inside a language group."""
|
||||
|
||||
key: str
|
||||
display: str
|
||||
defaults: ProfileDefaults
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True, kw_only=True)
|
||||
class LanguageEntry:
|
||||
"""A language group and the frameworks it offers."""
|
||||
|
||||
key: str
|
||||
display: str
|
||||
frameworks: tuple[FrameworkEntry, ...]
|
||||
|
||||
|
||||
def _fw(
|
||||
*,
|
||||
key: str,
|
||||
display: str,
|
||||
app_type: str,
|
||||
runtime_version: str,
|
||||
package_manager: str,
|
||||
setup_command: str,
|
||||
start_command: str,
|
||||
app_port: int,
|
||||
healthcheck_path: str = "/",
|
||||
build_command: str | None = None,
|
||||
test_command: str | None = None,
|
||||
docker_image: str,
|
||||
) -> FrameworkEntry:
|
||||
return FrameworkEntry(
|
||||
key=key,
|
||||
display=display,
|
||||
defaults=ProfileDefaults(
|
||||
app_type=app_type,
|
||||
language="", # filled by the language group below
|
||||
framework=key,
|
||||
runtime_version=runtime_version,
|
||||
package_manager=package_manager,
|
||||
setup_command=setup_command,
|
||||
build_command=build_command,
|
||||
test_command=test_command,
|
||||
start_command=start_command,
|
||||
app_port=app_port,
|
||||
healthcheck_path=healthcheck_path,
|
||||
docker_image=docker_image,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Node.js
|
||||
# ---------------------------------------------------------------------------
|
||||
_NODE = (
|
||||
_fw(
|
||||
key="next",
|
||||
display="Next.js",
|
||||
app_type="web_app",
|
||||
runtime_version="20",
|
||||
package_manager="npm",
|
||||
setup_command="npm install",
|
||||
build_command="npm run build",
|
||||
test_command="npm run test",
|
||||
start_command="npm run start",
|
||||
app_port=3000,
|
||||
healthcheck_path="/",
|
||||
docker_image="node:20-slim",
|
||||
),
|
||||
_fw(
|
||||
key="express",
|
||||
display="Express",
|
||||
app_type="api",
|
||||
runtime_version="20",
|
||||
package_manager="npm",
|
||||
setup_command="npm install",
|
||||
build_command=None,
|
||||
test_command="npm run test",
|
||||
start_command="npm start",
|
||||
app_port=3000,
|
||||
healthcheck_path="/health",
|
||||
docker_image="node:20-slim",
|
||||
),
|
||||
_fw(
|
||||
key="nestjs",
|
||||
display="NestJS",
|
||||
app_type="api",
|
||||
runtime_version="20",
|
||||
package_manager="npm",
|
||||
setup_command="npm install",
|
||||
build_command="npm run build",
|
||||
test_command="npm run test",
|
||||
start_command="npm run start",
|
||||
app_port=3000,
|
||||
healthcheck_path="/health",
|
||||
docker_image="node:20-slim",
|
||||
),
|
||||
_fw(
|
||||
key="vite-react",
|
||||
display="Vite + React",
|
||||
app_type="web_app",
|
||||
runtime_version="20",
|
||||
package_manager="npm",
|
||||
setup_command="npm install",
|
||||
build_command="npm run build",
|
||||
test_command=None,
|
||||
start_command="npm run dev -- --host 0.0.0.0",
|
||||
app_port=5173,
|
||||
healthcheck_path="/",
|
||||
docker_image="node:20-slim",
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Python
|
||||
# ---------------------------------------------------------------------------
|
||||
_PYTHON = (
|
||||
_fw(
|
||||
key="fastapi",
|
||||
display="FastAPI",
|
||||
app_type="api",
|
||||
runtime_version="3.12",
|
||||
package_manager="pip",
|
||||
setup_command="pip install -r requirements.txt",
|
||||
build_command=None,
|
||||
test_command="pytest",
|
||||
start_command="uvicorn main:app --host 0.0.0.0 --port 8000",
|
||||
app_port=8000,
|
||||
healthcheck_path="/health",
|
||||
docker_image="python:3.12-slim",
|
||||
),
|
||||
_fw(
|
||||
key="django",
|
||||
display="Django",
|
||||
app_type="web_app",
|
||||
runtime_version="3.12",
|
||||
package_manager="pip",
|
||||
setup_command="pip install -r requirements.txt",
|
||||
build_command=None,
|
||||
test_command="python manage.py test",
|
||||
start_command="gunicorn config.wsgi:application --bind 0.0.0.0:8000",
|
||||
app_port=8000,
|
||||
healthcheck_path="/",
|
||||
docker_image="python:3.12-slim",
|
||||
),
|
||||
_fw(
|
||||
key="flask",
|
||||
display="Flask",
|
||||
app_type="api",
|
||||
runtime_version="3.12",
|
||||
package_manager="pip",
|
||||
setup_command="pip install -r requirements.txt",
|
||||
build_command=None,
|
||||
test_command="pytest",
|
||||
start_command="gunicorn app:app --bind 0.0.0.0:8000",
|
||||
app_port=8000,
|
||||
healthcheck_path="/health",
|
||||
docker_image="python:3.12-slim",
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PHP
|
||||
# ---------------------------------------------------------------------------
|
||||
_PHP = (
|
||||
_fw(
|
||||
key="laravel",
|
||||
display="Laravel",
|
||||
app_type="web_app",
|
||||
runtime_version="8.3",
|
||||
package_manager="composer",
|
||||
setup_command="composer install",
|
||||
build_command="npm install && npm run build",
|
||||
test_command="php artisan test",
|
||||
start_command="php artisan serve --host 0.0.0.0 --port 8000",
|
||||
app_port=8000,
|
||||
healthcheck_path="/",
|
||||
docker_image="php:8.3-fpm",
|
||||
),
|
||||
_fw(
|
||||
key="plain",
|
||||
display="PHP ساده",
|
||||
app_type="web_app",
|
||||
runtime_version="8.3",
|
||||
package_manager="composer",
|
||||
setup_command="composer install",
|
||||
build_command=None,
|
||||
test_command="php -l .",
|
||||
start_command="php -S 0.0.0.0:8000",
|
||||
app_port=8000,
|
||||
healthcheck_path="/",
|
||||
docker_image="php:8.3-cli",
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Go
|
||||
# ---------------------------------------------------------------------------
|
||||
_GO = (
|
||||
_fw(
|
||||
key="plain",
|
||||
display="Go ساده",
|
||||
app_type="api",
|
||||
runtime_version="1.22",
|
||||
package_manager="go mod",
|
||||
setup_command="go mod download",
|
||||
build_command="go build -o bin/app ./cmd",
|
||||
test_command="go test ./...",
|
||||
start_command="./bin/app",
|
||||
app_port=8080,
|
||||
healthcheck_path="/health",
|
||||
docker_image="golang:1.22",
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Static
|
||||
# ---------------------------------------------------------------------------
|
||||
_STATIC = (
|
||||
_fw(
|
||||
key="html",
|
||||
display="HTML ایستا",
|
||||
app_type="static_site",
|
||||
runtime_version="",
|
||||
package_manager="",
|
||||
setup_command="",
|
||||
build_command=None,
|
||||
test_command=None,
|
||||
start_command="",
|
||||
app_port=80,
|
||||
healthcheck_path="/",
|
||||
docker_image="nginx:alpine",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
#: The full catalog, in display order.
|
||||
LANGUAGES: tuple[LanguageEntry, ...] = (
|
||||
LanguageEntry(key="node", display="Node.js", frameworks=_NODE),
|
||||
LanguageEntry(key="python", display="Python", frameworks=_PYTHON),
|
||||
LanguageEntry(key="php", display="PHP", frameworks=_PHP),
|
||||
LanguageEntry(key="go", display="Go", frameworks=_GO),
|
||||
LanguageEntry(key="static", display="ایستا", frameworks=_STATIC),
|
||||
)
|
||||
|
||||
#: Fast lookup ``(language, framework) -> ProfileDefaults``.
|
||||
_LOOKUP: dict[tuple[str, str], ProfileDefaults] = {}
|
||||
|
||||
|
||||
def _build_lookup() -> None:
|
||||
from dataclasses import asdict
|
||||
|
||||
for lang in LANGUAGES:
|
||||
for fw in lang.frameworks:
|
||||
base = asdict(fw.defaults)
|
||||
base["language"] = lang.key
|
||||
defaults = ProfileDefaults(**base)
|
||||
_LOOKUP[(lang.key, fw.key)] = defaults
|
||||
|
||||
|
||||
_build_lookup()
|
||||
|
||||
|
||||
def defaults_for(*, app_type: str, language: str, framework: str) -> ProfileDefaults:
|
||||
"""Resolve the catalog defaults for a concrete choice.
|
||||
|
||||
Raises ``ValueError`` when the combination is not in the catalog — the
|
||||
backend calls this while validating the wizard's ``complete`` payload, so an
|
||||
unknown framework (a stale frontend, a hand-crafted request) is rejected
|
||||
before anything is written.
|
||||
"""
|
||||
key = (language, framework)
|
||||
if key not in _LOOKUP:
|
||||
raise ValueError(
|
||||
f"چارچوب ناشناخته: language={language!r} framework={framework!r}"
|
||||
)
|
||||
found = _LOOKUP[key]
|
||||
if found.app_type != app_type:
|
||||
raise ValueError(
|
||||
f"نوع اپلیکیشن با چارچوب همخوانی ندارد: انتظار {found.app_type!r}، دریافت {app_type!r}"
|
||||
)
|
||||
return found
|
||||
|
||||
|
||||
def is_known_language(language: str) -> bool:
|
||||
return any(lang.key == language for lang in LANGUAGES)
|
||||
|
||||
|
||||
def is_known_framework(language: str, framework: str) -> bool:
|
||||
return (language, framework) in _LOOKUP
|
||||
|
||||
|
||||
def catalog_payload() -> dict:
|
||||
"""Structure returned by ``GET /setup/catalog``."""
|
||||
|
||||
def _framework_out(fw: FrameworkEntry) -> dict:
|
||||
d = fw.defaults
|
||||
return {
|
||||
"key": fw.key,
|
||||
"display": fw.display,
|
||||
"app_type": d.app_type,
|
||||
"defaults": {
|
||||
"runtime_version": d.runtime_version,
|
||||
"package_manager": d.package_manager,
|
||||
"setup_command": d.setup_command,
|
||||
"build_command": d.build_command,
|
||||
"test_command": d.test_command,
|
||||
"start_command": d.start_command,
|
||||
"app_port": d.app_port,
|
||||
"healthcheck_path": d.healthcheck_path,
|
||||
"docker_image": d.docker_image,
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
"app_types": list(APP_TYPES),
|
||||
"app_type_labels": APP_TYPE_LABELS,
|
||||
"git_sources": list(GIT_SOURCES),
|
||||
"languages": [
|
||||
{
|
||||
"key": lang.key,
|
||||
"display": lang.display,
|
||||
"frameworks": [_framework_out(fw) for fw in lang.frameworks],
|
||||
}
|
||||
for lang in LANGUAGES
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"APP_TYPES",
|
||||
"APP_TYPE_LABELS",
|
||||
"GIT_SOURCES",
|
||||
"LANGUAGES",
|
||||
"FrameworkEntry",
|
||||
"LanguageEntry",
|
||||
"ProfileDefaults",
|
||||
"catalog_payload",
|
||||
"defaults_for",
|
||||
"is_known_framework",
|
||||
"is_known_language",
|
||||
]
|
||||
Reference in New Issue
Block a user