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 && (
-
- )}
-
+
+ {!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.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.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