From e1872aca9f913cf5311a64be4585e90d4a361a14 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sun, 3 May 2026 00:12:16 -0700 Subject: [PATCH 1/2] feat: fix the users --- docker-compose.yml | 14 + example/inertia-pingcrm-app/.env.example | 11 + .../app/http/controllers/users_controller.py | 43 ++- .../bootstrap/application.py | 17 +- .../resources/js/Components/Menu/MainMenu.tsx | 59 ++-- .../resources/js/Layouts/MainLayout.tsx | 70 ++-- .../resources/js/Pages/Users/Edit.tsx | 310 +++++++++--------- .../resources/js/Pages/Users/Index.tsx | 122 ++++--- example/inertia-pingcrm-app/uv.lock | 11 - .../src/fastapi_startkit/application.py | 4 +- .../src/fastapi_startkit/config/app.py | 1 + .../fastapi_startkit/exceptions/handler.py | 1 + .../src/fastapi_startkit/inertia/provider.py | 2 +- .../masoniteorm/factory/factory.py | 104 +++++- .../masoniteorm/models/model.py | 15 +- 15 files changed, 449 insertions(+), 335 deletions(-) create mode 100644 example/inertia-pingcrm-app/.env.example diff --git a/docker-compose.yml b/docker-compose.yml index 79f7a92b..f33cfc72 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,3 +13,17 @@ services: interval: 5s timeout: 5s retries: 10 + + postgres: + image: postgres:16 + environment: + POSTGRES_DB: database_app_test + POSTGRES_USER: app + POSTGRES_PASSWORD: secret + ports: + - "5432:5432" + healthcheck: + test: [ "CMD", "pg_isready", "-U", "app", "-d", "database_app_test" ] + interval: 5s + timeout: 5s + retries: 10 diff --git a/example/inertia-pingcrm-app/.env.example b/example/inertia-pingcrm-app/.env.example new file mode 100644 index 00000000..0c8dd991 --- /dev/null +++ b/example/inertia-pingcrm-app/.env.example @@ -0,0 +1,11 @@ +APP_NAME="Inertia Tickets" +APP_ENV=local +APP_URL=http://localhost:8000 +APP_DEBUG=true + +DB_CONNECTION=postgres +DB_HOST=127.0.0.1 +DB_PORT=5432 +DB_DATABASE=inertia +DB_USERNAME=local +DB_PASSWORD=secret diff --git a/example/inertia-pingcrm-app/app/http/controllers/users_controller.py b/example/inertia-pingcrm-app/app/http/controllers/users_controller.py index 74441c6b..61749f3a 100644 --- a/example/inertia-pingcrm-app/app/http/controllers/users_controller.py +++ b/example/inertia-pingcrm-app/app/http/controllers/users_controller.py @@ -1,30 +1,30 @@ from fastapi import Request from fastapi.responses import RedirectResponse from fastapi_startkit.inertia import Inertia + from app.models.User import User async def index(): - users = await User.query().limit(10).get() + users = await User.query().limit(10).paginate() + return Inertia.render('Users/Index', { - 'users': { - 'data': [ - { - 'id': u.id, - 'name': f"{u.first_name} {u.last_name}", - 'email': u.email, - 'owner': u.owner, - 'photo': u.photo_path, - 'deleted_at': None, - } for u in users - ], - 'links': {'first': None, 'last': None, 'prev': None, 'next': None}, - 'meta': { - 'current_page': 1, 'last_page': 1, 'per_page': 10, - 'from': 1, 'to': len(users), 'total': len(users), - 'path': '/users', 'links': [], - }, - } + 'data': [ + { + 'id': u.id, + 'name': f"{u.first_name} {u.last_name}", + 'email': u.email, + 'owner': u.owner, + 'photo': u.photo_path, + 'deleted_at': None, + } for u in users.result + ], + 'meta': { + 'current_page': users.current_page, + 'last_page': users.last_page, + 'per_page': users.last_page, + 'total': users.total, + }, }) @@ -38,7 +38,7 @@ async def store(request: Request): return RedirectResponse(url="/users", status_code=303) -async def edit(user: str): +async def edit(user: int): u = await User.find(user) return Inertia.render('Users/Edit', { 'user': { @@ -48,13 +48,12 @@ async def edit(user: str): 'email': u.email, 'owner': u.owner, 'photo': u.photo_path, - 'password': '', 'deleted_at': None, } }) -async def update(request: Request, user: str): +async def update(request: Request, user: int): u = await User.find(user) form = await request.json() await u.update(form) diff --git a/example/inertia-pingcrm-app/bootstrap/application.py b/example/inertia-pingcrm-app/bootstrap/application.py index 02fd2d78..9abc2652 100644 --- a/example/inertia-pingcrm-app/bootstrap/application.py +++ b/example/inertia-pingcrm-app/bootstrap/application.py @@ -1,20 +1,20 @@ from pathlib import Path -from config.database import DatabaseConfig -from providers.fastapi_provider import FastAPIProvider -from starlette.middleware.trustedhost import TrustedHostMiddleware - -from starlette.middleware.sessions import SessionMiddleware - -from authentication.middlewares.auth import AuthMiddleware, NotAuthenticated from fastapi_startkit import Application from fastapi_startkit.exceptions import ExceptionHandler as BaseHandler from fastapi_startkit.inertia import InertiaProvider from fastapi_startkit.logging import LogProvider from fastapi_startkit.masoniteorm import DatabaseProvider from fastapi_startkit.vite import ViteProvider +from starlette.middleware.sessions import SessionMiddleware +from starlette.middleware.trustedhost import TrustedHostMiddleware from starlette.responses import RedirectResponse +from authentication.middlewares.auth import AuthMiddleware, NotAuthenticated +from config.database import DatabaseConfig +from providers.fastapi_provider import FastAPIProvider + + class ExceptionHandler(BaseHandler): def register(self): self.register_render( @@ -24,7 +24,7 @@ def register(self): app: Application = Application( - base_path=str(Path.cwd()), + base_path=Path(__file__).resolve().parent.parent, providers=[ LogProvider, (DatabaseProvider, DatabaseConfig), @@ -35,7 +35,6 @@ def register(self): exception_handler=ExceptionHandler, ) - app.add_middleware(AuthMiddleware) app.add_middleware(SessionMiddleware, secret_key="...") app.add_middleware(TrustedHostMiddleware, allowed_hosts=["*"]) diff --git a/example/inertia-pingcrm-app/resources/js/Components/Menu/MainMenu.tsx b/example/inertia-pingcrm-app/resources/js/Components/Menu/MainMenu.tsx index ec97042a..3981556f 100644 --- a/example/inertia-pingcrm-app/resources/js/Components/Menu/MainMenu.tsx +++ b/example/inertia-pingcrm-app/resources/js/Components/Menu/MainMenu.tsx @@ -1,33 +1,38 @@ -import MainMenuItem from '@/Components/Menu/MainMenuItem'; -import { Building, CircleGauge, Printer, Users } from 'lucide-react'; +import MainMenuItem from "@/Components/Menu/MainMenuItem" +import { Building, CircleGauge, Printer, Users } from "lucide-react" interface MainMenuProps { - className?: string; + className?: string; } export default function MainMenu({ className }: MainMenuProps) { - return ( -
- } - /> - } - /> - } - /> - } - /> -
- ); + return ( +
+ } + /> + } + /> + } + /> + } + /> + } + /> +
+ ) } diff --git a/example/inertia-pingcrm-app/resources/js/Layouts/MainLayout.tsx b/example/inertia-pingcrm-app/resources/js/Layouts/MainLayout.tsx index 8eac69c7..3245692b 100644 --- a/example/inertia-pingcrm-app/resources/js/Layouts/MainLayout.tsx +++ b/example/inertia-pingcrm-app/resources/js/Layouts/MainLayout.tsx @@ -1,42 +1,42 @@ -import { Head } from '@inertiajs/react'; -import MainMenu from '@/Components/Menu/MainMenu'; -import FlashMessages from '@/Components/Messages/FlashMessages'; -import TopHeader from '@/Components/Header/TopHeader'; -import BottomHeader from '@/Components/Header/BottomHeader'; +import BottomHeader from "@/Components/Header/BottomHeader" +import TopHeader from "@/Components/Header/TopHeader" +import MainMenu from "@/Components/Menu/MainMenu" +import FlashMessages from "@/Components/Messages/FlashMessages" +import { Head } from "@inertiajs/react" interface MainLayoutProps { - title?: string; - children: React.ReactNode; + title?: string; + children: React.ReactNode; } export default function MainLayout({ title, children }: MainLayoutProps) { - return ( - <> - -
-
-
- - -
-
- - {/** - * We need to scroll the content of the page, not the whole page. - * So we need to add `scroll-region="true"` to the div below. - * - * [Read more](https://inertiajs.com/pages#scroll-regions) - */} -
- - {children} + return ( + <> + +
+
+
+ + +
+
+ + {/** + * We need to scroll the content of the page, not the whole page. + * So we need to add `scroll-region="true"` to the div below. + * + * [Read more](https://inertiajs.com/pages#scroll-regions) + */} +
+ + {children} +
+
+
-
-
-
- - ); + + ) } diff --git a/example/inertia-pingcrm-app/resources/js/Pages/Users/Edit.tsx b/example/inertia-pingcrm-app/resources/js/Pages/Users/Edit.tsx index 50e85127..731599a6 100644 --- a/example/inertia-pingcrm-app/resources/js/Pages/Users/Edit.tsx +++ b/example/inertia-pingcrm-app/resources/js/Pages/Users/Edit.tsx @@ -1,177 +1,175 @@ -import React from 'react'; -import { Head } from '@inertiajs/react'; -import { Link, usePage, useForm, router } from '@inertiajs/react'; -import MainLayout from '@/Layouts/MainLayout'; -import DeleteButton from '@/Components/Button/DeleteButton'; -import LoadingButton from '@/Components/Button/LoadingButton'; -import TextInput from '@/Components/Form/TextInput'; -import SelectInput from '@/Components/Form/SelectInput'; -import FileInput from '@/Components/Form/FileInput'; -import TrashedMessage from '@/Components/Messages/TrashedMessage'; -import { User } from '@/types'; -import FieldGroup from '@/Components/Form/FieldGroup'; +import DeleteButton from "@/Components/Button/DeleteButton" +import LoadingButton from "@/Components/Button/LoadingButton" +import FieldGroup from "@/Components/Form/FieldGroup" +import FileInput from "@/Components/Form/FileInput" +import SelectInput from "@/Components/Form/SelectInput" +import TextInput from "@/Components/Form/TextInput" +import TrashedMessage from "@/Components/Messages/TrashedMessage" +import MainLayout from "@/Layouts/MainLayout" +import { User } from "@/types" +import { Head, Link, router, useForm, usePage } from "@inertiajs/react" +import React from "react" const Edit = () => { - const { user } = usePage<{ - user: User & { password: string; photo: File | null }; - }>().props; + const { user } = usePage<{ + user: User & { password: string; photo: File | null }; + }>().props - const { data, setData, errors, post, processing } = useForm({ - first_name: user.first_name || '', - last_name: user.last_name || '', - email: user.email || '', - password: user.password || '', - owner: user.owner ? '1' : '0' || '0', - photo: '', + const { data, setData, errors, put, processing } = useForm({ + first_name: user.first_name || "", + last_name: user.last_name || "", + email: user.email || "", + password: user.password || "", + owner: user.owner ? "1" : "0" || "0", + photo: "", - // NOTE: When working with Laravel PUT/PATCH requests and FormData - // you SHOULD send POST request and fake the PUT request like this. - _method: 'put' - }); + // NOTE: When working with Laravel PUT/PATCH requests and FormData + // you SHOULD send POST request and fake the PUT request like this. + _method: "put", + }) - function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - - // NOTE: We are using POST method here, not PUT/PATCH. See comment above. - post(route('users.update', user.id)); - } + function handleSubmit(e: React.FormEvent) { + e.preventDefault() + + put(route("users.update", user.id)) + } - function destroy() { - if (confirm('Are you sure you want to delete this user?')) { - router.delete(route('users.destroy', user.id)); + function destroy() { + if (confirm("Are you sure you want to delete this user?")) { + router.delete(route("users.destroy", user.id)) + } } - } - function restore() { - if (confirm('Are you sure you want to restore this user?')) { - router.put(route('users.restore', user.id)); + function restore() { + if (confirm("Are you sure you want to restore this user?")) { + router.put(route("users.restore", user.id)) + } } - } - return ( -
- -
-

- - Users - - / - {data.first_name} {data.last_name} -

- {user.photo && ( - - )} -
- {user.deleted_at && ( - - )} -
-
-
- - setData('first_name', e.target.value)} - /> - - - setData('last_name', e.target.value)} - /> - + return ( +
+ +
+

+ + Users + + / + {data.first_name} {data.last_name} +

+ {user.photo && ( + + )} +
+ {user.deleted_at && ( + + )} +
+ +
+ + setData("first_name", e.target.value)} + /> + + + setData("last_name", e.target.value)} + /> + - - setData('email', e.target.value)} - /> - + + setData("email", e.target.value)} + /> + - - setData('password', e.target.value)} - /> - + + setData("password", e.target.value)} + /> + - - setData('owner', e.target.value)} - options={[ - { value: '1', label: 'Yes' }, - { value: '0', label: 'No' } - ]} - /> - + + setData("owner", e.target.value)} + options={[ + { value: "1", label: "Yes" }, + { value: "0", label: "No" }, + ]} + /> + - - { - setData('photo', photo as unknown as string); - }} - /> - -
-
- {!user.deleted_at && ( - Delete User - )} - - Update User - -
- -
-
- ); -}; + + { + setData("photo", photo as unknown as string) + }} + /> + +
+
+ {!user.deleted_at && ( + Delete User + )} + + Update User + +
+ +
+
+ ) +} /** * Persistent Layout (Inertia.js) * * [Learn more](https://inertiajs.com/pages#persistent-layouts) */ -Edit.layout = (page: React.ReactNode) => ; +Edit.layout = (page: React.ReactNode) => -export default Edit; +export default Edit diff --git a/example/inertia-pingcrm-app/resources/js/Pages/Users/Index.tsx b/example/inertia-pingcrm-app/resources/js/Pages/Users/Index.tsx index 5b830f92..3d6f6af8 100644 --- a/example/inertia-pingcrm-app/resources/js/Pages/Users/Index.tsx +++ b/example/inertia-pingcrm-app/resources/js/Pages/Users/Index.tsx @@ -1,66 +1,64 @@ -import { Link, usePage } from '@inertiajs/react'; -import MainLayout from '@/Layouts/MainLayout'; -import FilterBar from '@/Components/FilterBar/FilterBar'; -import Pagination from '@/Components/Pagination/Pagination'; -import { PaginatedData, User } from '@/types'; -import Table from '@/Components/Table/Table'; -import { Trash2 } from 'lucide-react'; +import FilterBar from "@/Components/FilterBar/FilterBar" +import Pagination from "@/Components/Pagination/Pagination" +import Table from "@/Components/Table/Table" +import MainLayout from "@/Layouts/MainLayout" +import { PaginatedData, User } from "@/types" +import { Link, usePage } from "@inertiajs/react" +import { Trash2 } from "lucide-react" const Index = () => { - const { users } = usePage<{ users: PaginatedData }>().props; + const { data, meta } = usePage>().props - const { data, meta } = users; + return ( +
+

Users

+
+ + + Create + User + +
+ -

Users

-
- - - Create - User - -
-
( - <> - {row.photo && ( - {row.name} - )} - <>{row.name} - {row.deleted_at && ( - - )} - - ) - }, - { label: 'Email', name: 'email' }, - { - label: 'Role', - name: 'owner', - colSpan: 2, - renderCell: row => (row.owner ? 'Owner' : 'User') - } - ]} - rows={data} - getRowDetailsUrl={row => route('users.edit', row.id)} - /> - - - ); -}; + renderCell: row => ( + <> + {row.photo && ( + {row.name} + )} + <>{row.name} + {row.deleted_at && ( + + )} + + ), + }, + { label: "Email", name: "email" }, + { + label: "Role", + name: "owner", + colSpan: 2, + renderCell: row => (row.owner ? "Owner" : "User"), + }, + ]} + rows={data} + getRowDetailsUrl={row => route("users.edit", row.id)} + /> + + + ) +} /** * Persistent Layout (Inertia.js) @@ -68,7 +66,7 @@ const Index = () => { * [Learn more](https://inertiajs.com/pages#persistent-layouts) */ Index.layout = (page: React.ReactNode) => ( - -); + +) -export default Index; +export default Index diff --git a/example/inertia-pingcrm-app/uv.lock b/example/inertia-pingcrm-app/uv.lock index d1d2757e..e600ead7 100644 --- a/example/inertia-pingcrm-app/uv.lock +++ b/example/inertia-pingcrm-app/uv.lock @@ -7,15 +7,6 @@ resolution-markers = [ "python_full_version < '3.13'", ] -[[package]] -name = "aiosqlite" -version = "0.22.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, -] - [[package]] name = "annotated-doc" version = "0.0.4" @@ -344,7 +335,6 @@ name = "fastapi-startkit" version = "0.13.6" source = { editable = "../../fastapi_startkit" } dependencies = [ - { name = "aiosqlite" }, { name = "cleo" }, { name = "dotenv" }, { name = "dotty-dict" }, @@ -369,7 +359,6 @@ postgres = [ [package.metadata] requires-dist = [ { name = "aiomysql", marker = "extra == 'mysql'", specifier = ">=0.2.0" }, - { name = "aiosqlite", specifier = ">=0.22.1" }, { name = "aiosqlite", marker = "extra == 'sqlite'", specifier = ">=0.22.1" }, { name = "asyncpg", marker = "extra == 'postgres'", specifier = ">=0.29.0" }, { name = "cleo", specifier = ">=2.1.0,<3.0.0" }, diff --git a/fastapi_startkit/src/fastapi_startkit/application.py b/fastapi_startkit/src/fastapi_startkit/application.py index 3701052a..65729250 100644 --- a/fastapi_startkit/src/fastapi_startkit/application.py +++ b/fastapi_startkit/src/fastapi_startkit/application.py @@ -1,4 +1,6 @@ import os + +from dumpdie import dd from fastapi_startkit.providers.app_provider import AppProvider from pathlib import Path from typing import TYPE_CHECKING, Optional @@ -44,7 +46,7 @@ def __init__( self.providers = self.DEFAULT_PROVIDERS + (providers or []) self.published_resources = {} self.commands = [] - self._config = config + self._config = config or AppConfig self._config_instance: Optional[TConfig] = None self._exception_handler_class = exception_handler or ExceptionHandler self.exception_manager: ExceptionHandler diff --git a/fastapi_startkit/src/fastapi_startkit/config/app.py b/fastapi_startkit/src/fastapi_startkit/config/app.py index 2bea07f8..1d028a68 100644 --- a/fastapi_startkit/src/fastapi_startkit/config/app.py +++ b/fastapi_startkit/src/fastapi_startkit/config/app.py @@ -1,5 +1,6 @@ import os from dataclasses import field, dataclass + from fastapi_startkit.environment.environment import env diff --git a/fastapi_startkit/src/fastapi_startkit/exceptions/handler.py b/fastapi_startkit/src/fastapi_startkit/exceptions/handler.py index f7ae5ee6..4310d18d 100644 --- a/fastapi_startkit/src/fastapi_startkit/exceptions/handler.py +++ b/fastapi_startkit/src/fastapi_startkit/exceptions/handler.py @@ -2,6 +2,7 @@ import atexit from typing import Any, Callable, Dict, List, Optional, Type +from dumpdie import dd class ExceptionHandler: diff --git a/fastapi_startkit/src/fastapi_startkit/inertia/provider.py b/fastapi_startkit/src/fastapi_startkit/inertia/provider.py index 4884b80a..0dc2c870 100644 --- a/fastapi_startkit/src/fastapi_startkit/inertia/provider.py +++ b/fastapi_startkit/src/fastapi_startkit/inertia/provider.py @@ -9,7 +9,7 @@ class InertiaProvider(Provider): def register(self) -> None: """Bind the Inertia class to the container.""" - self.app.bind("inertia", Inertia(self.app)) + self.app.bind("inertia", Inertia) def boot(self) -> None: """Configure template globals and middleware.""" diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/factory/factory.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/factory/factory.py index 9d4c1b5f..ccb7383c 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/factory/factory.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/factory/factory.py @@ -1,20 +1,114 @@ from abc import ABC, abstractmethod -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Callable, List if TYPE_CHECKING: from fastapi_startkit.masoniteorm.models import Model +class FactoryBuilder: + """Fluent builder returned by Factory.new().""" + + def __init__(self, factory_cls: "type[Factory]"): + self._factory_cls = factory_cls + self._count: int = 1 + self._states: list[Callable] = [] + self._after_making: list[Callable] = [] + self._after_creating: list[Callable] = [] + + def count(self, n: int) -> "FactoryBuilder": + self._count = n + return self + + def state(self, callback: Callable) -> "FactoryBuilder": + self._states.append(callback) + return self + + def after_making(self, callback: Callable) -> "FactoryBuilder": + self._after_making.append(callback) + return self + + def after_creating(self, callback: Callable) -> "FactoryBuilder": + self._after_creating.append(callback) + return self + + def _build_attributes(self, overrides: dict) -> dict: + instance = self._factory_cls() + attrs = instance.definition() + for state_fn in self._states: + result = state_fn(attrs) + if result: + attrs.update(result) + attrs.update(overrides) + return attrs + + async def create(self, **overrides) -> "Model | list[Model]": + model_cls = self._factory_cls.model + results = [] + + for _ in range(self._count): + attrs = self._build_attributes(overrides) + record = await model_cls.create(attrs) + + for cb in self._after_creating: + result = cb(record) + import inspect + if inspect.isawaitable(result): + await result + + results.append(record) + + return results if self._count > 1 else results[0] + + async def make(self, **overrides) -> "Model | list[Model]": + """Build model instances without persisting.""" + model_cls = self._factory_cls.model + results = [] + + for _ in range(self._count): + attrs = self._build_attributes(overrides) + record = model_cls() + record._attributes = attrs + + for cb in self._after_making: + result = cb(record) + import inspect + if inspect.isawaitable(result): + await result + + results.append(record) + + return results if self._count > 1 else results[0] + + class Factory(ABC): model: "Model" + _builder: "FactoryBuilder | None" = None @abstractmethod def definition(self) -> dict: ... @classmethod - async def create(cls) -> "Model": - model: Model = cls.model + def new(cls) -> FactoryBuilder: + builder = FactoryBuilder(cls) + # Let subclasses configure the builder via configure() instance = cls() + if hasattr(instance, "configure"): + instance._builder = builder + instance.configure() + return builder + + def state(self, callback: Callable) -> FactoryBuilder: + """Called from configure() to register a state on the current builder.""" + assert self._builder is not None, "state() must be called inside configure()" + self._builder.state(callback) + return self._builder + + def after_making(self, callback: Callable) -> "Factory": + assert self._builder is not None, "after_making() must be called inside configure()" + self._builder.after_making(callback) + return self - result = await model.create(instance.definition()) - return result + def after_creating(self, callback: Callable) -> "Factory": + assert self._builder is not None, "after_creating() must be called inside configure()" + self._builder.after_creating(callback) + return self diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/model.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/model.py index 53bf2b35..fef19996 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/model.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/model.py @@ -1,16 +1,17 @@ from __future__ import annotations -import inflection from typing import TYPE_CHECKING +import inflection +from dumpdie import dd from fastapi_startkit.carbon import Carbon from fastapi_startkit.masoniteorm.collection import Collection -from fastapi_startkit.masoniteorm.models.fields import CreatedAtField, UpdatedAtField -from fastapi_startkit.masoniteorm.models.registry import Registry -from fastapi_startkit.masoniteorm.observers import ObservesEvents from fastapi_startkit.masoniteorm.connections.manager import DatabaseManager from fastapi_startkit.masoniteorm.models.attribute import Attribute +from fastapi_startkit.masoniteorm.models.fields import CreatedAtField, UpdatedAtField +from fastapi_startkit.masoniteorm.models.registry import Registry from fastapi_startkit.masoniteorm.models.relationship import Relationship +from fastapi_startkit.masoniteorm.observers import ObservesEvents if TYPE_CHECKING: from fastapi_startkit.orm.models.builder import QueryBuilder @@ -170,7 +171,7 @@ def query(cls): @classmethod async def first_or_create( - cls, search: dict, attributes: dict | None = None + cls, search: dict, attributes: dict | None = None ) -> "Model": return await cls.query().first_or_create(search, attributes) @@ -215,11 +216,13 @@ def finish_saving(self, options: dict | None = None): async def perform_insert(self, query) -> bool: attributes = self.get_attributes_for_insert() - inserted_id = await query.insert(attributes) + attributes = await query.insert(attributes) + inserted_id = (attributes or {}).get(self.__primary_key__, None) # Store the auto-generated primary key so subsequent saves do an UPDATE if inserted_id is not None: self._dirty_attributes[self.__primary_key__] = inserted_id + self._attributes[self.__primary_key__] = inserted_id self._exists = True self._was_recently_created = True From e4b1f2930af6bcf1442aa91f0614156239348b3d Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Tue, 5 May 2026 00:37:58 -0700 Subject: [PATCH 2/2] feat: wip --- .../auth/authenticated_session_controller.py | 18 +++--- .../app/http/requests/auth.py | 6 ++ .../resources/js/Pages/Auth/Login.tsx | 2 +- .../resources/js/Pages/Users/Edit.tsx | 6 +- example/inertia-pingcrm-app/tinker.py | 35 ++++++++++++ .../src/fastapi_startkit/application.py | 1 - .../fastapi_startkit/exceptions/handler.py | 1 - .../fastapi_startkit/fastapi/exceptions.py | 56 +++++++++++++++++++ .../fastapi/providers/fastapi_provider.py | 6 +- .../masoniteorm/factory/factory.py | 2 +- .../masoniteorm/models/model.py | 1 - 11 files changed, 116 insertions(+), 18 deletions(-) create mode 100644 example/inertia-pingcrm-app/app/http/requests/auth.py create mode 100644 example/inertia-pingcrm-app/tinker.py diff --git a/example/inertia-pingcrm-app/app/http/controllers/auth/authenticated_session_controller.py b/example/inertia-pingcrm-app/app/http/controllers/auth/authenticated_session_controller.py index 139498de..425dd310 100644 --- a/example/inertia-pingcrm-app/app/http/controllers/auth/authenticated_session_controller.py +++ b/example/inertia-pingcrm-app/app/http/controllers/auth/authenticated_session_controller.py @@ -1,25 +1,29 @@ from fastapi import Request from fastapi.responses import RedirectResponse from fastapi_startkit.inertia import Inertia + +from app.http.requests.auth import LoginRequest from app.models.User import User + async def create(): return Inertia.render('Auth/Login', {}) -async def store(request: Request): - form = await request.json() - email = form.get("email") - password = form.get("password") + +async def store(request: LoginRequest): + email = request.email + password = request.password user = await User.where("email", email).first() - if user and user.password == password: - request.session["user_id"] = user.id - return RedirectResponse(url="/", status_code=303) + # if user and user.password == password: + # request.session["user_id"] = user.id + # return RedirectResponse(url="/", status_code=303) return Inertia.render('Auth/Login', { 'errors': {'email': 'These credentials do not match our records.'} }) + async def destroy(request: Request): request.session.clear() return RedirectResponse(url="/login", status_code=303) diff --git a/example/inertia-pingcrm-app/app/http/requests/auth.py b/example/inertia-pingcrm-app/app/http/requests/auth.py new file mode 100644 index 00000000..adc5f147 --- /dev/null +++ b/example/inertia-pingcrm-app/app/http/requests/auth.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel, Field + + +class LoginRequest(BaseModel): + email: str = Field(min_length=1) + password: str = Field(min_length=1) diff --git a/example/inertia-pingcrm-app/resources/js/Pages/Auth/Login.tsx b/example/inertia-pingcrm-app/resources/js/Pages/Auth/Login.tsx index 940125ee..03135f7e 100644 --- a/example/inertia-pingcrm-app/resources/js/Pages/Auth/Login.tsx +++ b/example/inertia-pingcrm-app/resources/js/Pages/Auth/Login.tsx @@ -8,7 +8,7 @@ import React from "react" export default function LoginPage() { const { data, setData, errors, post, processing } = useForm({ - email: "johndoe@example.com", + email: "", password: "secret", remember: true, }) diff --git a/example/inertia-pingcrm-app/resources/js/Pages/Users/Edit.tsx b/example/inertia-pingcrm-app/resources/js/Pages/Users/Edit.tsx index 731599a6..aa1de12b 100644 --- a/example/inertia-pingcrm-app/resources/js/Pages/Users/Edit.tsx +++ b/example/inertia-pingcrm-app/resources/js/Pages/Users/Edit.tsx @@ -22,15 +22,11 @@ const Edit = () => { password: user.password || "", owner: user.owner ? "1" : "0" || "0", photo: "", - - // NOTE: When working with Laravel PUT/PATCH requests and FormData - // you SHOULD send POST request and fake the PUT request like this. - _method: "put", }) function handleSubmit(e: React.FormEvent) { e.preventDefault() - + put(route("users.update", user.id)) } diff --git a/example/inertia-pingcrm-app/tinker.py b/example/inertia-pingcrm-app/tinker.py new file mode 100644 index 00000000..d922133b --- /dev/null +++ b/example/inertia-pingcrm-app/tinker.py @@ -0,0 +1,35 @@ +from dumpdie import dd +from fastapi_startkit.collection import collection +from pydantic import BaseModel, ValidationError, Field, EmailStr + + +class RegisterRequest(BaseModel): + name: str = Field(min_length=3) + email: EmailStr + password: str + + +class Error: + def __init__(self, field: str, error_type: str, inputs: list, message: str, **args): + self.field = field + self.error_type = error_type + self.inputs = inputs + self.message = message + + +try: + RegisterRequest(**{"email": "h"}) +except ValidationError as e: + errors = collection.Collection() + for error in e.errors(): + print(error) + err = Error( + field=error.get('loc')[0], + message=error.get('msg'), + error_type=error.get('type'), + inputs=error.get('inputs'), + ) + + errors.push(err) + + dd(errors) diff --git a/fastapi_startkit/src/fastapi_startkit/application.py b/fastapi_startkit/src/fastapi_startkit/application.py index 65729250..ec79bc17 100644 --- a/fastapi_startkit/src/fastapi_startkit/application.py +++ b/fastapi_startkit/src/fastapi_startkit/application.py @@ -1,6 +1,5 @@ import os -from dumpdie import dd from fastapi_startkit.providers.app_provider import AppProvider from pathlib import Path from typing import TYPE_CHECKING, Optional diff --git a/fastapi_startkit/src/fastapi_startkit/exceptions/handler.py b/fastapi_startkit/src/fastapi_startkit/exceptions/handler.py index 4310d18d..f7ae5ee6 100644 --- a/fastapi_startkit/src/fastapi_startkit/exceptions/handler.py +++ b/fastapi_startkit/src/fastapi_startkit/exceptions/handler.py @@ -2,7 +2,6 @@ import atexit from typing import Any, Callable, Dict, List, Optional, Type -from dumpdie import dd class ExceptionHandler: diff --git a/fastapi_startkit/src/fastapi_startkit/fastapi/exceptions.py b/fastapi_startkit/src/fastapi_startkit/fastapi/exceptions.py index 1bce844a..8aa60bdc 100644 --- a/fastapi_startkit/src/fastapi_startkit/fastapi/exceptions.py +++ b/fastapi_startkit/src/fastapi_startkit/fastapi/exceptions.py @@ -1,3 +1,59 @@ +class ValidationExceptionHandler: + """ + Handles RequestValidationError and returns errors in Laravel style: + { + "message": "The email field is required. (and 1 more error)", + "errors": { + "email": ["The email field is required."], + "password": ["The password field is required."] + } + } + """ + + _LOCATION_PREFIXES = {"body", "query", "path", "header", "cookie"} + + def _loc_to_field(self, loc: tuple) -> str: + parts = loc[1:] if loc and loc[0] in self._LOCATION_PREFIXES else loc + return ".".join(str(p) for p in parts) + + def _format_message(self, field: str, error: dict) -> str: + error_type = error.get("type", "") + msg = error.get("msg", "") + + if error_type == "missing": + return f"The {field} field is required." + + if error_type == "enum": + return f"The selected {field} is invalid." + + # Strip Pydantic's "Value error, " prefix on custom field_validator errors + if error_type == "value_error" and msg.lower().startswith("value error, "): + msg = msg[len("value error, "):] + + return f"The {field} {msg[0].lower()}{msg[1:]}." + + async def render(self, request, exc): + from fastapi.responses import JSONResponse + + errors: dict[str, list[str]] = {} + for error in exc.errors(): + field = self._loc_to_field(error.get("loc", ())) + errors.setdefault(field, []).append(self._format_message(field, error)) + + total = sum(len(msgs) for msgs in errors.values()) + first_msg = next(iter(next(iter(errors.values())))) + if total > 1: + rest = total - 1 + summary = f"{first_msg} (and {rest} more {'error' if rest == 1 else 'errors'})" + else: + summary = first_msg + + return JSONResponse( + status_code=422, + content={"message": summary, "errors": errors}, + ) + + class HTTPExceptionHandler: """ The base exception handler for FastAPI applications. diff --git a/fastapi_startkit/src/fastapi_startkit/fastapi/providers/fastapi_provider.py b/fastapi_startkit/src/fastapi_startkit/fastapi/providers/fastapi_provider.py index bd4d72a5..d3712926 100644 --- a/fastapi_startkit/src/fastapi_startkit/fastapi/providers/fastapi_provider.py +++ b/fastapi_startkit/src/fastapi_startkit/fastapi/providers/fastapi_provider.py @@ -1,4 +1,4 @@ -from fastapi_startkit.fastapi.exceptions import HTTPExceptionHandler +from fastapi_startkit.fastapi.exceptions import HTTPExceptionHandler, ValidationExceptionHandler from fastapi import FastAPI from fastapi_startkit.fastapi.commands import ServeCommand @@ -21,10 +21,14 @@ def boot(self): def _register_exception_handlers(self): """Wire exception_manager as a catch-all handler for all exceptions.""" + from fastapi.exceptions import RequestValidationError + exception_manager = self.app.exception_manager exception_manager.register_handler(Exception, HTTPExceptionHandler()) + exception_manager.register_handler(RequestValidationError, ValidationExceptionHandler()) async def handler(request, exc): return await exception_manager.handle(exc, {"request": request}) self.app.fastapi.add_exception_handler(Exception, handler) + self.app.fastapi.add_exception_handler(RequestValidationError, handler) diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/factory/factory.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/factory/factory.py index ccb7383c..09ab935c 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/factory/factory.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/factory/factory.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Callable, List +from typing import TYPE_CHECKING, Callable if TYPE_CHECKING: from fastapi_startkit.masoniteorm.models import Model diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/model.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/model.py index fef19996..543a009a 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/model.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/model.py @@ -3,7 +3,6 @@ from typing import TYPE_CHECKING import inflection -from dumpdie import dd from fastapi_startkit.carbon import Carbon from fastapi_startkit.masoniteorm.collection import Collection from fastapi_startkit.masoniteorm.connections.manager import DatabaseManager