feat(org): org creation wizard + app profile (v2 phase A1-2)

This commit is contained in:
mjalmoudy94
2026-10-14 10:46:47 +03:30
parent 5095a29df9
commit 5fb85d6eed
13 changed files with 3485 additions and 71 deletions
+403
View File
@@ -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",
]