diff --git a/fastapi_forge/cli/main.py b/fastapi_forge/cli/main.py index e69cf64..9576a98 100644 --- a/fastapi_forge/cli/main.py +++ b/fastapi_forge/cli/main.py @@ -1,14 +1,6 @@ -import sys -from pathlib import Path - import click from fastapi_forge.api import start_forge_api -from fastapi_forge.frontend.main import init -from fastapi_forge.project_io import ( - YamlProjectLoader, - create_postgres_project_loader, -) def confirm_uv_installed() -> bool: @@ -56,84 +48,82 @@ def start() -> None: start_forge_api() -@main.command( - help="Start FastAPI Forge - Generate a new FastAPI project.", -) -@click.option( - "--use-example", - is_flag=True, - help="Generate a new project using a prebuilt example provided by FastAPI Forge.", -) -@click.option( - "--no-ui", - is_flag=True, - help="Generate the project directly in the terminal without launching the UI (default: False).", -) -@click.option( - "--dry-run", - is_flag=True, - help="Perform a dry run without generating any files (requires --no-ui).", -) -@click.option( - "--from-yaml", - type=click.Path( - exists=True, - dir_okay=False, - readable=True, - path_type=Path, - ), - help="Generate a project using a custom configuration from a YAML file.", -) -@click.option( - "--yes", - is_flag=True, - help="Automatically confirm all prompts (use with caution).", -) -@click.option( - "--conn-string", - help="Generate a project from a PostgreSQL connection string " - "(e.g., postgresql://user:password@host:port/dbname)", -) -@click.pass_context -def start_v1( - _: click.Context, - use_example: bool = False, - no_ui: bool = False, - dry_run: bool = False, - yes: bool = False, - from_yaml: Path | None = None, - conn_string: str | None = None, -) -> None: - """Start FastAPI Forge.""" - if not yes and not confirm_uv_installed(): - sys.exit(1) - - option_count = sum([use_example, bool(from_yaml), bool(conn_string)]) - if option_count > 1: - raise click.UsageError( - "Only one of '--use-example', '--from-yaml', or '--conn-string' can be used." - ) - - if no_ui and option_count < 1: - raise click.UsageError( - "Option '--no-ui' requires one of '--use-example', '--from-yaml', or '--conn-string'." - ) - - if dry_run and not no_ui: - raise click.UsageError("Option '--dry-run' requires '--no-ui' to be set.") - - project_spec = None - - if from_yaml: - project_spec = YamlProjectLoader(project_path=from_yaml).load() - elif conn_string: - project_spec = create_postgres_project_loader(conn_string).load() - elif use_example: - base_path = Path(__file__).parent / "example-projects" - path = base_path / "game_zone.yaml" - project_spec = YamlProjectLoader(project_path=path).load() - - init(project_spec=project_spec, no_ui=no_ui, dry_run=dry_run) +# @main.command( +# help="Start FastAPI Forge - Generate a new FastAPI project.", +# ) +# @click.option( +# "--use-example", +# is_flag=True, +# help="Generate a new project using a prebuilt example provided by FastAPI Forge.", +# ) +# @click.option( +# "--no-ui", +# is_flag=True, +# help="Generate the project directly in the terminal without launching the UI (default: False).", +# ) +# @click.option( +# "--dry-run", +# is_flag=True, +# help="Perform a dry run without generating any files (requires --no-ui).", +# ) +# @click.option( +# "--from-yaml", +# type=click.Path( +# exists=True, +# dir_okay=False, +# readable=True, +# path_type=Path, +# ), +# help="Generate a project using a custom configuration from a YAML file.", +# ) +# @click.option( +# "--yes", +# is_flag=True, +# help="Automatically confirm all prompts (use with caution).", +# ) +# @click.option( +# "--conn-string", +# help="Generate a project from a PostgreSQL connection string " +# "(e.g., postgresql://user:password@host:port/dbname)", +# ) +# @click.pass_context +# def start_v1( +# _: click.Context, +# use_example: bool = False, +# no_ui: bool = False, +# dry_run: bool = False, +# yes: bool = False, +# from_yaml: Path | None = None, +# conn_string: str | None = None, +# ) -> None: +# """Start FastAPI Forge.""" +# if not yes and not confirm_uv_installed(): +# sys.exit(1) + +# option_count = sum([use_example, bool(from_yaml), bool(conn_string)]) +# if option_count > 1: +# raise click.UsageError( +# "Only one of '--use-example', '--from-yaml', or '--conn-string' can be used." +# ) + +# if no_ui and option_count < 1: +# raise click.UsageError( +# "Option '--no-ui' requires one of '--use-example', '--from-yaml', or '--conn-string'." +# ) + +# if dry_run and not no_ui: +# raise click.UsageError("Option '--dry-run' requires '--no-ui' to be set.") + +# project_spec = None + +# if from_yaml: +# project_spec = YamlProjectLoader(project_path=from_yaml).load() +# elif conn_string: +# project_spec = create_postgres_project_loader(conn_string).load() +# elif use_example: +# base_path = Path(__file__).parent / "example-projects" +# path = base_path / "game_zone.yaml" +# project_spec = YamlProjectLoader(project_path=path).load() if __name__ in {"__main__", "__mp_main__"}: diff --git a/fastapi_forge/frontend/__init__.py b/fastapi_forge/frontend/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/fastapi_forge/frontend/components/__init__.py b/fastapi_forge/frontend/components/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/fastapi_forge/frontend/components/header.py b/fastapi_forge/frontend/components/header.py deleted file mode 100644 index e507c46..0000000 --- a/fastapi_forge/frontend/components/header.py +++ /dev/null @@ -1,30 +0,0 @@ -from nicegui import ui - - -class Header(ui.header): - def __init__(self): - super().__init__() - self.dark_mode = ui.dark_mode(value=True) - self._build() - - def _build(self) -> None: - with self: - ui.button( - icon="eva-github", - color="white", - on_click=lambda: ui.navigate.to( - "https://github.com/mslaursen/fastapi-forge", - ), - ).classes("self-center", remove="bg-white").tooltip( - "Drop a ⭐️ if you like FastAPI Forge!", - ) - - ui.label(text="FastAPI Forge").classes( - "font-bold ml-auto self-center text-2xl", - ) - - ui.button( - icon="dark_mode", - color="white", - on_click=lambda: self.dark_mode.toggle(), - ).classes("ml-auto", remove="bg-white") diff --git a/fastapi_forge/frontend/components/item_create.py b/fastapi_forge/frontend/components/item_create.py deleted file mode 100644 index 68dc690..0000000 --- a/fastapi_forge/frontend/components/item_create.py +++ /dev/null @@ -1,67 +0,0 @@ -from collections.abc import Callable - -from nicegui import ui - -from fastapi_forge.frontend.state import state - - -class _RowCreate(ui.row): - def __init__( - self, - *, - input_placeholder: str, - input_tooltip: str, - button_tooltip: str, - on_add_item: Callable[[str], None], - ): - super().__init__(wrap=False) - self.input_placeholder = input_placeholder - self.input_tooltip = input_tooltip - self.button_tooltip = button_tooltip - self.on_add_item = on_add_item - - self._build() - - def _build(self) -> None: - with self.classes("w-full flex items-center justify-between"): - self.item_input = ( - ui.input(placeholder=self.input_placeholder) - .classes("self-center") - .tooltip( - self.input_tooltip, - ) - ) - self.add_button = ( - ui.button(icon="add", on_click=self._add_item) - .classes("self-center") - .tooltip(self.button_tooltip) - ) - - def _add_item(self) -> None: - if not self.item_input.value: - return - value: str = self.item_input.value - item_name = value.strip() - if item_name: - self.on_add_item(item_name) - self.item_input.value = "" - - -class ModelCreate(_RowCreate): - def __init__(self): - super().__init__( - input_placeholder="Model name", - input_tooltip="Model names should be singular and snake_case (e.g. 'auth_user').", - button_tooltip="Add Model", - on_add_item=state.add_model, - ) - - -class EnumCreate(_RowCreate): - def __init__(self): - super().__init__( - input_placeholder="Enum name", - input_tooltip="Enums can be used as data types for model fields.", - button_tooltip="Add Enum", - on_add_item=state.add_enum, - ) diff --git a/fastapi_forge/frontend/components/item_row.py b/fastapi_forge/frontend/components/item_row.py deleted file mode 100644 index 67c6ab6..0000000 --- a/fastapi_forge/frontend/components/item_row.py +++ /dev/null @@ -1,142 +0,0 @@ -from collections.abc import Callable - -from nicegui import ui -from pydantic import BaseModel - -from fastapi_forge.frontend.constants import ITEM_ROW_TRUNCATE_LEN -from fastapi_forge.frontend.state import state -from fastapi_forge.schemas import CustomEnum, Model - - -class _ItemRow[T: BaseModel](ui.row): - def __init__( - self, - item: T, - color: str | None = None, - icon: str | None = None, - *, - is_selected: bool, - on_select: Callable[[T], None], - on_delete: Callable[[T], None], - on_update_name: Callable[[T, str], None], - get_name: Callable[[T], str] = lambda x: x.name, # type: ignore - ): - super().__init__(wrap=False) - self.item = item - self.is_selected_row = is_selected - self.color = color - self.icon = icon - self.is_editing = False - - self.on_select = on_select - self.on_delete = on_delete - self.on_update_name = on_update_name - self.get_name = get_name - - self._build() - - def _build(self) -> None: - self.on("click", lambda: self.on_select(self.item)) - base_classes = "w-full flex items-center justify-between cursor-pointer p-2 rounded transition-all" - if self.is_selected_row: - base_classes += " bg-blue-100 dark:bg-blue-900 border-l-4 border-blue-500" - else: - base_classes += " hover:bg-gray-100 dark:hover:bg-gray-800" - - with self.classes(base_classes): - with ui.row().classes("flex-nowrap gap-2 min-w-fit"): - if self.icon: - ui.icon(self.icon, color="green", size="20px").classes( - "self-center" - ) - full_name = self.get_name(self.item) - - if len(full_name) > ITEM_ROW_TRUNCATE_LEN: - truncated_name = ( - (full_name[:ITEM_ROW_TRUNCATE_LEN] + "...") - if len(full_name) > ITEM_ROW_TRUNCATE_LEN - else full_name - ) - self.name_label = ( - ui.label(text=truncated_name) - .classes("self-center truncate") - .tooltip(full_name) - ) - else: - self.name_label = ui.label(text=full_name).classes( - "self-center truncate" - ) - - if self.color: - self.name_label.classes(add=self.color) - - self.name_input = ( - ui.input(value=self.get_name(self.item)) - .classes("self-center") - .bind_visibility_from(self, "is_editing") - ) - self.name_label.bind_visibility_from(self, "is_editing", lambda x: not x) - - with ui.row().classes("flex-nowrap gap-2 min-w-fit"): - self.edit_button = ( - ui.button(icon="edit") - .on("click.stop", self._toggle_edit) - .bind_visibility_from(self, "is_editing", lambda x: not x) - .classes("min-w-fit") - ) - - self.save_button = ( - ui.button(icon="save") - .on("click.stop", self._save_item) - .bind_visibility_from(self, "is_editing") - .classes("min-w-fit") - ) - - ui.button(icon="delete").on( - "click.stop", lambda: self.on_delete(self.item) - ).classes("min-w-fit") - - def _toggle_edit(self) -> None: - self.is_editing = not self.is_editing - - def _save_item(self) -> None: - new_name = self.name_input.value.strip() - if new_name: - self.on_update_name(self.item, new_name) - self.is_editing = False - - -class ModelRow(_ItemRow): - def __init__( - self, - model: Model, - color: str | None = None, - icon: str | None = None, - ): - super().__init__( - item=model, - color=color, - icon=icon, - is_selected=model == state.selected_model, - on_select=state.select_model, - on_delete=state.delete_model, - on_update_name=state.update_model_name, - ) - - -class EnumRow(_ItemRow): - def __init__( - self, - custom_enum: CustomEnum, - color: str | None = None, - icon: str | None = None, - ): - super().__init__( - item=custom_enum, - color=color, - icon=icon, - is_selected=custom_enum == state.selected_enum, - on_select=state.select_enum, - on_delete=state.delete_enum, - on_update_name=state.update_enum_name, - ) diff --git a/fastapi_forge/frontend/constants.py b/fastapi_forge/frontend/constants.py deleted file mode 100644 index bede858..0000000 --- a/fastapi_forge/frontend/constants.py +++ /dev/null @@ -1,85 +0,0 @@ -from typing import Any - -from fastapi_forge.enums import FieldDataTypeEnum -from fastapi_forge.schemas import ModelField - -SELECTED_MODEL_TEXT_COLOR = "text-black-500 dark:text-amber-300" -SELECTED_ENUM_TEXT_COLOR = "text-black-500 dark:text-amber-300" -ITEM_ROW_TRUNCATE_LEN = 17 - -FIELD_COLUMNS: list[dict[str, Any]] = [ - { - "name": "name", - "label": "Name", - "field": "name", - "required": True, - "align": "left", - }, - {"name": "type", "label": "Type", "field": "type", "align": "left"}, - { - "name": "primary_key", - "label": "Primary Key", - "field": "primary_key", - "align": "center", - }, - {"name": "nullable", "label": "Nullable", "field": "nullable", "align": "center"}, - {"name": "unique", "label": "Unique", "field": "unique", "align": "center"}, - {"name": "index", "label": "Index", "field": "index", "align": "center"}, -] - -ENUM_COLUMNS: list[dict[str, Any]] = [ - { - "name": "name", - "label": "Name", - "field": "name", - "required": True, - "align": "left", - }, - { - "name": "value", - "label": "Value", - "field": "value", - "required": True, - "align": "left", - }, -] - -RELATIONSHIP_COLUMNS: list[dict[str, Any]] = [ - { - "name": "field_name", - "label": "Field Name", - "field": "field_name", - "required": True, - "align": "left", - }, - { - "name": "target_model", - "label": "Target Model", - "field": "target_model", - "align": "left", - }, - { - "name": "on_delete", - "label": "On Delete", - "field": "on_delete", - "align": "left", - }, - {"name": "nullable", "label": "Nullable", "field": "nullable", "align": "center"}, - {"name": "index", "label": "Index", "field": "index", "align": "center"}, - {"name": "unique", "label": "Unique", "field": "unique", "align": "center"}, -] - - -DEFAULT_AUTH_USER_FIELDS: list[ModelField] = [ - ModelField( - name="email", - type=FieldDataTypeEnum.STRING, - unique=True, - index=True, - ), - ModelField( - name="password", - type=FieldDataTypeEnum.STRING, - ), -] -DEFAULT_AUTH_USER_ROLE_ENUM_NAME = "UserRole" diff --git a/fastapi_forge/frontend/main.py b/fastapi_forge/frontend/main.py deleted file mode 100644 index 1345c0f..0000000 --- a/fastapi_forge/frontend/main.py +++ /dev/null @@ -1,94 +0,0 @@ -import asyncio - -from nicegui import native, ui - -from fastapi_forge.core import build_fastapi_project -from fastapi_forge.enums import FieldDataTypeEnum -from fastapi_forge.frontend.components.header import Header -from fastapi_forge.frontend.panels.item_editor_panel import ItemEditorPanel -from fastapi_forge.frontend.panels.left_panel import LeftPanel -from fastapi_forge.frontend.panels.project_config_panel import ProjectConfigPanel -from fastapi_forge.frontend.state import state -from fastapi_forge.schemas import ( - CustomEnum, - CustomEnumValue, - Model, - ModelField, - ProjectSpec, -) - - -def setup_ui() -> None: - """Setup basic UI configuration""" - ui.add_head_html( - '', - ) - ui.button.default_props("round flat dense") - ui.input.default_props("dense") - Header() - - -def create_ui_components() -> None: - """Create all UI components""" - ItemEditorPanel() - - LeftPanel().classes("shadow-xl dark:shadow-none") - ProjectConfigPanel().classes("shadow-xl dark:shadow-none") - - -def run_ui(reload: bool) -> None: - """Run the NiceGUI application""" - ui.run( - reload=reload, - title="FastAPI Forge", - port=native.find_open_port(8777, 8999), - ) - - -def init( - *, - reload: bool = False, - no_ui: bool = False, - dry_run: bool = False, - project_spec: ProjectSpec | None = None, -) -> None: - if project_spec: - if no_ui: - asyncio.run(build_fastapi_project(project_spec, dry_run=dry_run)) - return - - state.initialize_from_project(project_spec) - - setup_ui() - create_ui_components() - run_ui(reload) - - -if __name__ in {"__main__", "__mp_main__"}: - project_spec = ProjectSpec( - project_name="reload", - models=[ - Model( - name="test_model", - fields=[ - ModelField( - name="id", - primary_key=True, - type=FieldDataTypeEnum.UUID, - ), - ], - ), - ], - custom_enums=[ - CustomEnum( - name="MyEnum", - values=[ - CustomEnumValue( - name="FOO", - value="auto()", - ), - ], - ) - ], - ) - init(reload=True, project_spec=project_spec) diff --git a/fastapi_forge/frontend/modals/__init__.py b/fastapi_forge/frontend/modals/__init__.py deleted file mode 100644 index aa94ba1..0000000 --- a/fastapi_forge/frontend/modals/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -from fastapi_forge.frontend.modals.enum_modal import ( - AddEnumValueModal, - UpdateEnumValueModal, -) -from fastapi_forge.frontend.modals.field_modal import AddFieldModal, UpdateFieldModal -from fastapi_forge.frontend.modals.relation_modal import ( - AddRelationModal, - UpdateRelationModal, -) diff --git a/fastapi_forge/frontend/modals/enum_modal.py b/fastapi_forge/frontend/modals/enum_modal.py deleted file mode 100644 index 71b4fda..0000000 --- a/fastapi_forge/frontend/modals/enum_modal.py +++ /dev/null @@ -1,110 +0,0 @@ -from abc import ABC, abstractmethod -from collections.abc import Callable - -from nicegui import ui - -from fastapi_forge.frontend.notifications import notify_value_error -from fastapi_forge.frontend.state import state -from fastapi_forge.schemas import CustomEnumValue - - -class BaseEnumValueModal(ui.dialog, ABC): - title: str - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._build() - - @abstractmethod - def _build_action_buttons(self) -> None: - pass - - def _build(self) -> None: - with self, ui.card().classes("w-full max-w-md no-shadow rounded-lg"): - with ui.row().classes("w-full justify-between items-center p-4 border-b"): - ui.label(self.title).classes("text-xl font-semibold") - - with ( - ui.column().classes("w-full p-6 space-y-4"), - ui.grid(columns=2).classes("w-full gap-4"), - ): - self.value_name = ( - ui.input(label="Name").props("outlined dense").classes("w-full") - ) - self.value_value = ( - ui.input(label="Value").props("outlined dense").classes("w-full") - ).tooltip( - "Set to auto(), or any string value without including quotes." - ) - - with ui.row().classes("w-full justify-end p-4 border-t gap-2"): - self._build_action_buttons() - - def reset(self) -> None: - self.value_name.value = "" - self.value_value.value = "" - - -class AddEnumValueModal(BaseEnumValueModal): - title = "Add Enum Value" - - def __init__(self, on_add_value: Callable): - super().__init__() - self.on_add_value = on_add_value - - def _build_action_buttons(self) -> None: - ui.button("Cancel", on_click=self.close) - ui.button( - self.title, - on_click=self._handle_add, - ) - - def _handle_add(self) -> None: - try: - self.on_add_value( - name=self.value_name.value, - value=self.value_value.value, - ) - self.close() - except ValueError as exc: - notify_value_error(exc) - return - - -class UpdateEnumValueModal(BaseEnumValueModal): - title = "Update Enum Value" - - def __init__(self, on_update_value: Callable): - super().__init__() - self.on_update_value = on_update_value - - def _build_action_buttons(self) -> None: - ui.button("Cancel", on_click=self.close) - ui.button( - self.title, - on_click=self._handle_update, - ) - - def _handle_update(self) -> None: - if not state.selected_enum_value: - return - try: - self.on_update_value( - name=self.value_name.value, - value=self.value_value.value, - ) - self.close() - except ValueError as exc: - notify_value_error(exc) - return - - def _set_value(self, enum_value: CustomEnumValue) -> None: - state.selected_enum_value = enum_value - if enum_value: - self.value_name.value = enum_value.name - self.value_value.value = enum_value.value - - def open(self, enum_value: CustomEnumValue | None = None) -> None: - if enum_value: - self._set_value(enum_value) - super().open() diff --git a/fastapi_forge/frontend/modals/field_modal.py b/fastapi_forge/frontend/modals/field_modal.py deleted file mode 100644 index d9f67b5..0000000 --- a/fastapi_forge/frontend/modals/field_modal.py +++ /dev/null @@ -1,384 +0,0 @@ -from abc import ABC, abstractmethod -from collections.abc import Callable - -from nicegui import ui -from pydantic import ValidationError - -from fastapi_forge.enums import FieldDataTypeEnum -from fastapi_forge.frontend.notifications import ( - notify_validation_error, - notify_value_error, -) -from fastapi_forge.frontend.state import state -from fastapi_forge.render.filters import JinjaFilters -from fastapi_forge.schemas import ModelField, ModelFieldMetadata - - -class BaseFieldModal(ui.dialog, ABC): - title: str - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.extra_kwargs = {} - self.show_enum_selector: bool = False - self.show_metadata: bool = False - self.show_enum_defaults: bool = False - self.on("hide", lambda: self.reset()) - self._build() - - @abstractmethod - def _build_action_buttons(self) -> None: - pass - - def _build(self) -> None: - with self, ui.card().classes("w-full max-w-2xl no-shadow rounded-lg"): - with ui.row().classes("w-full justify-between items-center p-4 border-b"): - ui.label(self.title).classes("text-xl font-semibold") - ui.button( - icon="visibility", - on_click=self._show_field_preview, - ).props("flat dense").tooltip("Preview SQLAlchemy field code") - - with ui.column().classes("w-full p-6 space-y-4"): - with ui.grid(columns=2).classes("w-full gap-4"): - self.field_name = ui.input(label="Field Name").props( - "outlined dense" - ) - self.field_type = ui.select( - list(FieldDataTypeEnum), - label="Field Type", - on_change=self._handle_type_change, - ).props("outlined dense") - self.default_value_container = ui.column().classes("w-full") - with self.default_value_container: - self.default_value_input = ( - ui.input(label="Default Value") - .props("outlined dense") - .classes("w-full") - ) - - self.enum_selector = ( - ui.select( - [e.name for e in state.custom_enums], - label="Select Enum", - on_change=lambda: self._handle_type_change(), - ) - .props("outlined dense") - .classes("w-full") - .bind_visibility_from(self, "show_enum_selector") - ) - - with ui.row().classes("w-full justify-between gap-4"): - self.primary_key = ui.checkbox("Primary Key").props("dense") - self.nullable = ui.checkbox("Nullable").props("dense") - self.unique = ui.checkbox("Unique").props("dense") - self.index = ui.checkbox("Index").props("dense") - - self.metadata_card = ( - ui.card() - .classes("w-full p-4 border rounded-lg") - .bind_visibility_from(self, "show_metadata") - ) - with self.metadata_card: - with ui.row().classes("w-full justify-between items-center mb-2"): - ui.label("Field Metadata").classes("text-md font-medium") - - with ui.row().classes("w-full gap-4"): - self.created_at = ui.checkbox("Created At Timestamp").props( - "dense" - ) - self.updated_at = ui.checkbox("Updated At Timestamp").props( - "dense" - ) - - with ui.card().classes("w-full p-4 border rounded-lg"): - with ui.row().classes("w-full justify-between items-center"): - ui.label("Extra Column Arguments").classes( - "text-md font-medium" - ) - ui.button( - "Add Argument", icon="add", on_click=self._add_kwarg_row - ) - - self.kwargs_container = ui.column().classes("w-full gap-2 mt-2") - - with ui.row().classes("w-full justify-end p-4 border-t gap-2"): - self._build_action_buttons() - - def _get_current_enum_values(self) -> list[str]: - if not self.enum_selector.value: - return [] - selected_enum = next( - (e for e in state.custom_enums if e.name == self.enum_selector.value), None - ) - return [v.name for v in selected_enum.values] if selected_enum else [] - - def _handle_type_change(self) -> None: - self.show_metadata = self.field_type.value == FieldDataTypeEnum.DATETIME - self.show_enum_selector = self.field_type.value == FieldDataTypeEnum.ENUM - self.show_enum_defaults = self.field_type.value == FieldDataTypeEnum.ENUM - - if self.show_enum_selector: - self.enum_selector.options = [e.name for e in state.custom_enums] - self.enum_selector.update() - - if self.show_enum_defaults: - self.default_value_container.clear() - with self.default_value_container: - self.default_value_select = ( - ui.select( - self._get_current_enum_values(), - label="Default Value", - ) - .props("outlined dense") - .classes("w-full") - ) - else: - self.default_value_container.clear() - with self.default_value_container: - self.default_value_input = ( - ui.input(label="Default Value") - .props("outlined dense") - .classes("w-full") - ) - - def _add_kwarg_row(self, key: str = "", value: str = "") -> None: - with ( - self.kwargs_container, - ui.row().classes("w-full items-center gap-2") as row, - ): - key_input = ( - ui.input(label="Key", value=key) - .props("outlined dense") - .classes("flex-1") - ) - value_input = ( - ui.input(label="Value", value=value) - .props("outlined dense") - .classes("flex-1") - ) - - def update_kwargs(): - if key_input.value and value_input.value: - self.extra_kwargs[key_input.value] = value_input.value - elif key_input.value in self.extra_kwargs: - del self.extra_kwargs[key_input.value] - - def remove_row(): - if key_input.value in self.extra_kwargs: - del self.extra_kwargs[key_input.value] - row.delete() - - ui.button(icon="delete", on_click=remove_row) - - key_input.on("blur", update_kwargs) - value_input.on("blur", update_kwargs) - key_input.on("keydown.enter", update_kwargs) - value_input.on("keydown.enter", update_kwargs) - - def _show_field_preview(self) -> None: - if not self.field_name.value: - ui.notify("Set a field name first", type="warning") - return - if not self.field_type.value: - ui.notify("Select a field type first", type="warning") - return - try: - default_value = ( - self.default_value_select.value - if self.show_enum_defaults - else self.default_value_input.value - ) or None - - with ui.dialog() as modal, ui.card().classes("no-shadow border-[1px]"): - preview_field = ModelField( - name=self.field_name.value, - type=self.field_type.value, - type_enum=self.enum_selector.value, - primary_key=self.primary_key.value, - nullable=self.nullable.value, - unique=self.unique.value, - index=self.index.value, - default_value=default_value, - extra_kwargs=self.extra_kwargs or None, - metadata=ModelFieldMetadata( - is_created_at_timestamp=( - self.created_at.value if self.show_metadata else False - ), - is_updated_at_timestamp=( - self.updated_at.value if self.show_metadata else False - ), - is_foreign_key=False, - ), - ) - ui.code(JinjaFilters.generate_field(preview_field)).classes("w-full") - modal.open() - except ValidationError as exc: - notify_validation_error(exc) - - def reset(self) -> None: - self.field_name.value = "" - self.field_type.value = None - self.enum_selector.value = None - self.primary_key.value = False - self.nullable.value = False - self.unique.value = False - self.index.value = False - if hasattr(self, "default_value_input"): - self.default_value_input.value = "" - if hasattr(self, "default_value_select"): - self.default_value_select.value = None - self.created_at.value = False - self.updated_at.value = False - self.show_metadata = False - self.show_enum_selector = False - self.show_enum_defaults = False - self.extra_kwargs = {} - self.kwargs_container.clear() - - -class AddFieldModal(BaseFieldModal): - title = "Add Field" - - def __init__(self, on_add_field: Callable): - super().__init__() - self.on_add_field = on_add_field - - def _build_action_buttons(self) -> None: - ui.button("Cancel", on_click=self.close) - ui.button( - self.title, - on_click=lambda: self.on_add_field( - name=self.field_name.value, - type=self.field_type.value, - type_enum=next( - ( - e.name - for e in state.custom_enums - if e.name == self.enum_selector.value - ), - None, - ), - primary_key=self.primary_key.value, - nullable=self.nullable.value, - unique=self.unique.value, - index=self.index.value, - default_value=( - self.default_value_select.value - if self.show_enum_defaults - else self.default_value_input.value - ) - or None, - extra_kwargs=self.extra_kwargs or None, - metadata=ModelFieldMetadata( - is_created_at_timestamp=( - self.created_at.value if self.show_metadata else False - ), - is_updated_at_timestamp=( - self.updated_at.value if self.show_metadata else False - ), - is_foreign_key=False, - ), - ), - ) - - -class UpdateFieldModal(BaseFieldModal): - title = "Update Field" - - def __init__(self, on_update_field: Callable): - super().__init__() - self.on_update_field = on_update_field - - def _build_action_buttons(self) -> None: - ui.button("Cancel", on_click=self.close) - ui.button( - self.title, - on_click=self._handle_update, - ) - - def _handle_update(self) -> None: - if not state.selected_field: - return - - try: - self.on_update_field( - name=self.field_name.value, - type=self.field_type.value, - type_enum=( - next( - ( - e.name - for e in state.custom_enums - if e.name == self.enum_selector.value - ), - None, - ) - if self.show_enum_selector - else None - ), - primary_key=self.primary_key.value, - nullable=self.nullable.value, - unique=self.unique.value, - index=self.index.value, - default_value=( - self.default_value_select.value - if self.show_enum_defaults - else self.default_value_input.value - ) - or None, - extra_kwargs=self.extra_kwargs or None, - metadata=ModelFieldMetadata( - is_created_at_timestamp=( - self.created_at.value if self.show_metadata else False - ), - is_updated_at_timestamp=( - self.updated_at.value if self.show_metadata else False - ), - is_foreign_key=False, - ), - ) - self.close() - except ValueError as exc: - notify_value_error(exc) - return - - def _set_field(self, field: ModelField) -> None: - state.selected_field = field - if not field: - return - - self.field_name.value = field.name - self.field_type.value = field.type - self.primary_key.value = field.primary_key - self.nullable.value = field.nullable - self.unique.value = field.unique - self.index.value = field.index - if field.type == FieldDataTypeEnum.ENUM and field.type_enum: - self.enum_selector.value = field.type_enum - self.enum_selector.update() - if field.default_value: - self.default_value_select.value = field.default_value - self.default_value_select.update() - elif field.default_value: - self.default_value_input.value = field.default_value - self.default_value_input.update() - self.created_at.value = field.metadata.is_created_at_timestamp - self.updated_at.value = field.metadata.is_updated_at_timestamp - self.show_metadata = field.type == FieldDataTypeEnum.DATETIME - self.show_enum_selector = field.type == FieldDataTypeEnum.ENUM - self.extra_kwargs = field.extra_kwargs.copy() if field.extra_kwargs else {} - self.kwargs_container.clear() - - if field.extra_kwargs: - for key, value in field.extra_kwargs.items(): - self._add_kwarg_row(key, str(value)) - - if field.type_enum and self.show_enum_selector: - self.enum_selector.value = field.type_enum - self.enum_selector.update() - - def open(self, field: ModelField | None = None) -> None: - if field: - self._set_field(field) - super().open() diff --git a/fastapi_forge/frontend/modals/relation_modal.py b/fastapi_forge/frontend/modals/relation_modal.py deleted file mode 100644 index 40e9589..0000000 --- a/fastapi_forge/frontend/modals/relation_modal.py +++ /dev/null @@ -1,158 +0,0 @@ -from abc import ABC, abstractmethod -from collections.abc import Callable - -from nicegui import ui - -from fastapi_forge.enums import OnDeleteEnum -from fastapi_forge.frontend.notifications import notify_value_error -from fastapi_forge.schemas import Model, ModelRelationship - - -class BaseRelationModal(ui.dialog, ABC): - title: str - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._build_common_ui() - - def _build_common_ui(self) -> None: - with self, ui.card().classes("w-full max-w-2xl shadow-lg rounded-lg"): - with ui.row().classes("w-full justify-between items-center p-4 border-b"): - ui.label(self.title).classes("text-xl font-semibold") - - with ui.column().classes("w-full p-6 space-y-4"): - with ui.grid(columns=2).classes("w-full gap-4"): - self.field_name = ui.input(label="Field Name").props( - "outlined dense" - ) - self.target_model = ui.select( - label="Target Model", - options=[], - ).props("outlined dense") - self.on_delete = ui.select( - label="On Delete", - options=list(OnDeleteEnum), - value=OnDeleteEnum.CASCADE, - ).props("outlined dense") - self.back_populates = ui.input(label="Back Populates").props( - "outlined dense" - ) - - with ui.row().classes("w-full justify-between gap-4"): - self.nullable = ui.checkbox("Nullable").props("dense") - self.index = ui.checkbox("Index").props("dense") - self.unique = ui.checkbox("Unique").props("dense") - - with ui.row().classes("w-full justify-end p-4 border-t gap-2"): - self._build_action_buttons() - - @abstractmethod - def _build_action_buttons(self) -> None: - pass - - def _reset(self) -> None: - self.field_name.value = "" - self.target_model.value = None - self.back_populates.value = "" - self.on_delete.value = None - self.nullable.value = False - self.index.value = False - self.unique.value = False - - -class AddRelationModal(BaseRelationModal): - title = "Add Relationship" - - def __init__(self, on_add_relation: Callable): - super().__init__() - self.on_add_relation = on_add_relation - - def _build_action_buttons(self) -> None: - ui.button("Cancel", on_click=self.close) - ui.button( - "Add Relation", - on_click=self._add_relation, - ) - - def _add_relation(self) -> None: - try: - self.on_add_relation( - field_name=self.field_name.value, - target_model=self.target_model.value, - back_populates=self.back_populates.value or None, - nullable=self.nullable.value, - index=self.index.value, - unique=self.unique.value, - on_delete=self.on_delete.value, - ) - self.close() - except ValueError as exc: - notify_value_error(exc) - - def open(self, models: list[Model]) -> None: - self.target_model.options = [model.name for model in models] - self.target_model.value = models[0].name if models else None - super().open() - - -class UpdateRelationModal(BaseRelationModal): - title = "Update Relationship" - - def __init__(self, on_update_relation: Callable): - super().__init__() - self.on_update_relation = on_update_relation - self.selected_relation: ModelRelationship | None = None - - def _build_action_buttons(self) -> None: - ui.button("Cancel", on_click=self.close) - ui.button( - "Update Relation", - on_click=self._update_relation, - ) - - def _update_relation(self) -> None: - if not self.selected_relation: - return - - try: - self.on_update_relation( - field_name=self.field_name.value, - target_model=self.target_model.value, - back_populates=self.back_populates.value, - nullable=self.nullable.value, - index=self.index.value, - unique=self.unique.value, - on_delete=self.on_delete.value, - ) - self.close() - except ValueError as exc: - notify_value_error(exc) - - def _set_relation(self, relation: ModelRelationship) -> None: - self.selected_relation = relation - if relation: - self.field_name.value = relation.field_name - self.target_model.value = relation.target_model - self.nullable.value = relation.nullable - self.index.value = relation.index - self.unique.value = relation.unique - self.back_populates.value = relation.back_populates - self.on_delete.value = relation.on_delete - - def open( - self, - relation: ModelRelationship | None = None, - models: list[Model] | None = None, - ) -> None: - if relation and models: - self._set_relation(relation) - self.target_model.options = [model.name for model in models] - default_target_model = next( - (model for model in models if model.name == relation.target_model), - None, - ) - if default_target_model: - self.target_model.value = default_target_model.name - self.target_model.options = [model.name for model in models] - - super().open() diff --git a/fastapi_forge/frontend/notifications.py b/fastapi_forge/frontend/notifications.py deleted file mode 100644 index 6c94b15..0000000 --- a/fastapi_forge/frontend/notifications.py +++ /dev/null @@ -1,46 +0,0 @@ -from nicegui import ui -from pydantic import ValidationError - - -def notify_validation_error(e: ValidationError) -> None: - msg = e.errors()[0].get("msg", "Something went wrong.") - ui.notify(msg, type="negative") - - -def notify_value_error(e: ValueError) -> None: - ui.notify(str(e), type="negative") - - -def notify_model_exists(model_name: str) -> None: - ui.notify( - f"Model '{model_name}' already exists.", - type="negative", - ) - - -def notify_enum_exists(enum_name: str) -> None: - ui.notify( - f"Enum '{enum_name}' already exists.", - type="negative", - ) - - -def notify_field_exists(field_name: str, model_name: str) -> None: - ui.notify( - f"Model' {model_name}' already has field '{field_name}'.", - type="negative", - ) - - -def notify_enum_value_exists(value_name: str, enum_name: str) -> None: - ui.notify( - f"Enum' {enum_name}' already has value '{value_name}'.", - type="negative", - ) - - -def notify_something_went_wrong() -> None: - ui.notify( - "Something went wrong...", - type="warning", - ) diff --git a/fastapi_forge/frontend/panels/__init__.py b/fastapi_forge/frontend/panels/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/fastapi_forge/frontend/panels/enum_editor_panel.py b/fastapi_forge/frontend/panels/enum_editor_panel.py deleted file mode 100644 index f1c562d..0000000 --- a/fastapi_forge/frontend/panels/enum_editor_panel.py +++ /dev/null @@ -1,166 +0,0 @@ -from typing import Any - -from nicegui import ui -from pydantic import ValidationError - -from fastapi_forge.frontend import validation -from fastapi_forge.frontend.constants import ENUM_COLUMNS -from fastapi_forge.frontend.modals import ( - AddEnumValueModal, - UpdateEnumValueModal, -) -from fastapi_forge.frontend.notifications import ( - notify_enum_value_exists, - notify_validation_error, -) -from fastapi_forge.frontend.state import state -from fastapi_forge.schemas import CustomEnum, CustomEnumValue - - -class EnumEditorPanel(ui.card): - def __init__(self): - super().__init__() - self.visible = False - - state.select_enum_fn = self.set_selected_enum - state.deselect_enum_fn = self._handle_deselect_enum - - self.add_value_modal = AddEnumValueModal( - on_add_value=self._handle_modal_add_value - ) - self.update_value_modal = UpdateEnumValueModal( - on_update_value=self._handle_update_value - ) - - self._build() - - def _show_code_preview(self) -> None: - if state.selected_enum: - with ( - ui.dialog() as modal, - ui.card().classes("no-shadow border-[1px]"), - ): - ui.code(state.selected_enum.class_definition).classes("w-full") - modal.open() - - def _build(self) -> None: - with self: - with ui.row().classes("w-full justify-between items-center"): - with ui.row().classes("gap-4 items-center"): - self.enum_name_display = ui.label().classes("text-lg font-bold") - ui.button( - icon="visibility", - on_click=self._show_code_preview, - ).tooltip("Preview Python enum code") - - ui.button( - icon="add", on_click=lambda: self.add_value_modal.open() - ).classes("self-end").tooltip("Add Value") - - with ui.expansion("Values", value=True).classes("w-full"): - self.table = ui.table( - columns=ENUM_COLUMNS, - rows=[], - row_key="name", - selection="single", - on_select=lambda e: self._on_select_value(e.selection), - ).classes("w-full no-shadow border-[1px]") - - with ui.row().classes("w-full justify-end gap-2"): - ui.button( - icon="edit", - on_click=lambda: self.update_value_modal.open( - state.selected_enum_value, - ), - ).bind_visibility_from(state, "selected_enum_value") - ui.button( - icon="delete", on_click=self._handle_delete_enum_value - ).bind_visibility_from(state, "selected_enum_value") - - def refresh(self) -> None: - if state.selected_enum is None: - return - self.table.rows = [value.model_dump() for value in state.selected_enum.values] - self.add_value_modal.close() - - def set_selected_enum(self, enum: CustomEnum) -> None: - self.refresh() - self.enum_name_display.text = enum.name - self.visible = True - - def _on_select_value(self, selection: list[dict[str, Any]]) -> None: - if not state.selected_enum or not selection: - return - - name = selection[0].get("name") - state.selected_enum_value = next( - ( - enum_value - for enum_value in state.selected_enum.values - if enum_value.name == name - ), - None, - ) - - def _handle_delete_enum_value(self) -> None: - if state.selected_enum is None or state.selected_enum_value is None: - return - state.selected_enum.values.remove(state.selected_enum_value) - self.refresh() - - def _enum_value_exists(self, value_name: str) -> bool: - return any( - enum_value.name == value_name for enum_value in state.selected_enum.values - ) - - def _handle_modal_add_value(self, *, name: str, value: str) -> None: - if state.selected_enum is None: - return - - try: - validation.raise_if_missing_fields([("Name", name), ("Value", value)]) - except ValueError as exc: - raise exc - - if self._enum_value_exists(name): - notify_enum_value_exists(name, state.selected_enum.name) - return - - try: - enum_value_input = CustomEnumValue( - name=name, - value=value, - ) - - state.selected_enum.values.append(enum_value_input) - self.refresh() - except ValidationError as exc: - notify_validation_error(exc) - - def _handle_update_value(self, *, name: str, value: str) -> None: - if state.selected_enum is None or state.selected_enum_value is None: - return - - try: - validation.raise_if_missing_fields([("Name", name), ("Value", value)]) - except ValueError as exc: - raise exc - - if self._enum_value_exists(name): - notify_enum_value_exists(name, state.selected_enum.name) - return - - try: - enum_value_input = CustomEnumValue( - name=name, - value=value, - ) - - enum_index = state.selected_enum.values.index(state.selected_enum_value) - state.selected_enum.values[enum_index] = enum_value_input - self.refresh() - except ValidationError as exc: - notify_validation_error(exc) - - def _handle_deselect_enum(self) -> None: - self.visible = False diff --git a/fastapi_forge/frontend/panels/item_editor_panel.py b/fastapi_forge/frontend/panels/item_editor_panel.py deleted file mode 100644 index ea463c5..0000000 --- a/fastapi_forge/frontend/panels/item_editor_panel.py +++ /dev/null @@ -1,26 +0,0 @@ -from nicegui import ui - -from fastapi_forge.frontend.panels.enum_editor_panel import EnumEditorPanel -from fastapi_forge.frontend.panels.model_editor_panel import ModelEditorPanel -from fastapi_forge.frontend.state import state - - -class ItemEditorPanel: - def __init__(self): - self._build() - state.display_item_editor_fn = self._display_item_editor_panel - - def _build(self) -> None: - self._display_item_editor_panel() - - @ui.refreshable - def _display_item_editor_panel(self) -> None: - with ui.column().classes("w-full h-full items-center justify-center mt-4"): - if state.show_models: - ModelEditorPanel().classes( - "shadow-2xl dark:shadow-none min-w-[700px] max-w-[800px]" - ) - if state.show_enums: - EnumEditorPanel().classes( - "shadow-2xl dark:shadow-none min-w-[500px] max-w-[600px]" - ) diff --git a/fastapi_forge/frontend/panels/left_panel.py b/fastapi_forge/frontend/panels/left_panel.py deleted file mode 100644 index ad8b468..0000000 --- a/fastapi_forge/frontend/panels/left_panel.py +++ /dev/null @@ -1,125 +0,0 @@ -from pathlib import Path - -from nicegui import ui -from pydantic import ValidationError - -from fastapi_forge.frontend.components.item_create import EnumCreate, ModelCreate -from fastapi_forge.frontend.components.item_row import EnumRow, ModelRow -from fastapi_forge.frontend.constants import ( - SELECTED_ENUM_TEXT_COLOR, - SELECTED_MODEL_TEXT_COLOR, -) -from fastapi_forge.frontend.notifications import notify_validation_error -from fastapi_forge.frontend.state import state -from fastapi_forge.project_io import create_yaml_project_exporter - - -class NavigationTabs(ui.row): - def __init__(self): - super().__init__() - self._build() - - def _build(self) -> None: - with self.classes("w-full"): - self.toggle = ( - ui.toggle( - {"models": "Models", "enums": "Enums"}, - value="models", - on_change=self._on_toggle_change, - ) - .props("rounded spread") - .classes("w-full") - ) - - def _on_toggle_change(self) -> None: - if self.toggle.value == "models": - state.switch_item_editor(show_models=True) - else: - state.switch_item_editor(show_enums=True) - - -class ExportButton: - def __init__(self): - self._build() - - def _build(self) -> None: - ui.button( - "Export", - on_click=self._export_project, - icon="file_download", - ).classes("w-full py-3 text-lg font-bold").tooltip( - "Generates a YAML file containing the project configuration.", - ) - - async def _export_project(self) -> None: - """Export the project configuration to a YAML file.""" - try: - spec = state.get_project_spec() - exporter = create_yaml_project_exporter() - await exporter.export_project(spec) - ui.notify( - "Project configuration exported to " - f"{Path.cwd() / spec.project_name}.yaml", - type="positive", - ) - except ValidationError as exc: - notify_validation_error(exc) - except FileNotFoundError as exc: - ui.notify(f"File not found: {exc}", type="negative") - except Exception as exc: - ui.notify(f"An unexpected error occurred: {exc}", type="negative") - - -class LeftPanel(ui.left_drawer): - def __init__(self): - super().__init__(value=True, elevated=False, bottom_corner=True) - - state.render_content_fn = self._render_content - - self._build() - - def _build(self) -> None: - self.clear() - with self, ui.column().classes("items-align content-start w-full"): - NavigationTabs() - - self._render_content() - - ExportButton() - - @ui.refreshable - def _render_content(self) -> None: - with ui.column(): - EnumCreate() if state.show_enums else ModelCreate() - - self.content_list = ui.column().classes("items-align content-start w-full") - - if state.show_models: - self._render_models_list() - elif state.show_enums: - self._render_enums_list() - - def _render_models_list(self) -> None: - with self.content_list: - for model in state.models: - ModelRow( - model, - color=( - SELECTED_MODEL_TEXT_COLOR - if model == state.selected_model - else None - ), - icon="security" if model.metadata.is_auth_model else None, - ) - - def _render_enums_list(self) -> None: - with self.content_list: - for custom_enum in state.custom_enums: - EnumRow( - custom_enum, - color=( - SELECTED_ENUM_TEXT_COLOR - if custom_enum == state.selected_enum - else None - ), - ) diff --git a/fastapi_forge/frontend/panels/model_editor_panel.py b/fastapi_forge/frontend/panels/model_editor_panel.py deleted file mode 100644 index a386e7c..0000000 --- a/fastapi_forge/frontend/panels/model_editor_panel.py +++ /dev/null @@ -1,681 +0,0 @@ -from typing import Any - -from nicegui import ui -from pydantic import ValidationError - -from fastapi_forge.enums import FieldDataTypeEnum, OnDeleteEnum -from fastapi_forge.frontend import validation -from fastapi_forge.frontend.constants import ( - DEFAULT_AUTH_USER_FIELDS, - FIELD_COLUMNS, - RELATIONSHIP_COLUMNS, -) -from fastapi_forge.frontend.modals import ( - AddFieldModal, - AddRelationModal, - UpdateFieldModal, - UpdateRelationModal, -) -from fastapi_forge.frontend.notifications import ( - notify_field_exists, - notify_validation_error, - notify_value_error, -) -from fastapi_forge.frontend.state import state -from fastapi_forge.schemas import ( - Model, - ModelField, - ModelFieldMetadata, - ModelRelationship, -) - - -class ModelEditorPanel(ui.card): - def __init__(self): - super().__init__() - self.visible = False - - state.select_model_fn = self.set_selected_model - state.deselect_model_fn = self.deselect_model - state.render_model_editor_fn = self.refresh - state.render_actions_fn = self._render_action_group - - self.add_field_modal: AddFieldModal = AddFieldModal( - on_add_field=self._handle_modal_add_field, - ) - self.add_relation_modal: AddRelationModal = AddRelationModal( - on_add_relation=self._handle_modal_add_relation, - ) - self.update_field_modal: UpdateFieldModal = UpdateFieldModal( - on_update_field=self._handle_update_field, - ) - self.update_relation_modal: UpdateRelationModal = UpdateRelationModal( - on_update_relation=self._handle_update_relation, - ) - - self._build() - - def _show_code_preview(self) -> None: - if state.selected_model: - with ( - ui.dialog() as modal, - ui.card().classes("no-shadow border-[1px]"), - ): - model_renderer = state.get_render_manager().get_renderer("model") - code = model_renderer.render(state.selected_model.get_preview()) - code = code.split("class ")[1] - code = f"# ID is inherited from the `Base` class\nclass {code}" - ui.code(code).classes("w-full") - modal.open() - - def _toggle_auth_model(self) -> None: - if not state.selected_model or not state.use_builtin_auth: - return - - if not state.selected_model.metadata.is_auth_model and any( - m.metadata.is_auth_model for m in state.models - ): - ui.notify( - "Cannot have more than one authentication model.", type="negative" - ) - return - - model = state.selected_model - model.metadata.is_auth_model = not model.metadata.is_auth_model - - if not model.metadata.is_auth_model: - self._remove_auth_fields(model) - if state.render_model_editor_fn: - state.render_model_editor_fn() - - if state.render_content_fn: - state.render_content_fn.refresh() - - self._render_action_group.refresh() - - if model.metadata.is_auth_model: - self._setup_auth_model_fields(model) - - def _remove_auth_fields(self, model: Model) -> None: - for field_name in ("email", "password"): - if field := next((f for f in model.fields if f.name == field_name), None): - model.fields.remove(field) - - def _setup_auth_model_fields(self, model: Model) -> None: - self._remove_auth_fields(model) - id_index = 0 - insert_position = id_index + 1 if id_index >= 0 else 0 - for field in reversed(DEFAULT_AUTH_USER_FIELDS): - model.fields.insert(insert_position, field) - - self._refresh_table(model.fields) - - @ui.refreshable - def _render_action_group(self) -> None: - ui.button( - icon="security", - on_click=self._toggle_auth_model, - color=( - "green" - if state.use_builtin_auth - and state.selected_model - and state.selected_model.metadata.is_auth_model - else "grey" - ), - ).tooltip("Authentication model").bind_visibility_from( - state, "use_builtin_auth" - ) - - def _build(self) -> None: - with self: - with ui.row().classes("w-full justify-between items-center"): - with ui.row().classes("gap-4 items-center"): - self.model_name_display = ui.label().classes("text-lg font-bold") - ui.button( - icon="visibility", - on_click=self._show_code_preview, - ).tooltip("Preview SQLAlchemy model code") - - with ui.row().classes("gap-2 items-center"): - self._render_action_group() - with ( - ui.button(icon="menu").tooltip("Generate"), - ui.menu(), - ui.column().classes("gap-0 p-2"), - ): - self.create_endpoints_switch = ui.switch( - "Endpoints", - value=True, - on_change=lambda v: setattr( - state.selected_model.metadata, - "create_endpoints", - v.value, - ), - ) - self.create_tests_switch = ui.switch( - "Tests", - value=True, - on_change=lambda v: setattr( - state.selected_model.metadata, - "create_tests", - v.value, - ), - ) - self.create_daos_switch = ui.switch( - "DAOs", - value=True, - on_change=lambda v: setattr( - state.selected_model.metadata, - "create_daos", - v.value, - ), - ) - self.create_dtos_switch = ui.switch( - "DTOs", - value=True, - on_change=lambda v: setattr( - state.selected_model.metadata, - "create_dtos", - v.value, - ), - ) - - with ( - ui.button(icon="bolt", color="amber") - .classes("self-end") - .tooltip("Quick-Add"), - ui.menu(), - ): - self.primary_key_item = ui.menu_item( - "Primary Key", - on_click=lambda: self._toggle_quick_add( - "id", - is_primary_key=True, - ), - ) - self.created_at_item = ui.menu_item( - "Created At", - on_click=lambda: self._toggle_quick_add( - "created_at", - is_created_at_timestamp=True, - ), - ) - self.updated_at_item = ui.menu_item( - "Updated At", - on_click=lambda: self._toggle_quick_add( - "updated_at", - is_updated_at_timestamp=True, - ), - ) - - with ui.button(icon="add").classes("self-end"), ui.menu(): - ui.menu_item( - "Field", - on_click=lambda: self.add_field_modal.open(), - ) - ui.menu_item( - "Relationship", - on_click=lambda: self.add_relation_modal.open( - models=state.models, - ), - ) - - with ui.expansion("Fields", value=True).classes("w-full"): - self.table = ui.table( - columns=FIELD_COLUMNS, - rows=[], - row_key="name", - selection="single", - on_select=lambda e: self._on_select_field(e.selection), - ).classes("w-full no-shadow border-[1px]") - - with ui.row().classes("w-full justify-end gap-2"): - ui.button( - icon="edit", - on_click=lambda: self.update_field_modal.open( - state.selected_field, - ), - ).bind_visibility_from(state, "selected_field") - ui.button( - icon="delete", - on_click=self._delete_field, - ).bind_visibility_from(state, "selected_field") - - with ui.expansion("Relationships", value=True).classes("w-full"): - self.relationship_table = ui.table( - columns=RELATIONSHIP_COLUMNS, - rows=[], - row_key="field_name", - selection="single", - on_select=lambda e: self._on_select_relation(e.selection), - ).classes("w-full no-shadow border-[1px]") - - with ui.row().classes("w-full justify-end gap-2"): - ui.button( - icon="edit", - on_click=lambda: self.update_relation_modal.open( - state.selected_relation, - state.models, - ), - ).bind_visibility_from(state, "selected_relation") - ui.button( - icon="delete", - on_click=self._delete_relation, - ).bind_visibility_from(state, "selected_relation") - - def _toggle_quick_add( - self, - name: str, - is_primary_key: bool = False, - is_created_at_timestamp: bool = False, - is_updated_at_timestamp: bool = False, - ) -> None: - if not state.selected_model: - return - - if is_primary_key: - existing_pk = next( - (field for field in state.selected_model.fields if field.primary_key), - None, - ) - if existing_pk: - self._delete(existing_pk) - return - - self._add_field( - name=name, - type="UUID", - primary_key=True, - nullable=False, - unique=True, - index=True, - ) - return - - attr = ( - "is_created_at_timestamp" - if is_created_at_timestamp - else "is_updated_at_timestamp" - ) - - existing_quick_add = next( - ( - field - for field in state.selected_model.fields - if getattr(field.metadata, attr) is True - ), - None, - ) - - if existing_quick_add: - self._delete(existing_quick_add) - return - - self._add_field( - name=name, - type="DateTime", - primary_key=False, - nullable=False, - unique=False, - index=False, - default_value="datetime.now(timezone.utc)", - extra_kwargs=( - {"onupdate": "datetime.now(timezone.utc)"} - if is_updated_at_timestamp - else None - ), - metadata=ModelFieldMetadata( - is_created_at_timestamp=is_created_at_timestamp, - is_updated_at_timestamp=is_updated_at_timestamp, - ), - ) - - def _handle_modal_add_field( - self, - **kwargs, - ) -> None: - try: - self._add_field(**kwargs) - self.add_field_modal.close() - except ValueError as exc: - notify_value_error(exc) - - def _handle_modal_add_relation( - self, - field_name: str, - target_model: str, - on_delete: OnDeleteEnum, - nullable: bool, - index: bool, - unique: bool, - back_populates: str | None = None, - ) -> None: - if not state.selected_model: - return - - try: - validation.raise_if_missing_fields( - [ - ("Field Name", field_name), - ("Target Model", target_model), - ("On Delete", on_delete), - ] - ) - except ValueError as exc: - raise exc - - target_model_instance = next( - (model for model in state.models if model.name == target_model), - None, - ) - if not target_model_instance: - ui.notify(f"Model '{target_model}' not found.", type="negative") - return - - if field_name in [field.name for field in state.selected_model.fields]: - ui.notify(f"Field '{field_name}' already exists.", type="negative") - return - - try: - relationship = ModelRelationship( - field_name=field_name, - target_model=target_model_instance.name, - back_populates=back_populates, - nullable=nullable, - index=index, - unique=unique, - on_delete=on_delete, - ) - except ValidationError as exc: - notify_validation_error(exc) - return - - state.selected_model.relationships.append(relationship) - - self._refresh_relationship_table(state.selected_model.relationships) - - def refresh(self) -> None: - if state.selected_model is None: - return - self._refresh_table(state.selected_model.fields) - self._refresh_relationship_table(state.selected_model.relationships) - - def _refresh_table(self, fields: list[ModelField]) -> None: - if state.selected_model is None: - return - self.table.rows = [field.model_dump() for field in fields] - - quick_add_primary_key_enabled = any(field.primary_key for field in fields) - quick_add_created_at_enabled = any( - field.metadata.is_created_at_timestamp for field in fields - ) - quick_add_updated_at_enabled = any( - field.metadata.is_updated_at_timestamp for field in fields - ) - - self.primary_key_item.enabled = not quick_add_primary_key_enabled - self.created_at_item.enabled = not quick_add_created_at_enabled - self.updated_at_item.enabled = not quick_add_updated_at_enabled - - self._deselect_field() - - def _refresh_relationship_table( - self, - relationships: list[ModelRelationship], - ) -> None: - if state.selected_model is None: - return - self.relationship_table.rows = [ - relationship.model_dump() for relationship in relationships - ] - self._deselect_relation() - - def _field_name_exists(self, field_name: str) -> bool: - return any(field.name == field_name for field in state.selected_model.fields) - - def _add_field( - self, - *, - name: str, - type: str, - primary_key: bool, - nullable: bool, - unique: bool, - index: bool, - type_enum: str | None = None, - default_value: str | None = None, - extra_kwargs: dict[str, Any] | None = None, - metadata: ModelFieldMetadata | None = None, - ) -> None: - if state.selected_model is None: - return - - try: - validation.raise_if_missing_fields( - [("Field Name", name), ("Field Type", type)] - ) - except ValueError as exc: - raise exc - - if self._field_name_exists(name): - notify_field_exists(name, state.selected_model.name) - return - - field_type = FieldDataTypeEnum(type) - if type_enum and field_type != FieldDataTypeEnum.ENUM: - type_enum = None - - try: - field_input = ModelField( - name=name, - type=field_type, - type_enum=type_enum, - primary_key=primary_key, - nullable=nullable, - unique=unique, - index=index, - default_value=default_value, - extra_kwargs=extra_kwargs, - ) - - if metadata: - field_input.metadata = metadata - - state.selected_model.fields.append(field_input) - self._refresh_table(state.selected_model.fields) - - except ValidationError as exc: - notify_validation_error(exc) - - def _deselect_field(self) -> None: - state.selected_field = None - self.table.selected = [] - - def _deselect_relation(self) -> None: - state.selected_relation = None - self.relationship_table.selected = [] - - def _on_select_field(self, selection: list[dict[str, Any]]) -> None: - if not state.selected_model or not selection: - self._deselect_field() - return - - name = selection[0].get("name") - - if ( - state.selected_model.metadata.is_auth_model - and state.use_builtin_auth - and name in ("password", "email") - ): - ui.notify( - f"Cannot edit {name} field in authentication model.", - type="warning", - ) - self._deselect_field() - return - - state.selected_field = next( - (field for field in state.selected_model.fields if field.name == name), None - ) - - def _on_select_relation(self, selection: list[dict[str, Any]]) -> None: - if not state.selected_model: - return - if not selection: - self._deselect_relation() - return - state.selected_relation = next( - ( - relation - for relation in state.selected_model.relationships - if relation.field_name == selection[0]["field_name"] - ), - None, - ) - - def _handle_update_field( - self, - name: str, - type: str, - primary_key: bool, - nullable: bool, - unique: bool, - index: bool, - metadata: ModelFieldMetadata, - type_enum: str | None = None, - default_value: str | None = None, - extra_kwargs: dict[str, Any] | None = None, - ) -> None: - if not state.selected_model or not state.selected_field: - return - - if ( - state.selected_model - and state.selected_model.metadata.is_auth_model - and state.use_builtin_auth - ): - return - - try: - validation.raise_if_missing_fields( - [("Field Name", name), ("Field Type", type)] - ) - except ValueError as exc: - raise exc - - if state.selected_field.name != name and self._field_name_exists(name): - notify_field_exists(name, state.selected_model.name) - return - - field_type = FieldDataTypeEnum(type) - if type_enum and field_type != FieldDataTypeEnum.ENUM: - type_enum = None - - try: - field_input = ModelField( - name=name, - type=field_type, - type_enum=type_enum, - primary_key=primary_key, - nullable=nullable, - unique=unique, - index=index, - default_value=default_value, - extra_kwargs=extra_kwargs, - metadata=metadata, - ) - - model_index = state.selected_model.fields.index(state.selected_field) - state.selected_model.fields[model_index] = field_input - self._refresh_table(state.selected_model.fields) - - except ValidationError as exc: - notify_validation_error(exc) - - def _handle_update_relation( - self, - field_name: str, - target_model: str, - nullable: bool = False, - index: bool = False, - unique: bool = False, - on_delete: OnDeleteEnum = OnDeleteEnum.CASCADE, - back_populates: str | None = None, - ) -> None: - if not state.selected_model or not state.selected_relation: - return - - try: - validation.raise_if_missing_fields( - [ - ("Field Name", field_name), - ("Target Model", target_model), - ("On Delete", on_delete), - ] - ) - except ValueError as exc: - raise exc - - target_model_instance = next( - (model for model in state.models if model.name == target_model), - None, - ) - if not target_model_instance: - ui.notify(f"Model '{target_model}' not found.", type="negative") - return - - if ( - field_name in [field.name for field in state.selected_model.fields] - and field_name != state.selected_relation.field_name - ): - ui.notify(f"Field '{field_name}' already exists.", type="negative") - return - try: - relationship = ModelRelationship( - field_name=field_name, - target_model=target_model_instance.name, - back_populates=back_populates or None, - nullable=nullable, - index=index, - unique=unique, - on_delete=on_delete, - ) - except ValidationError as exc: - notify_validation_error(exc) - return - - model_index = state.selected_model.relationships.index(state.selected_relation) - state.selected_model.relationships[model_index] = relationship - self._refresh_relationship_table(state.selected_model.relationships) - - def _delete(self, field: ModelField) -> None: - if not state.selected_model: - ui.notify("No model selected.", type="negative") - return - state.selected_model.fields.remove(field) - self._refresh_table(state.selected_model.fields) - - def _delete_relation(self) -> None: - if state.selected_model and state.selected_relation: - state.selected_model.relationships.remove(state.selected_relation) - self._refresh_relationship_table(state.selected_model.relationships) - - def _delete_field(self) -> None: - if state.selected_model and state.selected_field: - self._delete(state.selected_field) - - def set_selected_model(self, model: Model) -> None: - self.model_name_display.text = model.name - metadata = model.metadata - - self.create_endpoints_switch.value = metadata.create_endpoints - self.create_tests_switch.value = metadata.create_tests - self.create_dtos_switch.value = metadata.create_dtos - self.create_daos_switch.value = metadata.create_daos - - self._refresh_table(model.fields) - self._refresh_relationship_table(model.relationships) - self.visible = True - - def deselect_model(self) -> None: - self.visible = False diff --git a/fastapi_forge/frontend/panels/project_config_panel.py b/fastapi_forge/frontend/panels/project_config_panel.py deleted file mode 100644 index 078e2b5..0000000 --- a/fastapi_forge/frontend/panels/project_config_panel.py +++ /dev/null @@ -1,449 +0,0 @@ -from pathlib import Path - -from nicegui import ui -from nicegui.events import ValueChangeEventArguments -from pydantic import ValidationError - -from fastapi_forge.core.build import build_fastapi_project -from fastapi_forge.enums import FieldDataTypeEnum -from fastapi_forge.frontend.constants import ( - DEFAULT_AUTH_USER_FIELDS, - DEFAULT_AUTH_USER_ROLE_ENUM_NAME, -) -from fastapi_forge.frontend.notifications import ( - notify_validation_error, - notify_value_error, -) -from fastapi_forge.frontend.state import state -from fastapi_forge.project_io import create_postgres_project_loader -from fastapi_forge.schemas import ( - CustomEnum, - CustomEnumValue, - Model, - ModelField, - ModelFieldMetadata, - ModelMetadata, -) -from fastapi_forge.type_info_registry import enum_registry - - -class ProjectConfigPanel(ui.right_drawer): - def __init__(self): - super().__init__(value=True, elevated=False, bottom_corner=True) - self._build() - self._bind_state_to_ui() - self._update_taskiq_state() - - async def _confirm_upload(self) -> bool: - dialog = ui.dialog() - with dialog, ui.card().classes("w-full max-w-md p-6 text-center"): - ui.icon("warning", color="orange-500").classes("text-4xl self-center") - ui.markdown( - "⚠️ Current project configuration will be overwritten!\n\n" - "It is **highly recommended** to export the current project before proceeding." - ).classes("text-center mb-4") - - confirm_checkbox = ui.checkbox("I understand and want to proceed") - - with ui.row().classes("w-full justify-center gap-4 mt-4"): - ui.button("Cancel", color="primary", on_click=dialog.close) - ui.button( - "Continue", - color="negative", - on_click=lambda: dialog.submit(confirm_checkbox.value), - ).bind_enabled_from(confirm_checkbox, "value") - - return await dialog - - async def _load_from_conn_string(self, conn_string: str) -> None: - try: - confirmed = await self._confirm_upload() - if not confirmed: - ui.notify("Upload cancelled", type="warning") - return - - enum_registry.clear() - postgres_loader = create_postgres_project_loader(conn_string=conn_string) - project_spec = postgres_loader.load() - - state.initialize_from_project(project_spec) - self.upload_menu.close() - ui.notify( - "Successfully loaded project configuration from connection string!", - type="positive", - ) - - except ValueError as exc: - notify_value_error(exc) - except Exception as exc: - ui.notify(f"Error loading project: {exc!s}", type="negative") - - def _build(self) -> None: - with ( - self, - ui.column().classes( - "items-align content-start w-full gap-4", - ) as self.column, - ): - with ui.column().classes("w-full gap-2"): - with ui.row().classes("w-full justify-between items-center"): - ui.label("Project Name").classes("text-lg font-bold") - - with ( - ui.menu() as self.upload_menu, - ui.column().classes("p-2 w-full"), - ui.row().classes("items-center w-full gap-2"), - ): - text_input = ui.input( - "Postgres connection string", - placeholder="postgresql://user:pass@host/database", - ).classes("flex-grow w-[250px]") - ui.button( - "Upload", - on_click=lambda: self._load_from_conn_string( - text_input.value - ), - ).props("unelevated") - - ui.button(icon="upload", on_click=self.upload_menu.open).props( - "round" - ).tooltip("Upload from database") - - self.project_name = ( - ui.input( - placeholder="Project Name", - value=state.project_name, - ) - .classes("w-full") - .bind_value_from(state, "project_name") - ) - - with ui.column().classes("w-full gap-2"): - ui.label("Database").classes("text-lg font-bold") - self.use_postgres = ( - ui.checkbox( - "Postgres", - value=state.use_postgres, - ) - .classes("w-full") - .bind_value_from(state, "use_postgres") - ) - - self.use_mysql = ( - ui.checkbox("MySQL") - .classes("w-full") - .tooltip("Coming soon!") - .set_enabled(False) - ) - - with ui.column().classes("w-full gap-2"): - ui.label("Migrations").classes("text-lg font-bold") - self.use_alembic = ( - ui.checkbox("Alembic", value=state.use_alembic) - .classes("w-full") - .bind_enabled_from(self.use_postgres, "value") - .bind_value_from(state, "use_alembic") - ) - - with ui.column().classes("w-full gap-2"): - ui.label("Authentication").classes("text-lg font-bold") - self.use_builtin_auth = ( - ui.checkbox( - "JWT Auth", - value=state.use_builtin_auth, - on_change=self._handle_builtin_auth_change, - ) - .tooltip( - "Authentication is built in the API itself, using JWT.", - ) - .classes("w-full") - .bind_enabled_from(self.use_postgres, "value") - .bind_value_from(state, "use_builtin_auth") - ) - - with ui.column().classes("w-full gap-2"): - ui.label("Messaging").classes("text-lg font-bold") - self.use_rabbitmq = ( - ui.checkbox( - "RabbitMQ", - value=state.use_rabbitmq, - on_change=self._update_taskiq_state, - ) - .classes("w-full") - .bind_value_from(state, "use_rabbitmq") - ) - - with ui.column().classes("w-full gap-2"): - ui.label("Task Queues").classes("text-lg font-bold") - self.use_taskiq = ( - ui.checkbox( - "Taskiq", - value=state.use_taskiq, - on_change=self._update_taskiq_state, - ) - .classes("w-full") - .tooltip("Requires Redis and RabbitMQ to be enabled.") - .bind_value_from(state, "use_taskiq") - ) - - self.use_celery = ( - ui.checkbox("Celery") - .classes("w-full") - .tooltip("Coming soon!") - .set_enabled(False) - ) - - with ui.column().classes("w-full gap-2"): - ui.label("Caching").classes("text-lg font-bold") - self.use_redis = ( - ui.checkbox( - "Redis", - value=state.use_redis, - on_change=self._update_taskiq_state, - ) - .classes("w-full") - .bind_value_from(state, "use_redis") - ) - - with ui.column().classes("w-full gap-2"): - ui.label("Observability").classes("text-lg font-bold") - self.use_prometheus = ( - ui.checkbox("Prometheus", value=state.use_prometheus) - .classes("w-full") - .bind_value_from(state, "use_prometheus") - .tooltip("Collect and query metrics with Prometheus") - ) - self.use_logfire = ( - ui.checkbox("Logfire", value=state.use_logfire) - .classes("w-full") - .bind_value_from(state, "use_logfire") - ) - - with ui.column().classes("w-full gap-2"): - ui.label("Object Storage").classes("text-lg font-bold") - self.use_elasticsearch = ( - ui.checkbox("S3") - .classes("w-full") - .tooltip("Coming soon!") - .set_enabled(False) - ) - - with ui.column().classes("w-full gap-2"): - self.loading_spinner = ui.spinner(size="lg").classes( - "hidden mt-4 self-center", - ) - self.create_button = ui.button( - "Generate", - icon="rocket", - on_click=self._create_project, - ).classes("w-full py-3 text-lg font-bold mt-4") - - def _bind_state_to_ui(self) -> None: - """Bind UI elements to state variables""" - self.project_name.bind_value_to(state, "project_name") - self.use_postgres.bind_value_to(state, "use_postgres") - self.use_alembic.bind_value_to(state, "use_alembic") - self.use_builtin_auth.bind_value_to(state, "use_builtin_auth") - self.use_rabbitmq.bind_value_to(state, "use_rabbitmq").on( - "change", self._update_taskiq_state - ) - self.use_redis.bind_value_to(state, "use_redis").on( - "change", self._update_taskiq_state - ) - self.use_taskiq.bind_value_to(state, "use_taskiq") - self.use_prometheus.bind_value_to(state, "use_prometheus") - self.use_logfire.bind_value_to(state, "use_logfire") - - def _update_taskiq_state(self, *_) -> None: - """Enable or disable Taskiq based on Redis and RabbitMQ.""" - self.use_taskiq.set_enabled(self.use_redis.value and self.use_rabbitmq.value) - if ( - not (self.use_redis.value and self.use_rabbitmq.value) - and self.use_taskiq.value - ): - self.use_taskiq.value = False - state.use_taskiq = False - - async def _auth_dialog(self) -> bool: - dialog = ui.dialog() - with dialog, ui.card().classes("w-full max-w-md p-6 text-center"): - ui.icon("security", color="green-500").classes("text-4xl self-center") - ui.markdown( - "JWT Auth needs a model with 'email' and 'password' fields. " - "Select any model as auth model later, or create one now (optional)." - ).classes("text-center") - - create_model_checkbox = ui.checkbox( - "Create default 'auth_user' model", value=True - ).classes("w-full mt-4") - - with ui.row().classes("w-full justify-center gap-4 mt-4"): - ui.button("Close", color="negative", on_click=dialog.close) - ui.button( - "Proceed", - color="primary", - on_click=lambda: dialog.submit(create_model_checkbox.value), - ) - - return await dialog - - async def _handle_builtin_auth_change( - self, event: ValueChangeEventArguments - ) -> None: - """Handle JWT Auth checkbox changes""" - enabled = event.value - state.use_builtin_auth = enabled - - if not enabled: - auth_model = state.get_auth_model() - if not auth_model: - return - auth_model.metadata.is_auth_model = False - if state.render_content_fn: - state.render_content_fn.refresh() - if state.render_actions_fn: - state.render_actions_fn.refresh() - return - - proceed = await self._auth_dialog() - - if not proceed: - return - - if any(model.name == "auth_user" for model in state.models): - ui.notify("The 'auth_user' model already exists.", type="negative") - self.use_builtin_auth.value = False - state.use_builtin_auth = False - return - - try: - auth_role_enum_name = DEFAULT_AUTH_USER_ROLE_ENUM_NAME - auth_enum = state.get_enum_by_name(auth_role_enum_name) - - auth_enum_values = [ - CustomEnumValue(name="USER", value="auto()"), - CustomEnumValue(name="ADMIN", value="auto()"), - ] - if auth_enum and not auth_enum.values: - auth_enum.values = auth_enum_values - - if not auth_enum: - auth_enum = CustomEnum( - name=auth_role_enum_name, - values=auth_enum_values, - ) - state.custom_enums.append(auth_enum) - - auth_user_model = Model( - name="auth_user", - metadata=ModelMetadata(is_auth_model=True), - fields=[ - ModelField( - name="id", - type=FieldDataTypeEnum.UUID, - primary_key=True, - unique=True, - index=True, - ), - *DEFAULT_AUTH_USER_FIELDS, - ModelField( - name="role", - type=FieldDataTypeEnum.ENUM, - type_enum=auth_role_enum_name, - default_value=auth_enum.values[0].name, - ), - ModelField( - name="created_at", - type=FieldDataTypeEnum.DATETIME, - default_value="datetime.now(timezone.utc)", - metadata=ModelFieldMetadata(is_created_at_timestamp=True), - ), - ModelField( - name="updated_at", - type=FieldDataTypeEnum.DATETIME, - default_value="datetime.now(timezone.utc)", - extra_kwargs={"onupdate": "datetime.now(timezone.utc)"}, - metadata=ModelFieldMetadata(is_updated_at_timestamp=True), - ), - ], - ) - state.models.append(auth_user_model) - if state.render_content_fn: - state.render_content_fn.refresh() - ui.notify("The 'auth_user' model has been created.", type="positive") - except ValidationError as exc: - notify_validation_error(exc) - - async def _warn_overwrite(self) -> bool: - """Show a confirmation dialog if the project already exists.""" - dialog = ui.dialog() - with dialog, ui.card().classes("w-full max-w-md p-6 text-center"): - ui.icon("warning", color="orange-500").classes("text-4xl self-center") - ui.markdown( - f"Project '{state.project_name}' already exists!\n\n" - "This will **permanently overwrite** the existing project directory.\n" - "Are you sure you want to continue?" - ).classes("text-center") - - with ui.row().classes("w-full justify-center gap-4 mt-4"): - ui.button("Cancel", color="primary", on_click=dialog.close) - ui.button( - "Overwrite", color="negative", on_click=lambda: dialog.submit(True) - ) - - return await dialog - - async def _create_project(self) -> None: - """Generate the project based on the current state.""" - if not state.project_name: - ui.notify("Missing project name!", type="negative") - return - - if not state.models: - ui.notify("No models to generate!", type="negative") - return - - project_path = Path(state.project_name) - - if project_path.exists(): - try: - overwrite = await self._warn_overwrite() - if not overwrite: - ui.notify("Project generation cancelled.", type="warning") - return - except Exception as e: - ui.notify(f"Error displaying confirmation: {e}", type="negative") - return - - self.create_button.classes("hidden") - self.loading_spinner.classes(remove="hidden") - ongoing_notification = ui.notification("Generating project...") - - try: - state.project_name = self.project_name.value - state.use_postgres = self.use_postgres.value - state.use_alembic = self.use_alembic.value - state.use_builtin_auth = self.use_builtin_auth.value - state.use_redis = self.use_redis.value - state.use_rabbitmq = self.use_rabbitmq.value - state.use_taskiq = self.use_taskiq.value - state.use_prometheus = self.use_prometheus.value - state.use_logfire = self.use_logfire.value - - project_spec = state.get_project_spec() - await build_fastapi_project(project_spec) - - ui.notify( - "Project successfully generated at: " - f"{Path.cwd() / project_spec.project_name}", - type="positive", - ) - - except ValidationError as exc: - notify_validation_error(exc) - except Exception as exc: - ui.notify(f"Error creating Project: {exc}", type="negative") - finally: - self.create_button.classes(remove="hidden") - self.loading_spinner.classes("hidden") - ongoing_notification.dismiss() diff --git a/fastapi_forge/frontend/state.py b/fastapi_forge/frontend/state.py deleted file mode 100644 index 8d04815..0000000 --- a/fastapi_forge/frontend/state.py +++ /dev/null @@ -1,289 +0,0 @@ -from collections.abc import Callable - -from pydantic import BaseModel, ValidationError - -from fastapi_forge.enums import FieldDataTypeEnum -from fastapi_forge.frontend.notifications import ( - notify_enum_exists, - notify_model_exists, - notify_something_went_wrong, - notify_validation_error, -) -from fastapi_forge.render import create_jinja_render_manager -from fastapi_forge.render.manager import RenderManager -from fastapi_forge.schemas import ( - CustomEnum, - CustomEnumValue, - Model, - ModelField, - ModelRelationship, - ProjectSpec, -) -from fastapi_forge.type_info_registry import enum_registry - - -class ProjectState(BaseModel): - """Central state management for the project configuration.""" - - models: list[Model] = [] - selected_model: Model | None = None - selected_field: ModelField | None = None - selected_relation: ModelRelationship | None = None - - custom_enums: list[CustomEnum] = [] - selected_enum: CustomEnum | None = None - selected_enum_value: CustomEnumValue | None = None - select_enum_fn: Callable[[CustomEnum], None] | None = None - deselect_enum_fn: Callable | None = None - - render_model_editor_fn: Callable | None = None - render_actions_fn: Callable | None = None - select_model_fn: Callable[[Model], None] | None = None - deselect_model_fn: Callable | None = None - - render_content_fn: Callable | None = None - display_item_editor_fn: Callable | None = None - - show_models: bool = True - show_enums: bool = False - - project_name: str = "" - use_postgres: bool = False - use_alembic: bool = False - use_builtin_auth: bool = False - use_redis: bool = False - use_rabbitmq: bool = False - use_taskiq: bool = False - use_prometheus: bool = False - use_logfire: bool = False - - def get_render_manager(self) -> RenderManager: - """Get the render manager for the current project.""" - return create_jinja_render_manager(project_name=self.project_name) - - def switch_item_editor( - self, - show_models: bool = False, - show_enums: bool = False, - ) -> None: - if sum([show_models, show_enums]) != 1: - msg = "One flag has to be True." - raise ValueError(msg) - - self.show_models = show_models - self.show_enums = show_enums - - if self.render_content_fn: - self.render_content_fn.refresh() - - self._deselect_content() - self.display_item_editor_fn.refresh() - - def initialize_from_project(self, project: ProjectSpec) -> None: - """Initialize state from an existing project specification.""" - self.project_name = project.project_name - self.use_postgres = project.use_postgres - self.use_alembic = project.use_alembic - self.use_builtin_auth = project.use_builtin_auth - self.use_redis = project.use_redis - self.use_rabbitmq = project.use_rabbitmq - self.use_taskiq = project.use_taskiq - self.use_prometheus = project.use_prometheus - self.use_logfire = project.use_logfire - self.models = project.models.copy() - self.custom_enums = project.custom_enums.copy() - - self._trigger_ui_refresh() - - def add_model(self, model_name: str) -> None: - """Add a new model to the project.""" - if self._model_exists(model_name): - notify_model_exists(model_name) - return - - try: - self.models.append(self._create_default_model(model_name)) - self._trigger_ui_refresh() - except ValidationError as exc: - notify_validation_error(exc) - - def delete_model(self, model: Model) -> None: - """Remove a model from the project.""" - if not self._validate_model_operation(model): - return - - self.models.remove(model) - self._cleanup_relationships_for_deleted_model(model.name) - self._deselect_content() - self._trigger_ui_refresh() - - def update_model_name(self, model: Model, new_name: str) -> None: - """Rename an existing model.""" - if model.name == new_name: - return - - if self._model_exists(new_name, exclude=model): - notify_model_exists(new_name) - return - - old_name = model.name - model.name = new_name - self._update_relationships_for_rename(old_name, new_name) - - if model == self.selected_model and self.select_model_fn: - self.select_model_fn(model) - - self._trigger_ui_refresh() - - def select_model(self, model: Model) -> None: - """Set the currently selected model.""" - if self.selected_model == model: - return - - self.selected_model = model - self.select_model_fn(model) # type: ignore - self._trigger_ui_refresh() - - def _enum_exists(self, name: str, exclude: CustomEnum | None = None) -> bool: - return any( - enum.name.lower() == name.lower() - for enum in self.custom_enums - if enum != exclude - ) - - def add_enum(self, enum_name: str) -> None: - if self._enum_exists(enum_name): - notify_enum_exists(enum_name) - return - - try: - self.custom_enums.append(CustomEnum(name=enum_name)) - self._trigger_ui_refresh() - except ValidationError as exc: - notify_validation_error(exc) - - def select_enum(self, enum: CustomEnum) -> None: - if self.selected_enum == enum: - return - - self.selected_enum = enum - self.select_enum_fn(enum) # type: ignore - self._trigger_ui_refresh() - - def delete_enum(self, enum: CustomEnum) -> None: - """Remove an enum from the project.""" - self.custom_enums.remove(enum) - enum_registry.remove(enum.name) - self._deselect_content() - self._trigger_ui_refresh() - - def update_enum_name(self, enum: CustomEnum, new_name: str) -> None: - """Rename an existing enum.""" - if enum.name == new_name: - return - - if self._enum_exists(new_name, exclude=enum): - notify_enum_exists(new_name) - return - - enum_registry.update_key(enum.name, new_name) - enum.name = new_name - - if enum == self.selected_enum and self.select_enum_fn: - self.select_enum_fn(enum) - - self._trigger_ui_refresh() - - def get_project_spec(self) -> ProjectSpec: - """Generate a ProjectSpec from the current state.""" - return ProjectSpec( - project_name=self.project_name, - use_postgres=self.use_postgres, - use_alembic=self.use_alembic, - use_builtin_auth=self.use_builtin_auth, - use_redis=self.use_redis, - use_rabbitmq=self.use_rabbitmq, - use_taskiq=self.use_taskiq, - use_prometheus=self.use_prometheus, - use_logfire=self.use_logfire, - models=self.models, - custom_enums=self.custom_enums, - ) - - def _create_default_model(self, name: str) -> Model: - """Create a new model with default fields.""" - return Model( - name=name, - fields=[ - ModelField( - name="id", - type=FieldDataTypeEnum.UUID, - primary_key=True, - nullable=False, - unique=True, - index=True, - ) - ], - ) - - def _cleanup_relationships_for_deleted_model(self, deleted_model_name: str) -> None: - """Remove relationships pointing to deleted models.""" - for model in self.models: - model.relationships = [ - rel - for rel in model.relationships - if rel.target_model != deleted_model_name - ] - - def _update_relationships_for_rename(self, old_name: str, new_name: str) -> None: - """Update relationships when a model is renamed.""" - for model in self.models: - for relationship in model.relationships: - if relationship.target_model == old_name: - relationship.target_model = new_name - - def _model_exists(self, name: str, exclude: Model | None = None) -> bool: - """Check if a model with the given name already exists.""" - return any(model.name == name for model in self.models if model != exclude) - - def _validate_model_operation(self, model: Model) -> bool: - """Validate conditions for model operations.""" - if model not in self.models or not all( - [self.deselect_model_fn, self.render_content_fn] - ): - notify_something_went_wrong() - return False - return True - - def _deselect_content(self) -> None: - if self.deselect_model_fn: - self.deselect_model_fn() - if self.deselect_enum_fn: - self.deselect_enum_fn() - - self.selected_model = None - self.selected_enum = None - - def _trigger_ui_refresh(self) -> None: - """Refresh all relevant UI components.""" - if self.render_content_fn: - self.render_content_fn.refresh() - if self.render_model_editor_fn: - self.render_model_editor_fn() - if self.render_actions_fn: - self.render_actions_fn.refresh() - - def get_auth_model(self) -> Model | None: - return next( - (model for model in self.models if model.metadata.is_auth_model), - None, - ) - - def get_enum_by_name(self, name: str) -> CustomEnum | None: - return next( - (enum for enum in self.custom_enums if enum.name == name), - None, - ) - - -state: ProjectState = ProjectState() diff --git a/fastapi_forge/frontend/validation.py b/fastapi_forge/frontend/validation.py deleted file mode 100644 index e618241..0000000 --- a/fastapi_forge/frontend/validation.py +++ /dev/null @@ -1,5 +0,0 @@ -def raise_if_missing_fields(required: list[tuple[str, str | None]]): - missing_required = [field_name for field_name, kwarg in required if not kwarg] - if missing_required: - msg = f"Missing fields: {', '.join(missing_required)}." - raise ValueError(msg) diff --git a/frontend/README.md b/frontend/README.md index 02b06e3..e69de29 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,39 +0,0 @@ -# frontend - -This template should help get you started developing with Vue 3 in Vite. - -## Recommended IDE Setup - -[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur). - -## Type Support for `.vue` Imports in TS - -TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types. - -## Customize configuration - -See [Vite Configuration Reference](https://vite.dev/config/). - -## Project Setup - -```sh -npm install -``` - -### Compile and Hot-Reload for Development - -```sh -npm run dev -``` - -### Type-Check, Compile and Minify for Production - -```sh -npm run build -``` - -### Lint with [ESLint](https://eslint.org/) - -```sh -npm run lint -```