Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ services:
interval: 5s
timeout: 5s
retries: 10
postgres:
db:
image: postgres:17
environment:
POSTGRES_DB: database_app_test
Expand Down
4 changes: 3 additions & 1 deletion example/config-app/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 9 additions & 2 deletions example/database-app/app/students/controllers/registration.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import hashlib

from fastapi import HTTPException
from fastapi.exceptions import RequestValidationError

from app.http.schemas.auth import StudentRegistrationRequest
from app.models import User, Profile
Expand All @@ -9,7 +9,14 @@
async def register(request: StudentRegistrationRequest):
existing_user = await User.where("email", request.email).first()
if existing_user:
raise HTTPException(status_code=400, detail="Email already registered")
raise RequestValidationError(
errors=[{
"loc": ("body", "email"),
"msg": "Email already registered",
"type": "value_error",
"input": request.email,
}]
)

password = hashlib.md5(request.password.encode()).hexdigest()
user = User(
Expand Down
5 changes: 3 additions & 2 deletions example/database-app/tests/features/students/test_register.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,5 +61,6 @@ async def test_user_cannot_register_with_duplicate_email(self):
await self.post("/students/register", json=payload)

response = await self.post("/students/register", json=payload)
assert response.status_code == 400
assert response.json()["detail"] == "Email already registered"
assert response.status_code == 422
errors = response.json()["errors"]
assert "email" in errors
4 changes: 3 additions & 1 deletion example/database-app/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 18 additions & 0 deletions example/inertia-pingcrm-app/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
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=database_app_test
DB_USERNAME=app
DB_PASSWORD=secret

AWS_ENDPOINT=http://localhost:9000
AWS_ACCESS_KEY_ID=minioadmin
AWS_SECRET_ACCESS_KEY=minioadmin
AWS_BUCKET=pingcrm
AWS_DEFAULT_REGION=us-east-1
AWS_URL=http://localhost:9000/uploads
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
from fastapi import Request
from fastapi.responses import JSONResponse
from fastapi_startkit.inertia import Inertia

async def show(request: Request):
return JSONResponse(content={'message': 'images_controller.py@show'})
from fastapi_startkit.storage import Storage

async def stream(path: str):
"""Stream a file from S3 back to the client."""
return Storage.disk("s3").stream(path)
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import uuid
from pathlib import Path
from typing import Optional


from fastapi import Request, Depends, UploadFile, File
from fastapi.responses import RedirectResponse
from fastapi_startkit.inertia import Inertia
from fastapi_startkit.storage import Storage
from app.models.User import User
from app.http.requests.profile import ProfileUpdateRequest


async def save_photo(photo: Optional[UploadFile]) -> Optional[str]:
if photo is None or not photo.filename:
return None
if not photo.content_type or not photo.content_type.startswith("image/"):
return None
ext = Path(photo.filename).suffix.lower() or ".jpg"
filename = f"photos/{uuid.uuid4().hex}{ext}"
Storage.disk("s3").put(filename, await photo.read())
return filename


async def edit(request: Request):
user = await User.find(request.state.user["id"])
photo_url = f"/images/{user.photo_path}" if user.photo_path else None
return Inertia.render('Profile/Edit', {
'user': {
'id': user.id,
'first_name': user.first_name,
'last_name': user.last_name,
'email': user.email,
'photo': photo_url,
'password': '',
}
})


async def update(
request: Request,
form: ProfileUpdateRequest = Depends(),
photo: Optional[UploadFile] = File(default=None),
):
user = await User.find(request.state.user["id"])

photo_path = await save_photo(photo)

update_data = form.validated()
if photo_path:
update_data['photo_path'] = photo_path

await user.update(update_data)
return RedirectResponse(url="/profile", status_code=303)
Original file line number Diff line number Diff line change
@@ -1,9 +1,29 @@
from fastapi import Request
import uuid
from pathlib import Path
from typing import Optional

from fastapi import Request, UploadFile, File, Form
from fastapi.responses import RedirectResponse
from fastapi_startkit.inertia import Inertia
from fastapi_startkit.storage import Storage
from app.models.User import User


async def _save_photo(photo: Optional[UploadFile]) -> Optional[str]:
"""Save an UploadFile to the public disk and return its public URL path, or None."""
if photo is None or not photo.filename:
return None
if not photo.content_type or not photo.content_type.startswith("image/"):
return None

ext = Path(photo.filename).suffix.lower() or ".jpg"
filename = f"{uuid.uuid4().hex}{ext}"

Storage.disk("public").put(filename, await photo.read())

return f"/storage/{filename}"


async def index():
users = await User.query().limit(10).get()
return Inertia.render('Users/Index', {
Expand Down Expand Up @@ -32,13 +52,32 @@ async def create():
return Inertia.render('Users/Create', {})


async def store(request: Request):
form = await request.json()
await User.create(form)
async def store(
request: Request,
first_name: str = Form(...),
last_name: str = Form(...),
email: str = Form(...),
password: str = Form(default=''),
owner: str = Form(default='0'),
photo: Optional[UploadFile] = File(default=None),
):
photo_path = await _save_photo(photo)

user_data = {
'first_name': first_name,
'last_name': last_name,
'email': email,
'password': password,
'owner': owner == '1',
}
if photo_path:
user_data['photo_path'] = photo_path

await User.create(user_data)
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': {
Expand All @@ -54,16 +93,38 @@ async def edit(user: str):
})


async def update(request: Request, user: str):
async def update(
request: Request,
user: int,
first_name: str = Form(...),
last_name: str = Form(...),
email: str = Form(...),
password: str = Form(default=''),
owner: str = Form(default='0'),
photo: Optional[UploadFile] = File(default=None),
):
u = await User.find(user)
form = await request.json()
await u.update(form)

photo_path = await _save_photo(photo)

update_data = {
'first_name': first_name,
'last_name': last_name,
'email': email,
'owner': owner == '1',
}
if password:
update_data['password'] = password
if photo_path:
update_data['photo_path'] = photo_path

await u.update(update_data)
return RedirectResponse(url=f"/users/{user}/edit", status_code=303)


async def destroy(user: str):
async def destroy(user: int):
return RedirectResponse(url="/users", status_code=303)


async def restore(user: str):
async def restore(user: int):
return RedirectResponse(url="/users", status_code=303)
1 change: 1 addition & 0 deletions example/inertia-pingcrm-app/app/http/requests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

8 changes: 8 additions & 0 deletions example/inertia-pingcrm-app/app/http/requests/profile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from fastapi_startkit.fastapi import RequestModel


class ProfileUpdateRequest(RequestModel):
first_name: str
last_name: str
email: str
password: str = ''
2 changes: 1 addition & 1 deletion example/inertia-pingcrm-app/app/models/User.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from typing import Optional
from fastapi_startkit.masoniteorm.models import Model
from fastapi_startkit.masoniteorm import Model


class User(Model):
Expand Down
3 changes: 3 additions & 0 deletions example/inertia-pingcrm-app/bootstrap/application.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from pathlib import Path

from config.database import DatabaseConfig
from config.storage import StorageConfig
from providers.fastapi_provider import FastAPIProvider
from starlette.middleware.trustedhost import TrustedHostMiddleware

Expand All @@ -12,6 +13,7 @@
from fastapi_startkit.inertia import InertiaProvider
from fastapi_startkit.logging import LogProvider
from fastapi_startkit.masoniteorm import DatabaseProvider
from fastapi_startkit.storage.providers.provider import StorageProvider
from fastapi_startkit.vite import ViteProvider
from starlette.responses import RedirectResponse

Expand All @@ -28,6 +30,7 @@ def register(self):
providers=[
LogProvider,
(DatabaseProvider, DatabaseConfig),
(StorageProvider, StorageConfig),
FastAPIProvider,
ViteProvider,
InertiaProvider,
Expand Down
31 changes: 31 additions & 0 deletions example/inertia-pingcrm-app/config/storage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from dataclasses import dataclass, field
from typing import Any, Dict

from fastapi_startkit.environment import env
from fastapi_startkit.storage import LocalDiskConfig, PublicDiskConfig, S3Config


@dataclass
class StorageConfig:
default: str = field(default_factory=lambda: env("FILESYSTEM_DISK", "local"))

disks: dict[str, Dict[str, Any]] = field(
default_factory=lambda: {
"local": LocalDiskConfig(
root=env("FILESYSTEM_DISK_ROOT", "storage"),
),
"public": PublicDiskConfig(
root=env("FILESYSTEM_PUBLIC_DISK_ROOT", "storage/app/public"),
url=env("FILESYSTEM_PUBLIC_DISK_URL", "/storage"),
),
"s3": S3Config(
key=env("AWS_ACCESS_KEY_ID"),
secret=env("AWS_SECRET_ACCESS_KEY"),
region=env("AWS_DEFAULT_REGION", "us-east-1"),
bucket=env("AWS_BUCKET"),
url=env("AWS_URL"),
endpoint=env("AWS_ENDPOINT"),
use_path_style_endpoint=True,
),
}
)
2 changes: 1 addition & 1 deletion example/inertia-pingcrm-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"type": "module",
"version": "0.1.0",
"scripts": {
"dev": "vite",
"dev": "concurrently \"npx vite dev\" \"uv run python artisan serve\"",
"build": "vite build",
"preview": "vite preview"
},
Expand Down
2 changes: 0 additions & 2 deletions example/inertia-pingcrm-app/providers/fastapi_provider.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
from pathlib import Path

from fastapi.responses import RedirectResponse

from authentication.middlewares.auth import AuthMiddleware, NotAuthenticated
from fastapi import FastAPI, Request
from fastapi_startkit.fastapi import FastAPIProvider as BaseFastAPIProvider
Expand Down
9 changes: 9 additions & 0 deletions example/inertia-pingcrm-app/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ version = "0.1.0"
description = "Vite + FastAPI example using fastapi-startkit"
requires-python = ">=3.12"
dependencies = [
"boto3>=1.35.0",
"faker>=40.15.0",
"fastapi-startkit[fastapi,database,postgres]",
"itsdangerous>=2.2.0",
"jinja2>=3.1",
"python-multipart>=0.0.9",
]

[tool.uv.sources]
Expand All @@ -16,4 +18,11 @@ fastapi-startkit = { path = "../../fastapi_startkit", editable = true }
[dependency-groups]
dev = [
"dumpdie>=1.5.0",
"pytest>=8.0",
"pytest-asyncio>=0.24",
"httpx>=0.27",
]

[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
Loading
Loading