omgkit 2.1.1 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/plugin/skills/SKILL_STANDARDS.md +743 -0
- package/plugin/skills/databases/mongodb/SKILL.md +797 -28
- package/plugin/skills/databases/prisma/SKILL.md +776 -30
- package/plugin/skills/databases/redis/SKILL.md +885 -25
- package/plugin/skills/devops/aws/SKILL.md +686 -28
- package/plugin/skills/devops/github-actions/SKILL.md +684 -29
- package/plugin/skills/devops/kubernetes/SKILL.md +621 -24
- package/plugin/skills/frameworks/django/SKILL.md +920 -20
- package/plugin/skills/frameworks/express/SKILL.md +1361 -35
- package/plugin/skills/frameworks/fastapi/SKILL.md +1260 -33
- package/plugin/skills/frameworks/laravel/SKILL.md +1244 -31
- package/plugin/skills/frameworks/nestjs/SKILL.md +1005 -26
- package/plugin/skills/frameworks/rails/SKILL.md +594 -28
- package/plugin/skills/frameworks/spring/SKILL.md +528 -35
- package/plugin/skills/frameworks/vue/SKILL.md +1296 -27
- package/plugin/skills/frontend/accessibility/SKILL.md +1108 -34
- package/plugin/skills/frontend/frontend-design/SKILL.md +1304 -26
- package/plugin/skills/frontend/responsive/SKILL.md +847 -21
- package/plugin/skills/frontend/shadcn-ui/SKILL.md +976 -38
- package/plugin/skills/frontend/tailwindcss/SKILL.md +831 -35
- package/plugin/skills/frontend/threejs/SKILL.md +1298 -29
- package/plugin/skills/languages/javascript/SKILL.md +935 -31
- package/plugin/skills/methodology/brainstorming/SKILL.md +597 -23
- package/plugin/skills/methodology/defense-in-depth/SKILL.md +832 -34
- package/plugin/skills/methodology/dispatching-parallel-agents/SKILL.md +665 -31
- package/plugin/skills/methodology/executing-plans/SKILL.md +556 -24
- package/plugin/skills/methodology/finishing-development-branch/SKILL.md +595 -25
- package/plugin/skills/methodology/problem-solving/SKILL.md +429 -61
- package/plugin/skills/methodology/receiving-code-review/SKILL.md +536 -24
- package/plugin/skills/methodology/requesting-code-review/SKILL.md +632 -21
- package/plugin/skills/methodology/root-cause-tracing/SKILL.md +641 -30
- package/plugin/skills/methodology/sequential-thinking/SKILL.md +262 -3
- package/plugin/skills/methodology/systematic-debugging/SKILL.md +571 -32
- package/plugin/skills/methodology/test-driven-development/SKILL.md +779 -24
- package/plugin/skills/methodology/testing-anti-patterns/SKILL.md +691 -29
- package/plugin/skills/methodology/token-optimization/SKILL.md +598 -29
- package/plugin/skills/methodology/verification-before-completion/SKILL.md +543 -22
- package/plugin/skills/methodology/writing-plans/SKILL.md +590 -18
- package/plugin/skills/omega/omega-architecture/SKILL.md +838 -39
- package/plugin/skills/omega/omega-coding/SKILL.md +636 -39
- package/plugin/skills/omega/omega-sprint/SKILL.md +855 -48
- package/plugin/skills/omega/omega-testing/SKILL.md +940 -41
- package/plugin/skills/omega/omega-thinking/SKILL.md +703 -50
- package/plugin/skills/security/better-auth/SKILL.md +1065 -28
- package/plugin/skills/security/oauth/SKILL.md +968 -31
- package/plugin/skills/security/owasp/SKILL.md +894 -33
- package/plugin/skills/testing/playwright/SKILL.md +764 -38
- package/plugin/skills/testing/pytest/SKILL.md +873 -36
- package/plugin/skills/testing/vitest/SKILL.md +980 -35
|
@@ -1,58 +1,1285 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: fastapi
|
|
3
|
-
description: FastAPI development
|
|
3
|
+
description: Enterprise FastAPI development with async patterns, Pydantic v2, dependency injection, and production APIs
|
|
4
|
+
category: frameworks
|
|
5
|
+
triggers:
|
|
6
|
+
- fastapi
|
|
7
|
+
- fast api
|
|
8
|
+
- python api
|
|
9
|
+
- pydantic
|
|
10
|
+
- starlette
|
|
11
|
+
- async python
|
|
12
|
+
- python rest api
|
|
13
|
+
- uvicorn
|
|
4
14
|
---
|
|
5
15
|
|
|
6
|
-
# FastAPI
|
|
16
|
+
# FastAPI
|
|
17
|
+
|
|
18
|
+
Enterprise-grade **FastAPI development** following industry best practices. This skill covers async programming, Pydantic v2 validation, dependency injection, authentication, background tasks, testing patterns, and production deployment configurations used by top engineering teams.
|
|
19
|
+
|
|
20
|
+
## Purpose
|
|
21
|
+
|
|
22
|
+
Build high-performance Python APIs with confidence:
|
|
23
|
+
|
|
24
|
+
- Design async API architectures for maximum throughput
|
|
25
|
+
- Implement comprehensive request validation with Pydantic
|
|
26
|
+
- Use dependency injection for clean, testable code
|
|
27
|
+
- Handle authentication and authorization securely
|
|
28
|
+
- Write comprehensive tests with pytest
|
|
29
|
+
- Deploy production-ready applications
|
|
30
|
+
- Leverage automatic OpenAPI documentation
|
|
31
|
+
|
|
32
|
+
## Features
|
|
33
|
+
|
|
34
|
+
### 1. Application Setup and Configuration
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
# app/main.py
|
|
38
|
+
from contextlib import asynccontextmanager
|
|
39
|
+
from fastapi import FastAPI
|
|
40
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
41
|
+
from fastapi.middleware.gzip import GZipMiddleware
|
|
42
|
+
import uvicorn
|
|
43
|
+
|
|
44
|
+
from app.api.v1 import router as api_v1_router
|
|
45
|
+
from app.core.config import settings
|
|
46
|
+
from app.core.logging import setup_logging
|
|
47
|
+
from app.db.session import engine, async_session_maker
|
|
48
|
+
from app.db.base import Base
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@asynccontextmanager
|
|
52
|
+
async def lifespan(app: FastAPI):
|
|
53
|
+
"""Application lifespan events."""
|
|
54
|
+
# Startup
|
|
55
|
+
setup_logging()
|
|
56
|
+
async with engine.begin() as conn:
|
|
57
|
+
await conn.run_sync(Base.metadata.create_all)
|
|
58
|
+
yield
|
|
59
|
+
# Shutdown
|
|
60
|
+
await engine.dispose()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def create_app() -> FastAPI:
|
|
64
|
+
app = FastAPI(
|
|
65
|
+
title=settings.PROJECT_NAME,
|
|
66
|
+
description="Enterprise API",
|
|
67
|
+
version="1.0.0",
|
|
68
|
+
openapi_url=f"{settings.API_V1_PREFIX}/openapi.json",
|
|
69
|
+
docs_url=f"{settings.API_V1_PREFIX}/docs",
|
|
70
|
+
redoc_url=f"{settings.API_V1_PREFIX}/redoc",
|
|
71
|
+
lifespan=lifespan,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Middleware
|
|
75
|
+
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
|
76
|
+
app.add_middleware(
|
|
77
|
+
CORSMiddleware,
|
|
78
|
+
allow_origins=settings.CORS_ORIGINS,
|
|
79
|
+
allow_credentials=True,
|
|
80
|
+
allow_methods=["*"],
|
|
81
|
+
allow_headers=["*"],
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Routes
|
|
85
|
+
app.include_router(api_v1_router, prefix=settings.API_V1_PREFIX)
|
|
86
|
+
|
|
87
|
+
@app.get("/health")
|
|
88
|
+
async def health_check():
|
|
89
|
+
return {"status": "healthy"}
|
|
90
|
+
|
|
91
|
+
return app
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
app = create_app()
|
|
95
|
+
|
|
96
|
+
if __name__ == "__main__":
|
|
97
|
+
uvicorn.run(
|
|
98
|
+
"app.main:app",
|
|
99
|
+
host="0.0.0.0",
|
|
100
|
+
port=8000,
|
|
101
|
+
reload=settings.DEBUG,
|
|
102
|
+
workers=settings.WORKERS,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# app/core/config.py
|
|
107
|
+
from functools import lru_cache
|
|
108
|
+
from typing import List, Optional
|
|
109
|
+
from pydantic import Field, PostgresDsn, field_validator
|
|
110
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class Settings(BaseSettings):
|
|
114
|
+
model_config = SettingsConfigDict(
|
|
115
|
+
env_file=".env",
|
|
116
|
+
env_file_encoding="utf-8",
|
|
117
|
+
case_sensitive=True,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# Application
|
|
121
|
+
PROJECT_NAME: str = "FastAPI App"
|
|
122
|
+
DEBUG: bool = False
|
|
123
|
+
WORKERS: int = 4
|
|
124
|
+
API_V1_PREFIX: str = "/api/v1"
|
|
125
|
+
|
|
126
|
+
# Database
|
|
127
|
+
DATABASE_URL: PostgresDsn
|
|
128
|
+
DATABASE_POOL_SIZE: int = 5
|
|
129
|
+
DATABASE_MAX_OVERFLOW: int = 10
|
|
130
|
+
|
|
131
|
+
# Redis
|
|
132
|
+
REDIS_URL: str = "redis://localhost:6379/0"
|
|
133
|
+
|
|
134
|
+
# Security
|
|
135
|
+
SECRET_KEY: str
|
|
136
|
+
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
|
137
|
+
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
|
138
|
+
ALGORITHM: str = "HS256"
|
|
139
|
+
|
|
140
|
+
# CORS
|
|
141
|
+
CORS_ORIGINS: List[str] = ["http://localhost:3000"]
|
|
142
|
+
|
|
143
|
+
@field_validator("CORS_ORIGINS", mode="before")
|
|
144
|
+
@classmethod
|
|
145
|
+
def parse_cors_origins(cls, v: str | List[str]) -> List[str]:
|
|
146
|
+
if isinstance(v, str):
|
|
147
|
+
return [origin.strip() for origin in v.split(",")]
|
|
148
|
+
return v
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@lru_cache
|
|
152
|
+
def get_settings() -> Settings:
|
|
153
|
+
return Settings()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
settings = get_settings()
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### 2. Pydantic Schemas and Validation
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
# app/schemas/user.py
|
|
163
|
+
from datetime import datetime
|
|
164
|
+
from typing import Optional, List
|
|
165
|
+
from uuid import UUID
|
|
166
|
+
from pydantic import BaseModel, EmailStr, Field, field_validator, ConfigDict
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class UserBase(BaseModel):
|
|
170
|
+
email: EmailStr
|
|
171
|
+
name: str = Field(..., min_length=2, max_length=100)
|
|
172
|
+
is_active: bool = True
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class UserCreate(UserBase):
|
|
176
|
+
password: str = Field(..., min_length=8, max_length=128)
|
|
177
|
+
|
|
178
|
+
@field_validator("password")
|
|
179
|
+
@classmethod
|
|
180
|
+
def validate_password(cls, v: str) -> str:
|
|
181
|
+
if not any(c.isupper() for c in v):
|
|
182
|
+
raise ValueError("Password must contain uppercase letter")
|
|
183
|
+
if not any(c.isdigit() for c in v):
|
|
184
|
+
raise ValueError("Password must contain a digit")
|
|
185
|
+
if not any(c in "!@#$%^&*" for c in v):
|
|
186
|
+
raise ValueError("Password must contain a special character")
|
|
187
|
+
return v
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class UserUpdate(BaseModel):
|
|
191
|
+
email: Optional[EmailStr] = None
|
|
192
|
+
name: Optional[str] = Field(None, min_length=2, max_length=100)
|
|
193
|
+
is_active: Optional[bool] = None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class UserInDB(UserBase):
|
|
197
|
+
model_config = ConfigDict(from_attributes=True)
|
|
198
|
+
|
|
199
|
+
id: UUID
|
|
200
|
+
role: str
|
|
201
|
+
created_at: datetime
|
|
202
|
+
updated_at: datetime
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class UserResponse(UserInDB):
|
|
206
|
+
"""Response model excluding sensitive fields."""
|
|
207
|
+
pass
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class UserWithOrganizations(UserResponse):
|
|
211
|
+
organizations: List["OrganizationSummary"] = []
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# app/schemas/organization.py
|
|
215
|
+
from datetime import datetime
|
|
216
|
+
from typing import Optional, List
|
|
217
|
+
from uuid import UUID
|
|
218
|
+
from pydantic import BaseModel, Field, ConfigDict
|
|
219
|
+
from enum import Enum
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class MemberRole(str, Enum):
|
|
223
|
+
OWNER = "owner"
|
|
224
|
+
ADMIN = "admin"
|
|
225
|
+
MEMBER = "member"
|
|
226
|
+
VIEWER = "viewer"
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class OrganizationCreate(BaseModel):
|
|
230
|
+
name: str = Field(..., min_length=2, max_length=255)
|
|
231
|
+
slug: str = Field(..., min_length=2, max_length=100, pattern=r"^[a-z0-9-]+$")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class OrganizationUpdate(BaseModel):
|
|
235
|
+
name: Optional[str] = Field(None, min_length=2, max_length=255)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class OrganizationSummary(BaseModel):
|
|
239
|
+
model_config = ConfigDict(from_attributes=True)
|
|
240
|
+
|
|
241
|
+
id: UUID
|
|
242
|
+
name: str
|
|
243
|
+
slug: str
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class OrganizationResponse(OrganizationSummary):
|
|
247
|
+
owner_id: UUID
|
|
248
|
+
member_count: int = 0
|
|
249
|
+
created_at: datetime
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class MembershipResponse(BaseModel):
|
|
253
|
+
model_config = ConfigDict(from_attributes=True)
|
|
254
|
+
|
|
255
|
+
user_id: UUID
|
|
256
|
+
organization_id: UUID
|
|
257
|
+
role: MemberRole
|
|
258
|
+
joined_at: datetime
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# app/schemas/common.py
|
|
262
|
+
from typing import Generic, TypeVar, List, Optional
|
|
263
|
+
from pydantic import BaseModel, Field
|
|
264
|
+
|
|
265
|
+
T = TypeVar("T")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class PaginationParams(BaseModel):
|
|
269
|
+
page: int = Field(1, ge=1)
|
|
270
|
+
limit: int = Field(20, ge=1, le=100)
|
|
271
|
+
|
|
272
|
+
@property
|
|
273
|
+
def offset(self) -> int:
|
|
274
|
+
return (self.page - 1) * self.limit
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class PaginatedResponse(BaseModel, Generic[T]):
|
|
278
|
+
data: List[T]
|
|
279
|
+
total: int
|
|
280
|
+
page: int
|
|
281
|
+
limit: int
|
|
282
|
+
total_pages: int
|
|
283
|
+
has_more: bool
|
|
284
|
+
|
|
285
|
+
@classmethod
|
|
286
|
+
def create(
|
|
287
|
+
cls,
|
|
288
|
+
data: List[T],
|
|
289
|
+
total: int,
|
|
290
|
+
page: int,
|
|
291
|
+
limit: int,
|
|
292
|
+
) -> "PaginatedResponse[T]":
|
|
293
|
+
total_pages = (total + limit - 1) // limit
|
|
294
|
+
return cls(
|
|
295
|
+
data=data,
|
|
296
|
+
total=total,
|
|
297
|
+
page=page,
|
|
298
|
+
limit=limit,
|
|
299
|
+
total_pages=total_pages,
|
|
300
|
+
has_more=page < total_pages,
|
|
301
|
+
)
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
### 3. Dependency Injection
|
|
7
305
|
|
|
8
|
-
## Setup
|
|
9
306
|
```python
|
|
10
|
-
|
|
11
|
-
from
|
|
307
|
+
# app/api/deps.py
|
|
308
|
+
from typing import Annotated, AsyncGenerator, Optional
|
|
309
|
+
from fastapi import Depends, HTTPException, status, Query
|
|
310
|
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|
311
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
312
|
+
from jose import JWTError, jwt
|
|
313
|
+
from uuid import UUID
|
|
314
|
+
|
|
315
|
+
from app.core.config import settings
|
|
316
|
+
from app.db.session import async_session_maker
|
|
317
|
+
from app.models.user import User
|
|
318
|
+
from app.schemas.common import PaginationParams
|
|
319
|
+
from app.services.user import UserService
|
|
320
|
+
from app.services.organization import OrganizationService
|
|
321
|
+
from app.core.redis import redis_client
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
security = HTTPBearer()
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
|
328
|
+
"""Database session dependency."""
|
|
329
|
+
async with async_session_maker() as session:
|
|
330
|
+
try:
|
|
331
|
+
yield session
|
|
332
|
+
finally:
|
|
333
|
+
await session.close()
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
async def get_redis():
|
|
337
|
+
"""Redis client dependency."""
|
|
338
|
+
return redis_client
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def get_pagination(
|
|
342
|
+
page: int = Query(1, ge=1, description="Page number"),
|
|
343
|
+
limit: int = Query(20, ge=1, le=100, description="Items per page"),
|
|
344
|
+
) -> PaginationParams:
|
|
345
|
+
"""Pagination parameters dependency."""
|
|
346
|
+
return PaginationParams(page=page, limit=limit)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
async def get_current_user(
|
|
350
|
+
credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)],
|
|
351
|
+
db: Annotated[AsyncSession, Depends(get_db)],
|
|
352
|
+
) -> User:
|
|
353
|
+
"""Get current authenticated user."""
|
|
354
|
+
credentials_exception = HTTPException(
|
|
355
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
356
|
+
detail="Could not validate credentials",
|
|
357
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
try:
|
|
361
|
+
payload = jwt.decode(
|
|
362
|
+
credentials.credentials,
|
|
363
|
+
settings.SECRET_KEY,
|
|
364
|
+
algorithms=[settings.ALGORITHM],
|
|
365
|
+
)
|
|
366
|
+
user_id: str = payload.get("sub")
|
|
367
|
+
if user_id is None:
|
|
368
|
+
raise credentials_exception
|
|
369
|
+
except JWTError:
|
|
370
|
+
raise credentials_exception
|
|
371
|
+
|
|
372
|
+
user_service = UserService(db)
|
|
373
|
+
user = await user_service.get_by_id(UUID(user_id))
|
|
374
|
+
|
|
375
|
+
if user is None:
|
|
376
|
+
raise credentials_exception
|
|
377
|
+
if not user.is_active:
|
|
378
|
+
raise HTTPException(
|
|
379
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
380
|
+
detail="Inactive user",
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
return user
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
async def get_current_active_user(
|
|
387
|
+
current_user: Annotated[User, Depends(get_current_user)],
|
|
388
|
+
) -> User:
|
|
389
|
+
"""Get current active user."""
|
|
390
|
+
if not current_user.is_active:
|
|
391
|
+
raise HTTPException(status_code=400, detail="Inactive user")
|
|
392
|
+
return current_user
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def require_role(*roles: str):
|
|
396
|
+
"""Role-based access control dependency factory."""
|
|
397
|
+
async def role_checker(
|
|
398
|
+
current_user: Annotated[User, Depends(get_current_user)],
|
|
399
|
+
) -> User:
|
|
400
|
+
if current_user.role not in roles:
|
|
401
|
+
raise HTTPException(
|
|
402
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
403
|
+
detail="Insufficient permissions",
|
|
404
|
+
)
|
|
405
|
+
return current_user
|
|
406
|
+
return role_checker
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
async def get_organization_member(
|
|
410
|
+
org_slug: str,
|
|
411
|
+
current_user: Annotated[User, Depends(get_current_user)],
|
|
412
|
+
db: Annotated[AsyncSession, Depends(get_db)],
|
|
413
|
+
):
|
|
414
|
+
"""Verify user is a member of the organization."""
|
|
415
|
+
org_service = OrganizationService(db)
|
|
416
|
+
organization = await org_service.get_by_slug(org_slug)
|
|
417
|
+
|
|
418
|
+
if not organization:
|
|
419
|
+
raise HTTPException(
|
|
420
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
421
|
+
detail="Organization not found",
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
membership = await org_service.get_membership(organization.id, current_user.id)
|
|
425
|
+
if not membership:
|
|
426
|
+
raise HTTPException(
|
|
427
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
428
|
+
detail="Not a member of this organization",
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
return {"organization": organization, "membership": membership}
|
|
12
432
|
|
|
13
|
-
|
|
433
|
+
|
|
434
|
+
# Type aliases for cleaner signatures
|
|
435
|
+
DB = Annotated[AsyncSession, Depends(get_db)]
|
|
436
|
+
CurrentUser = Annotated[User, Depends(get_current_user)]
|
|
437
|
+
Pagination = Annotated[PaginationParams, Depends(get_pagination)]
|
|
438
|
+
AdminUser = Annotated[User, Depends(require_role("admin"))]
|
|
14
439
|
```
|
|
15
440
|
|
|
16
|
-
|
|
441
|
+
### 4. Routes and Endpoints
|
|
17
442
|
|
|
18
|
-
### Route with Pydantic
|
|
19
443
|
```python
|
|
20
|
-
|
|
21
|
-
|
|
444
|
+
# app/api/v1/__init__.py
|
|
445
|
+
from fastapi import APIRouter
|
|
446
|
+
from app.api.v1 import auth, users, organizations, projects
|
|
447
|
+
|
|
448
|
+
router = APIRouter()
|
|
449
|
+
|
|
450
|
+
router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
|
451
|
+
router.include_router(users.router, prefix="/users", tags=["users"])
|
|
452
|
+
router.include_router(organizations.router, prefix="/organizations", tags=["organizations"])
|
|
453
|
+
router.include_router(projects.router, prefix="/projects", tags=["projects"])
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
# app/api/v1/users.py
|
|
457
|
+
from typing import Optional
|
|
458
|
+
from uuid import UUID
|
|
459
|
+
from fastapi import APIRouter, HTTPException, status, Query
|
|
460
|
+
|
|
461
|
+
from app.api.deps import DB, CurrentUser, Pagination, AdminUser
|
|
462
|
+
from app.schemas.user import UserCreate, UserUpdate, UserResponse, UserWithOrganizations
|
|
463
|
+
from app.schemas.common import PaginatedResponse
|
|
464
|
+
from app.services.user import UserService
|
|
465
|
+
|
|
466
|
+
router = APIRouter()
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
@router.get("/", response_model=PaginatedResponse[UserResponse])
|
|
470
|
+
async def list_users(
|
|
471
|
+
db: DB,
|
|
472
|
+
current_user: AdminUser,
|
|
473
|
+
pagination: Pagination,
|
|
474
|
+
search: Optional[str] = Query(None, min_length=2),
|
|
475
|
+
role: Optional[str] = None,
|
|
476
|
+
is_active: Optional[bool] = None,
|
|
477
|
+
):
|
|
478
|
+
"""List all users (admin only)."""
|
|
479
|
+
service = UserService(db)
|
|
480
|
+
users, total = await service.list(
|
|
481
|
+
offset=pagination.offset,
|
|
482
|
+
limit=pagination.limit,
|
|
483
|
+
search=search,
|
|
484
|
+
role=role,
|
|
485
|
+
is_active=is_active,
|
|
486
|
+
)
|
|
487
|
+
return PaginatedResponse.create(
|
|
488
|
+
data=users,
|
|
489
|
+
total=total,
|
|
490
|
+
page=pagination.page,
|
|
491
|
+
limit=pagination.limit,
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
@router.get("/me", response_model=UserWithOrganizations)
|
|
496
|
+
async def get_current_user(db: DB, current_user: CurrentUser):
|
|
497
|
+
"""Get current user profile."""
|
|
498
|
+
service = UserService(db)
|
|
499
|
+
return await service.get_with_organizations(current_user.id)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
@router.patch("/me", response_model=UserResponse)
|
|
503
|
+
async def update_current_user(
|
|
504
|
+
db: DB,
|
|
505
|
+
current_user: CurrentUser,
|
|
506
|
+
user_in: UserUpdate,
|
|
507
|
+
):
|
|
508
|
+
"""Update current user profile."""
|
|
509
|
+
service = UserService(db)
|
|
510
|
+
return await service.update(current_user.id, user_in)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
@router.get("/{user_id}", response_model=UserResponse)
|
|
514
|
+
async def get_user(db: DB, current_user: AdminUser, user_id: UUID):
|
|
515
|
+
"""Get user by ID (admin only)."""
|
|
516
|
+
service = UserService(db)
|
|
517
|
+
user = await service.get_by_id(user_id)
|
|
518
|
+
if not user:
|
|
519
|
+
raise HTTPException(
|
|
520
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
521
|
+
detail="User not found",
|
|
522
|
+
)
|
|
523
|
+
return user
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
|
527
|
+
async def create_user(db: DB, current_user: AdminUser, user_in: UserCreate):
|
|
528
|
+
"""Create new user (admin only)."""
|
|
529
|
+
service = UserService(db)
|
|
530
|
+
existing = await service.get_by_email(user_in.email)
|
|
531
|
+
if existing:
|
|
532
|
+
raise HTTPException(
|
|
533
|
+
status_code=status.HTTP_409_CONFLICT,
|
|
534
|
+
detail="Email already registered",
|
|
535
|
+
)
|
|
536
|
+
return await service.create(user_in)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
540
|
+
async def delete_user(db: DB, current_user: AdminUser, user_id: UUID):
|
|
541
|
+
"""Delete user (admin only)."""
|
|
542
|
+
service = UserService(db)
|
|
543
|
+
user = await service.get_by_id(user_id)
|
|
544
|
+
if not user:
|
|
545
|
+
raise HTTPException(
|
|
546
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
547
|
+
detail="User not found",
|
|
548
|
+
)
|
|
549
|
+
await service.delete(user_id)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
# app/api/v1/auth.py
|
|
553
|
+
from datetime import timedelta
|
|
554
|
+
from fastapi import APIRouter, HTTPException, status, BackgroundTasks
|
|
555
|
+
from pydantic import BaseModel, EmailStr
|
|
556
|
+
|
|
557
|
+
from app.api.deps import DB
|
|
558
|
+
from app.core.config import settings
|
|
559
|
+
from app.core.security import create_access_token, create_refresh_token, verify_password
|
|
560
|
+
from app.services.user import UserService
|
|
561
|
+
from app.schemas.user import UserCreate, UserResponse
|
|
562
|
+
|
|
563
|
+
router = APIRouter()
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
class LoginRequest(BaseModel):
|
|
567
|
+
email: EmailStr
|
|
22
568
|
password: str
|
|
23
569
|
|
|
24
|
-
class User(BaseModel):
|
|
25
|
-
id: str
|
|
26
|
-
email: str
|
|
27
570
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
571
|
+
class TokenResponse(BaseModel):
|
|
572
|
+
access_token: str
|
|
573
|
+
refresh_token: str
|
|
574
|
+
token_type: str = "bearer"
|
|
575
|
+
user: UserResponse
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
class RefreshRequest(BaseModel):
|
|
579
|
+
refresh_token: str
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
|
583
|
+
async def register(
|
|
584
|
+
db: DB,
|
|
585
|
+
user_in: UserCreate,
|
|
586
|
+
background_tasks: BackgroundTasks,
|
|
587
|
+
):
|
|
588
|
+
"""Register a new user."""
|
|
589
|
+
service = UserService(db)
|
|
590
|
+
existing = await service.get_by_email(user_in.email)
|
|
591
|
+
if existing:
|
|
592
|
+
raise HTTPException(
|
|
593
|
+
status_code=status.HTTP_409_CONFLICT,
|
|
594
|
+
detail="Email already registered",
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
user = await service.create(user_in)
|
|
598
|
+
|
|
599
|
+
# Send welcome email in background
|
|
600
|
+
background_tasks.add_task(send_welcome_email, user.email, user.name)
|
|
601
|
+
|
|
602
|
+
return user
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
@router.post("/login", response_model=TokenResponse)
|
|
606
|
+
async def login(db: DB, login_data: LoginRequest):
|
|
607
|
+
"""Authenticate user and return tokens."""
|
|
608
|
+
service = UserService(db)
|
|
609
|
+
user = await service.get_by_email(login_data.email)
|
|
610
|
+
|
|
611
|
+
if not user or not verify_password(login_data.password, user.hashed_password):
|
|
612
|
+
raise HTTPException(
|
|
613
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
614
|
+
detail="Incorrect email or password",
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
if not user.is_active:
|
|
618
|
+
raise HTTPException(
|
|
619
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
620
|
+
detail="Inactive account",
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
access_token = create_access_token(
|
|
624
|
+
subject=str(user.id),
|
|
625
|
+
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES),
|
|
626
|
+
)
|
|
627
|
+
refresh_token = create_refresh_token(
|
|
628
|
+
subject=str(user.id),
|
|
629
|
+
expires_delta=timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS),
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
return TokenResponse(
|
|
633
|
+
access_token=access_token,
|
|
634
|
+
refresh_token=refresh_token,
|
|
635
|
+
user=user,
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
@router.post("/refresh", response_model=TokenResponse)
|
|
640
|
+
async def refresh_token(db: DB, refresh_data: RefreshRequest):
|
|
641
|
+
"""Refresh access token."""
|
|
642
|
+
# Verify and decode refresh token
|
|
643
|
+
# Generate new access token
|
|
644
|
+
pass
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
async def send_welcome_email(email: str, name: str):
|
|
648
|
+
"""Background task to send welcome email."""
|
|
649
|
+
# Implement email sending logic
|
|
650
|
+
pass
|
|
31
651
|
```
|
|
32
652
|
|
|
33
|
-
###
|
|
653
|
+
### 5. Service Layer
|
|
654
|
+
|
|
34
655
|
```python
|
|
35
|
-
|
|
36
|
-
|
|
656
|
+
# app/services/user.py
|
|
657
|
+
from typing import Optional, List, Tuple
|
|
658
|
+
from uuid import UUID
|
|
659
|
+
from sqlalchemy import select, func, or_
|
|
660
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
661
|
+
from sqlalchemy.orm import selectinload
|
|
662
|
+
|
|
663
|
+
from app.models.user import User
|
|
664
|
+
from app.models.membership import Membership
|
|
665
|
+
from app.schemas.user import UserCreate, UserUpdate
|
|
666
|
+
from app.core.security import get_password_hash
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
class UserService:
|
|
670
|
+
def __init__(self, db: AsyncSession):
|
|
671
|
+
self.db = db
|
|
672
|
+
|
|
673
|
+
async def get_by_id(self, user_id: UUID) -> Optional[User]:
|
|
674
|
+
result = await self.db.execute(
|
|
675
|
+
select(User).where(User.id == user_id, User.deleted_at.is_(None))
|
|
676
|
+
)
|
|
677
|
+
return result.scalar_one_or_none()
|
|
678
|
+
|
|
679
|
+
async def get_by_email(self, email: str) -> Optional[User]:
|
|
680
|
+
result = await self.db.execute(
|
|
681
|
+
select(User).where(User.email == email, User.deleted_at.is_(None))
|
|
682
|
+
)
|
|
683
|
+
return result.scalar_one_or_none()
|
|
684
|
+
|
|
685
|
+
async def get_with_organizations(self, user_id: UUID) -> Optional[User]:
|
|
686
|
+
result = await self.db.execute(
|
|
687
|
+
select(User)
|
|
688
|
+
.options(selectinload(User.memberships).selectinload(Membership.organization))
|
|
689
|
+
.where(User.id == user_id, User.deleted_at.is_(None))
|
|
690
|
+
)
|
|
691
|
+
return result.scalar_one_or_none()
|
|
692
|
+
|
|
693
|
+
async def list(
|
|
694
|
+
self,
|
|
695
|
+
offset: int = 0,
|
|
696
|
+
limit: int = 20,
|
|
697
|
+
search: Optional[str] = None,
|
|
698
|
+
role: Optional[str] = None,
|
|
699
|
+
is_active: Optional[bool] = None,
|
|
700
|
+
) -> Tuple[List[User], int]:
|
|
701
|
+
query = select(User).where(User.deleted_at.is_(None))
|
|
702
|
+
|
|
703
|
+
if search:
|
|
704
|
+
query = query.where(
|
|
705
|
+
or_(
|
|
706
|
+
User.name.ilike(f"%{search}%"),
|
|
707
|
+
User.email.ilike(f"%{search}%"),
|
|
708
|
+
)
|
|
709
|
+
)
|
|
710
|
+
|
|
711
|
+
if role:
|
|
712
|
+
query = query.where(User.role == role)
|
|
713
|
+
|
|
714
|
+
if is_active is not None:
|
|
715
|
+
query = query.where(User.is_active == is_active)
|
|
716
|
+
|
|
717
|
+
# Get total count
|
|
718
|
+
count_query = select(func.count()).select_from(query.subquery())
|
|
719
|
+
total_result = await self.db.execute(count_query)
|
|
720
|
+
total = total_result.scalar()
|
|
721
|
+
|
|
722
|
+
# Get paginated results
|
|
723
|
+
query = query.order_by(User.created_at.desc()).offset(offset).limit(limit)
|
|
724
|
+
result = await self.db.execute(query)
|
|
725
|
+
users = list(result.scalars().all())
|
|
726
|
+
|
|
727
|
+
return users, total
|
|
728
|
+
|
|
729
|
+
async def create(self, user_in: UserCreate) -> User:
|
|
730
|
+
user = User(
|
|
731
|
+
email=user_in.email,
|
|
732
|
+
name=user_in.name,
|
|
733
|
+
hashed_password=get_password_hash(user_in.password),
|
|
734
|
+
role="user",
|
|
735
|
+
is_active=True,
|
|
736
|
+
)
|
|
737
|
+
self.db.add(user)
|
|
738
|
+
await self.db.commit()
|
|
739
|
+
await self.db.refresh(user)
|
|
740
|
+
return user
|
|
741
|
+
|
|
742
|
+
async def update(self, user_id: UUID, user_in: UserUpdate) -> Optional[User]:
|
|
743
|
+
user = await self.get_by_id(user_id)
|
|
744
|
+
if not user:
|
|
745
|
+
return None
|
|
746
|
+
|
|
747
|
+
update_data = user_in.model_dump(exclude_unset=True)
|
|
748
|
+
for field, value in update_data.items():
|
|
749
|
+
setattr(user, field, value)
|
|
750
|
+
|
|
751
|
+
await self.db.commit()
|
|
752
|
+
await self.db.refresh(user)
|
|
753
|
+
return user
|
|
754
|
+
|
|
755
|
+
async def delete(self, user_id: UUID) -> bool:
|
|
756
|
+
user = await self.get_by_id(user_id)
|
|
757
|
+
if not user:
|
|
758
|
+
return False
|
|
759
|
+
|
|
760
|
+
# Soft delete
|
|
761
|
+
from datetime import datetime
|
|
762
|
+
user.deleted_at = datetime.utcnow()
|
|
763
|
+
await self.db.commit()
|
|
764
|
+
return True
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
# app/services/organization.py
|
|
768
|
+
from typing import Optional, List, Tuple
|
|
769
|
+
from uuid import UUID
|
|
770
|
+
from sqlalchemy import select, func
|
|
771
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
772
|
+
from sqlalchemy.orm import selectinload
|
|
773
|
+
|
|
774
|
+
from app.models.organization import Organization
|
|
775
|
+
from app.models.membership import Membership
|
|
776
|
+
from app.schemas.organization import OrganizationCreate, OrganizationUpdate, MemberRole
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
class OrganizationService:
|
|
780
|
+
def __init__(self, db: AsyncSession):
|
|
781
|
+
self.db = db
|
|
782
|
+
|
|
783
|
+
async def get_by_id(self, org_id: UUID) -> Optional[Organization]:
|
|
784
|
+
result = await self.db.execute(
|
|
785
|
+
select(Organization).where(Organization.id == org_id)
|
|
786
|
+
)
|
|
787
|
+
return result.scalar_one_or_none()
|
|
788
|
+
|
|
789
|
+
async def get_by_slug(self, slug: str) -> Optional[Organization]:
|
|
790
|
+
result = await self.db.execute(
|
|
791
|
+
select(Organization).where(Organization.slug == slug)
|
|
792
|
+
)
|
|
793
|
+
return result.scalar_one_or_none()
|
|
794
|
+
|
|
795
|
+
async def get_membership(
|
|
796
|
+
self, org_id: UUID, user_id: UUID
|
|
797
|
+
) -> Optional[Membership]:
|
|
798
|
+
result = await self.db.execute(
|
|
799
|
+
select(Membership).where(
|
|
800
|
+
Membership.organization_id == org_id,
|
|
801
|
+
Membership.user_id == user_id,
|
|
802
|
+
)
|
|
803
|
+
)
|
|
804
|
+
return result.scalar_one_or_none()
|
|
805
|
+
|
|
806
|
+
async def list_for_user(
|
|
807
|
+
self, user_id: UUID, offset: int = 0, limit: int = 20
|
|
808
|
+
) -> Tuple[List[Organization], int]:
|
|
809
|
+
query = (
|
|
810
|
+
select(Organization)
|
|
811
|
+
.join(Membership)
|
|
812
|
+
.where(Membership.user_id == user_id)
|
|
813
|
+
)
|
|
814
|
+
|
|
815
|
+
count_result = await self.db.execute(
|
|
816
|
+
select(func.count()).select_from(query.subquery())
|
|
817
|
+
)
|
|
818
|
+
total = count_result.scalar()
|
|
819
|
+
|
|
820
|
+
result = await self.db.execute(
|
|
821
|
+
query.order_by(Organization.created_at.desc())
|
|
822
|
+
.offset(offset)
|
|
823
|
+
.limit(limit)
|
|
824
|
+
)
|
|
825
|
+
organizations = list(result.scalars().all())
|
|
826
|
+
|
|
827
|
+
return organizations, total
|
|
828
|
+
|
|
829
|
+
async def create(
|
|
830
|
+
self, org_in: OrganizationCreate, owner_id: UUID
|
|
831
|
+
) -> Organization:
|
|
832
|
+
org = Organization(
|
|
833
|
+
name=org_in.name,
|
|
834
|
+
slug=org_in.slug,
|
|
835
|
+
owner_id=owner_id,
|
|
836
|
+
)
|
|
837
|
+
self.db.add(org)
|
|
838
|
+
await self.db.flush()
|
|
839
|
+
|
|
840
|
+
# Create owner membership
|
|
841
|
+
membership = Membership(
|
|
842
|
+
user_id=owner_id,
|
|
843
|
+
organization_id=org.id,
|
|
844
|
+
role=MemberRole.OWNER,
|
|
845
|
+
)
|
|
846
|
+
self.db.add(membership)
|
|
847
|
+
|
|
848
|
+
await self.db.commit()
|
|
849
|
+
await self.db.refresh(org)
|
|
850
|
+
return org
|
|
851
|
+
|
|
852
|
+
async def add_member(
|
|
853
|
+
self, org_id: UUID, user_id: UUID, role: MemberRole = MemberRole.MEMBER
|
|
854
|
+
) -> Membership:
|
|
855
|
+
membership = Membership(
|
|
856
|
+
user_id=user_id,
|
|
857
|
+
organization_id=org_id,
|
|
858
|
+
role=role,
|
|
859
|
+
)
|
|
860
|
+
self.db.add(membership)
|
|
861
|
+
await self.db.commit()
|
|
862
|
+
await self.db.refresh(membership)
|
|
863
|
+
return membership
|
|
864
|
+
```
|
|
865
|
+
|
|
866
|
+
### 6. Database Models
|
|
867
|
+
|
|
868
|
+
```python
|
|
869
|
+
# app/models/user.py
|
|
870
|
+
from datetime import datetime
|
|
871
|
+
from typing import List, TYPE_CHECKING
|
|
872
|
+
from uuid import uuid4
|
|
873
|
+
from sqlalchemy import String, Boolean, DateTime
|
|
874
|
+
from sqlalchemy.dialects.postgresql import UUID
|
|
875
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
876
|
+
|
|
877
|
+
from app.db.base import Base
|
|
878
|
+
|
|
879
|
+
if TYPE_CHECKING:
|
|
880
|
+
from app.models.membership import Membership
|
|
881
|
+
from app.models.organization import Organization
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
class User(Base):
|
|
885
|
+
__tablename__ = "users"
|
|
886
|
+
|
|
887
|
+
id: Mapped[UUID] = mapped_column(
|
|
888
|
+
UUID(as_uuid=True), primary_key=True, default=uuid4
|
|
889
|
+
)
|
|
890
|
+
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
|
891
|
+
name: Mapped[str] = mapped_column(String(100))
|
|
892
|
+
hashed_password: Mapped[str] = mapped_column(String(255))
|
|
893
|
+
role: Mapped[str] = mapped_column(String(50), default="user")
|
|
894
|
+
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
|
895
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
896
|
+
DateTime, default=datetime.utcnow
|
|
897
|
+
)
|
|
898
|
+
updated_at: Mapped[datetime] = mapped_column(
|
|
899
|
+
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
|
|
900
|
+
)
|
|
901
|
+
deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
|
902
|
+
|
|
903
|
+
# Relationships
|
|
904
|
+
memberships: Mapped[List["Membership"]] = relationship(
|
|
905
|
+
"Membership", back_populates="user", lazy="selectin"
|
|
906
|
+
)
|
|
907
|
+
owned_organizations: Mapped[List["Organization"]] = relationship(
|
|
908
|
+
"Organization", back_populates="owner", foreign_keys="Organization.owner_id"
|
|
909
|
+
)
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
# app/models/organization.py
|
|
913
|
+
from datetime import datetime
|
|
914
|
+
from typing import List, TYPE_CHECKING
|
|
915
|
+
from uuid import uuid4
|
|
916
|
+
from sqlalchemy import String, DateTime, ForeignKey
|
|
917
|
+
from sqlalchemy.dialects.postgresql import UUID
|
|
918
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
919
|
+
|
|
920
|
+
from app.db.base import Base
|
|
921
|
+
|
|
922
|
+
if TYPE_CHECKING:
|
|
923
|
+
from app.models.user import User
|
|
924
|
+
from app.models.membership import Membership
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
class Organization(Base):
|
|
928
|
+
__tablename__ = "organizations"
|
|
929
|
+
|
|
930
|
+
id: Mapped[UUID] = mapped_column(
|
|
931
|
+
UUID(as_uuid=True), primary_key=True, default=uuid4
|
|
932
|
+
)
|
|
933
|
+
name: Mapped[str] = mapped_column(String(255))
|
|
934
|
+
slug: Mapped[str] = mapped_column(String(100), unique=True, index=True)
|
|
935
|
+
owner_id: Mapped[UUID] = mapped_column(
|
|
936
|
+
UUID(as_uuid=True), ForeignKey("users.id")
|
|
937
|
+
)
|
|
938
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
939
|
+
DateTime, default=datetime.utcnow
|
|
940
|
+
)
|
|
941
|
+
|
|
942
|
+
# Relationships
|
|
943
|
+
owner: Mapped["User"] = relationship(
|
|
944
|
+
"User", back_populates="owned_organizations", foreign_keys=[owner_id]
|
|
945
|
+
)
|
|
946
|
+
memberships: Mapped[List["Membership"]] = relationship(
|
|
947
|
+
"Membership", back_populates="organization"
|
|
948
|
+
)
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
# app/models/membership.py
|
|
952
|
+
from datetime import datetime
|
|
953
|
+
from uuid import uuid4
|
|
954
|
+
from sqlalchemy import String, DateTime, ForeignKey, UniqueConstraint
|
|
955
|
+
from sqlalchemy.dialects.postgresql import UUID
|
|
956
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
957
|
+
|
|
958
|
+
from app.db.base import Base
|
|
959
|
+
from app.models.user import User
|
|
960
|
+
from app.models.organization import Organization
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
class Membership(Base):
|
|
964
|
+
__tablename__ = "memberships"
|
|
965
|
+
__table_args__ = (
|
|
966
|
+
UniqueConstraint("user_id", "organization_id", name="uq_user_org"),
|
|
967
|
+
)
|
|
968
|
+
|
|
969
|
+
id: Mapped[UUID] = mapped_column(
|
|
970
|
+
UUID(as_uuid=True), primary_key=True, default=uuid4
|
|
971
|
+
)
|
|
972
|
+
user_id: Mapped[UUID] = mapped_column(
|
|
973
|
+
UUID(as_uuid=True), ForeignKey("users.id")
|
|
974
|
+
)
|
|
975
|
+
organization_id: Mapped[UUID] = mapped_column(
|
|
976
|
+
UUID(as_uuid=True), ForeignKey("organizations.id")
|
|
977
|
+
)
|
|
978
|
+
role: Mapped[str] = mapped_column(String(50), default="member")
|
|
979
|
+
joined_at: Mapped[datetime] = mapped_column(
|
|
980
|
+
DateTime, default=datetime.utcnow
|
|
981
|
+
)
|
|
982
|
+
|
|
983
|
+
# Relationships
|
|
984
|
+
user: Mapped["User"] = relationship("User", back_populates="memberships")
|
|
985
|
+
organization: Mapped["Organization"] = relationship(
|
|
986
|
+
"Organization", back_populates="memberships"
|
|
987
|
+
)
|
|
988
|
+
```
|
|
989
|
+
|
|
990
|
+
### 7. Testing Patterns
|
|
991
|
+
|
|
992
|
+
```python
|
|
993
|
+
# tests/conftest.py
|
|
994
|
+
import asyncio
|
|
995
|
+
from typing import AsyncGenerator
|
|
996
|
+
import pytest
|
|
997
|
+
from httpx import AsyncClient, ASGITransport
|
|
998
|
+
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
|
999
|
+
from sqlalchemy.orm import sessionmaker
|
|
1000
|
+
|
|
1001
|
+
from app.main import app
|
|
1002
|
+
from app.db.base import Base
|
|
1003
|
+
from app.api.deps import get_db
|
|
1004
|
+
from app.core.security import create_access_token
|
|
1005
|
+
|
|
1006
|
+
TEST_DATABASE_URL = "sqlite+aiosqlite:///./test.db"
|
|
1007
|
+
|
|
1008
|
+
engine = create_async_engine(TEST_DATABASE_URL, echo=True)
|
|
1009
|
+
TestingSessionLocal = sessionmaker(
|
|
1010
|
+
engine, class_=AsyncSession, expire_on_commit=False
|
|
1011
|
+
)
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
@pytest.fixture(scope="session")
|
|
1015
|
+
def event_loop():
|
|
1016
|
+
loop = asyncio.get_event_loop_policy().new_event_loop()
|
|
1017
|
+
yield loop
|
|
1018
|
+
loop.close()
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
@pytest.fixture(scope="function")
|
|
1022
|
+
async def db_session() -> AsyncGenerator[AsyncSession, None]:
|
|
1023
|
+
async with engine.begin() as conn:
|
|
1024
|
+
await conn.run_sync(Base.metadata.create_all)
|
|
1025
|
+
|
|
1026
|
+
async with TestingSessionLocal() as session:
|
|
1027
|
+
yield session
|
|
1028
|
+
|
|
1029
|
+
async with engine.begin() as conn:
|
|
1030
|
+
await conn.run_sync(Base.metadata.drop_all)
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
@pytest.fixture(scope="function")
|
|
1034
|
+
async def client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]:
|
|
1035
|
+
async def override_get_db():
|
|
1036
|
+
yield db_session
|
|
1037
|
+
|
|
1038
|
+
app.dependency_overrides[get_db] = override_get_db
|
|
1039
|
+
|
|
1040
|
+
async with AsyncClient(
|
|
1041
|
+
transport=ASGITransport(app=app),
|
|
1042
|
+
base_url="http://test",
|
|
1043
|
+
) as ac:
|
|
1044
|
+
yield ac
|
|
1045
|
+
|
|
1046
|
+
app.dependency_overrides.clear()
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
@pytest.fixture
|
|
1050
|
+
async def test_user(db_session: AsyncSession):
|
|
1051
|
+
from app.models.user import User
|
|
1052
|
+
from app.core.security import get_password_hash
|
|
1053
|
+
|
|
1054
|
+
user = User(
|
|
1055
|
+
email="test@example.com",
|
|
1056
|
+
name="Test User",
|
|
1057
|
+
hashed_password=get_password_hash("TestPass123!"),
|
|
1058
|
+
role="user",
|
|
1059
|
+
is_active=True,
|
|
1060
|
+
)
|
|
1061
|
+
db_session.add(user)
|
|
1062
|
+
await db_session.commit()
|
|
1063
|
+
await db_session.refresh(user)
|
|
1064
|
+
return user
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
@pytest.fixture
|
|
1068
|
+
def auth_headers(test_user):
|
|
1069
|
+
token = create_access_token(subject=str(test_user.id))
|
|
1070
|
+
return {"Authorization": f"Bearer {token}"}
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
# tests/test_users.py
|
|
1074
|
+
import pytest
|
|
1075
|
+
from httpx import AsyncClient
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
@pytest.mark.asyncio
|
|
1079
|
+
async def test_get_current_user(client: AsyncClient, auth_headers: dict):
|
|
1080
|
+
response = await client.get("/api/v1/users/me", headers=auth_headers)
|
|
1081
|
+
assert response.status_code == 200
|
|
1082
|
+
data = response.json()
|
|
1083
|
+
assert data["email"] == "test@example.com"
|
|
1084
|
+
assert "hashed_password" not in data
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
@pytest.mark.asyncio
|
|
1088
|
+
async def test_update_current_user(client: AsyncClient, auth_headers: dict):
|
|
1089
|
+
response = await client.patch(
|
|
1090
|
+
"/api/v1/users/me",
|
|
1091
|
+
headers=auth_headers,
|
|
1092
|
+
json={"name": "Updated Name"},
|
|
1093
|
+
)
|
|
1094
|
+
assert response.status_code == 200
|
|
1095
|
+
assert response.json()["name"] == "Updated Name"
|
|
1096
|
+
|
|
1097
|
+
|
|
1098
|
+
@pytest.mark.asyncio
|
|
1099
|
+
async def test_unauthorized_access(client: AsyncClient):
|
|
1100
|
+
response = await client.get("/api/v1/users/me")
|
|
1101
|
+
assert response.status_code == 403
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
@pytest.mark.asyncio
|
|
1105
|
+
async def test_register_user(client: AsyncClient):
|
|
1106
|
+
response = await client.post(
|
|
1107
|
+
"/api/v1/auth/register",
|
|
1108
|
+
json={
|
|
1109
|
+
"email": "new@example.com",
|
|
1110
|
+
"name": "New User",
|
|
1111
|
+
"password": "SecurePass123!",
|
|
1112
|
+
},
|
|
1113
|
+
)
|
|
1114
|
+
assert response.status_code == 201
|
|
1115
|
+
data = response.json()
|
|
1116
|
+
assert data["email"] == "new@example.com"
|
|
1117
|
+
assert "password" not in data
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
@pytest.mark.asyncio
|
|
1121
|
+
async def test_register_duplicate_email(client: AsyncClient, test_user):
|
|
1122
|
+
response = await client.post(
|
|
1123
|
+
"/api/v1/auth/register",
|
|
1124
|
+
json={
|
|
1125
|
+
"email": "test@example.com",
|
|
1126
|
+
"name": "Duplicate User",
|
|
1127
|
+
"password": "SecurePass123!",
|
|
1128
|
+
},
|
|
1129
|
+
)
|
|
1130
|
+
assert response.status_code == 409
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
@pytest.mark.asyncio
|
|
1134
|
+
async def test_login(client: AsyncClient, test_user):
|
|
1135
|
+
response = await client.post(
|
|
1136
|
+
"/api/v1/auth/login",
|
|
1137
|
+
json={
|
|
1138
|
+
"email": "test@example.com",
|
|
1139
|
+
"password": "TestPass123!",
|
|
1140
|
+
},
|
|
1141
|
+
)
|
|
1142
|
+
assert response.status_code == 200
|
|
1143
|
+
data = response.json()
|
|
1144
|
+
assert "access_token" in data
|
|
1145
|
+
assert "refresh_token" in data
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
@pytest.mark.asyncio
|
|
1149
|
+
async def test_login_wrong_password(client: AsyncClient, test_user):
|
|
1150
|
+
response = await client.post(
|
|
1151
|
+
"/api/v1/auth/login",
|
|
1152
|
+
json={
|
|
1153
|
+
"email": "test@example.com",
|
|
1154
|
+
"password": "WrongPassword123!",
|
|
1155
|
+
},
|
|
1156
|
+
)
|
|
1157
|
+
assert response.status_code == 401
|
|
1158
|
+
```
|
|
1159
|
+
|
|
1160
|
+
## Use Cases
|
|
1161
|
+
|
|
1162
|
+
### Background Task Processing
|
|
1163
|
+
|
|
1164
|
+
```python
|
|
1165
|
+
# app/tasks/email.py
|
|
1166
|
+
from celery import Celery
|
|
1167
|
+
from app.core.config import settings
|
|
1168
|
+
|
|
1169
|
+
celery = Celery("tasks", broker=settings.REDIS_URL)
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
@celery.task(bind=True, max_retries=3)
|
|
1173
|
+
def send_email_task(self, to: str, subject: str, body: str):
|
|
37
1174
|
try:
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
1175
|
+
# Send email logic
|
|
1176
|
+
pass
|
|
1177
|
+
except Exception as exc:
|
|
1178
|
+
raise self.retry(exc=exc, countdown=60)
|
|
41
1179
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
1180
|
+
|
|
1181
|
+
# Using with FastAPI background tasks
|
|
1182
|
+
from fastapi import BackgroundTasks
|
|
1183
|
+
|
|
1184
|
+
@router.post("/invite")
|
|
1185
|
+
async def invite_user(
|
|
1186
|
+
email: str,
|
|
1187
|
+
background_tasks: BackgroundTasks,
|
|
1188
|
+
current_user: CurrentUser,
|
|
1189
|
+
):
|
|
1190
|
+
# Add task to background
|
|
1191
|
+
background_tasks.add_task(
|
|
1192
|
+
send_invitation_email,
|
|
1193
|
+
email=email,
|
|
1194
|
+
inviter_name=current_user.name,
|
|
1195
|
+
)
|
|
1196
|
+
return {"message": "Invitation sent"}
|
|
45
1197
|
```
|
|
46
1198
|
|
|
47
|
-
###
|
|
1199
|
+
### WebSocket for Real-time Updates
|
|
1200
|
+
|
|
48
1201
|
```python
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
1202
|
+
# app/api/v1/websocket.py
|
|
1203
|
+
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends
|
|
1204
|
+
from typing import Dict, Set
|
|
1205
|
+
from uuid import UUID
|
|
1206
|
+
import json
|
|
1207
|
+
|
|
1208
|
+
router = APIRouter()
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
class ConnectionManager:
|
|
1212
|
+
def __init__(self):
|
|
1213
|
+
self.active_connections: Dict[str, Set[WebSocket]] = {}
|
|
1214
|
+
|
|
1215
|
+
async def connect(self, websocket: WebSocket, room: str):
|
|
1216
|
+
await websocket.accept()
|
|
1217
|
+
if room not in self.active_connections:
|
|
1218
|
+
self.active_connections[room] = set()
|
|
1219
|
+
self.active_connections[room].add(websocket)
|
|
1220
|
+
|
|
1221
|
+
def disconnect(self, websocket: WebSocket, room: str):
|
|
1222
|
+
if room in self.active_connections:
|
|
1223
|
+
self.active_connections[room].discard(websocket)
|
|
1224
|
+
|
|
1225
|
+
async def broadcast(self, room: str, message: dict):
|
|
1226
|
+
if room in self.active_connections:
|
|
1227
|
+
for connection in self.active_connections[room]:
|
|
1228
|
+
await connection.send_json(message)
|
|
1229
|
+
|
|
1230
|
+
|
|
1231
|
+
manager = ConnectionManager()
|
|
1232
|
+
|
|
1233
|
+
|
|
1234
|
+
@router.websocket("/ws/projects/{project_id}")
|
|
1235
|
+
async def project_websocket(
|
|
1236
|
+
websocket: WebSocket,
|
|
1237
|
+
project_id: UUID,
|
|
1238
|
+
):
|
|
1239
|
+
room = f"project:{project_id}"
|
|
1240
|
+
await manager.connect(websocket, room)
|
|
1241
|
+
|
|
1242
|
+
try:
|
|
1243
|
+
while True:
|
|
1244
|
+
data = await websocket.receive_text()
|
|
1245
|
+
message = json.loads(data)
|
|
1246
|
+
await manager.broadcast(room, message)
|
|
1247
|
+
except WebSocketDisconnect:
|
|
1248
|
+
manager.disconnect(websocket, room)
|
|
52
1249
|
```
|
|
53
1250
|
|
|
54
1251
|
## Best Practices
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
-
|
|
1252
|
+
|
|
1253
|
+
### Do's
|
|
1254
|
+
|
|
1255
|
+
- Use Pydantic v2 for all validation
|
|
1256
|
+
- Use async/await consistently throughout
|
|
1257
|
+
- Use dependency injection for testability
|
|
1258
|
+
- Use proper type hints everywhere
|
|
1259
|
+
- Use background tasks for long operations
|
|
1260
|
+
- Use connection pooling for database
|
|
1261
|
+
- Use Redis for caching and rate limiting
|
|
1262
|
+
- Write comprehensive tests with pytest
|
|
1263
|
+
- Use proper error handling with HTTPException
|
|
1264
|
+
- Use environment variables for configuration
|
|
1265
|
+
|
|
1266
|
+
### Don'ts
|
|
1267
|
+
|
|
1268
|
+
- Don't use sync operations in async functions
|
|
1269
|
+
- Don't skip input validation
|
|
1270
|
+
- Don't hardcode configuration values
|
|
1271
|
+
- Don't ignore database session management
|
|
1272
|
+
- Don't expose sensitive data in responses
|
|
1273
|
+
- Don't use `*` imports
|
|
1274
|
+
- Don't skip error handling
|
|
1275
|
+
- Don't forget to close database sessions
|
|
1276
|
+
- Don't use blocking I/O operations
|
|
1277
|
+
- Don't ignore type checker warnings
|
|
1278
|
+
|
|
1279
|
+
## References
|
|
1280
|
+
|
|
1281
|
+
- [FastAPI Documentation](https://fastapi.tiangolo.com/)
|
|
1282
|
+
- [Pydantic v2 Documentation](https://docs.pydantic.dev/latest/)
|
|
1283
|
+
- [SQLAlchemy 2.0 Documentation](https://docs.sqlalchemy.org/en/20/)
|
|
1284
|
+
- [pytest-asyncio Documentation](https://pytest-asyncio.readthedocs.io/)
|
|
1285
|
+
- [Starlette Documentation](https://www.starlette.io/)
|