omgkit 2.2.0 → 2.3.1
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/README.md +3 -3
- package/package.json +1 -1
- package/plugin/skills/databases/database-management/SKILL.md +288 -0
- package/plugin/skills/databases/database-migration/SKILL.md +285 -0
- package/plugin/skills/databases/database-schema-design/SKILL.md +195 -0
- package/plugin/skills/databases/mongodb/SKILL.md +60 -776
- package/plugin/skills/databases/prisma/SKILL.md +53 -744
- package/plugin/skills/databases/redis/SKILL.md +53 -860
- package/plugin/skills/databases/supabase/SKILL.md +283 -0
- package/plugin/skills/devops/aws/SKILL.md +68 -672
- package/plugin/skills/devops/github-actions/SKILL.md +54 -657
- package/plugin/skills/devops/kubernetes/SKILL.md +67 -602
- package/plugin/skills/devops/performance-profiling/SKILL.md +59 -863
- package/plugin/skills/frameworks/django/SKILL.md +87 -853
- package/plugin/skills/frameworks/express/SKILL.md +95 -1301
- package/plugin/skills/frameworks/fastapi/SKILL.md +90 -1198
- package/plugin/skills/frameworks/laravel/SKILL.md +87 -1187
- package/plugin/skills/frameworks/nestjs/SKILL.md +106 -973
- package/plugin/skills/frameworks/react/SKILL.md +94 -962
- package/plugin/skills/frameworks/vue/SKILL.md +95 -1242
- package/plugin/skills/frontend/accessibility/SKILL.md +91 -1056
- package/plugin/skills/frontend/frontend-design/SKILL.md +69 -1262
- package/plugin/skills/frontend/responsive/SKILL.md +76 -799
- package/plugin/skills/frontend/shadcn-ui/SKILL.md +73 -921
- package/plugin/skills/frontend/tailwindcss/SKILL.md +60 -788
- package/plugin/skills/frontend/threejs/SKILL.md +72 -1266
- package/plugin/skills/languages/javascript/SKILL.md +106 -849
- package/plugin/skills/methodology/brainstorming/SKILL.md +70 -576
- package/plugin/skills/methodology/defense-in-depth/SKILL.md +79 -831
- package/plugin/skills/methodology/dispatching-parallel-agents/SKILL.md +81 -654
- package/plugin/skills/methodology/executing-plans/SKILL.md +86 -529
- package/plugin/skills/methodology/finishing-development-branch/SKILL.md +95 -586
- package/plugin/skills/methodology/problem-solving/SKILL.md +67 -681
- package/plugin/skills/methodology/receiving-code-review/SKILL.md +70 -533
- package/plugin/skills/methodology/requesting-code-review/SKILL.md +70 -610
- package/plugin/skills/methodology/root-cause-tracing/SKILL.md +70 -646
- package/plugin/skills/methodology/sequential-thinking/SKILL.md +70 -478
- package/plugin/skills/methodology/systematic-debugging/SKILL.md +66 -559
- package/plugin/skills/methodology/test-driven-development/SKILL.md +91 -752
- package/plugin/skills/methodology/testing-anti-patterns/SKILL.md +78 -687
- package/plugin/skills/methodology/token-optimization/SKILL.md +72 -602
- package/plugin/skills/methodology/verification-before-completion/SKILL.md +108 -529
- package/plugin/skills/methodology/writing-plans/SKILL.md +79 -566
- package/plugin/skills/omega/omega-architecture/SKILL.md +91 -752
- package/plugin/skills/omega/omega-coding/SKILL.md +161 -552
- package/plugin/skills/omega/omega-sprint/SKILL.md +132 -777
- package/plugin/skills/omega/omega-testing/SKILL.md +157 -845
- package/plugin/skills/omega/omega-thinking/SKILL.md +165 -606
- package/plugin/skills/security/better-auth/SKILL.md +46 -1034
- package/plugin/skills/security/oauth/SKILL.md +80 -934
- package/plugin/skills/security/owasp/SKILL.md +78 -862
- package/plugin/skills/testing/playwright/SKILL.md +77 -700
- package/plugin/skills/testing/pytest/SKILL.md +73 -811
- package/plugin/skills/testing/vitest/SKILL.md +60 -920
- package/plugin/skills/tools/document-processing/SKILL.md +111 -838
- package/plugin/skills/tools/image-processing/SKILL.md +126 -659
- package/plugin/skills/tools/mcp-development/SKILL.md +85 -758
- package/plugin/skills/tools/media-processing/SKILL.md +118 -735
- package/plugin/stdrules/SKILL_STANDARDS.md +490 -0
- package/plugin/skills/SKILL_STANDARDS.md +0 -743
|
@@ -1,1285 +1,177 @@
|
|
|
1
1
|
---
|
|
2
|
-
name: fastapi
|
|
3
|
-
description:
|
|
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
|
|
2
|
+
name: building-fastapi-apis
|
|
3
|
+
description: Builds high-performance FastAPI applications with async/await, Pydantic v2, dependency injection, and SQLAlchemy. Use when creating Python REST APIs, async backends, or microservices.
|
|
14
4
|
---
|
|
15
5
|
|
|
16
6
|
# FastAPI
|
|
17
7
|
|
|
18
|
-
|
|
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
|
|
8
|
+
## Quick Start
|
|
35
9
|
|
|
36
10
|
```python
|
|
37
|
-
# app/main.py
|
|
38
|
-
from contextlib import asynccontextmanager
|
|
39
11
|
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
12
|
|
|
113
|
-
|
|
114
|
-
model_config = SettingsConfigDict(
|
|
115
|
-
env_file=".env",
|
|
116
|
-
env_file_encoding="utf-8",
|
|
117
|
-
case_sensitive=True,
|
|
118
|
-
)
|
|
13
|
+
app = FastAPI()
|
|
119
14
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
15
|
+
@app.get("/health")
|
|
16
|
+
async def health_check():
|
|
17
|
+
return {"status": "ok"}
|
|
149
18
|
|
|
19
|
+
@app.get("/users/{user_id}")
|
|
20
|
+
async def get_user(user_id: int):
|
|
21
|
+
return {"user_id": user_id}
|
|
22
|
+
```
|
|
150
23
|
|
|
151
|
-
|
|
152
|
-
def get_settings() -> Settings:
|
|
153
|
-
return Settings()
|
|
24
|
+
## Features
|
|
154
25
|
|
|
26
|
+
| Feature | Description | Guide |
|
|
27
|
+
|---------|-------------|-------|
|
|
28
|
+
| Routing | Path params, query params, body | [ROUTING.md](ROUTING.md) |
|
|
29
|
+
| Pydantic | Schemas, validation, serialization | [SCHEMAS.md](SCHEMAS.md) |
|
|
30
|
+
| Dependencies | Injection, database sessions | [DEPENDENCIES.md](DEPENDENCIES.md) |
|
|
31
|
+
| Auth | JWT, OAuth2, security utils | [AUTH.md](AUTH.md) |
|
|
32
|
+
| Database | SQLAlchemy async, migrations | [DATABASE.md](DATABASE.md) |
|
|
33
|
+
| Testing | pytest, AsyncClient | [TESTING.md](TESTING.md) |
|
|
155
34
|
|
|
156
|
-
|
|
157
|
-
```
|
|
35
|
+
## Common Patterns
|
|
158
36
|
|
|
159
|
-
###
|
|
37
|
+
### Pydantic Schemas
|
|
160
38
|
|
|
161
39
|
```python
|
|
162
|
-
|
|
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
|
|
40
|
+
from pydantic import BaseModel, EmailStr, Field, field_validator
|
|
167
41
|
|
|
168
|
-
|
|
169
|
-
class UserBase(BaseModel):
|
|
42
|
+
class UserCreate(BaseModel):
|
|
170
43
|
email: EmailStr
|
|
171
44
|
name: str = Field(..., min_length=2, max_length=100)
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
class UserCreate(UserBase):
|
|
176
|
-
password: str = Field(..., min_length=8, max_length=128)
|
|
45
|
+
password: str = Field(..., min_length=8)
|
|
177
46
|
|
|
178
47
|
@field_validator("password")
|
|
179
48
|
@classmethod
|
|
180
49
|
def validate_password(cls, v: str) -> str:
|
|
181
50
|
if not any(c.isupper() for c in v):
|
|
182
|
-
raise ValueError("
|
|
51
|
+
raise ValueError("Must contain uppercase")
|
|
183
52
|
if not any(c.isdigit() for c in v):
|
|
184
|
-
raise ValueError("
|
|
185
|
-
if not any(c in "!@#$%^&*" for c in v):
|
|
186
|
-
raise ValueError("Password must contain a special character")
|
|
53
|
+
raise ValueError("Must contain digit")
|
|
187
54
|
return v
|
|
188
55
|
|
|
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):
|
|
56
|
+
class UserResponse(BaseModel):
|
|
239
57
|
model_config = ConfigDict(from_attributes=True)
|
|
240
58
|
|
|
241
59
|
id: UUID
|
|
60
|
+
email: EmailStr
|
|
242
61
|
name: str
|
|
243
|
-
slug: str
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
class OrganizationResponse(OrganizationSummary):
|
|
247
|
-
owner_id: UUID
|
|
248
|
-
member_count: int = 0
|
|
249
62
|
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
63
|
```
|
|
303
64
|
|
|
304
|
-
###
|
|
65
|
+
### Dependency Injection
|
|
305
66
|
|
|
306
67
|
```python
|
|
307
|
-
|
|
308
|
-
from typing import Annotated, AsyncGenerator, Optional
|
|
309
|
-
from fastapi import Depends, HTTPException, status, Query
|
|
310
|
-
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|
68
|
+
from fastapi import Depends
|
|
311
69
|
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
70
|
|
|
327
71
|
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
|
328
|
-
"""Database session dependency."""
|
|
329
72
|
async with async_session_maker() as session:
|
|
330
|
-
|
|
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
|
-
|
|
73
|
+
yield session
|
|
348
74
|
|
|
349
75
|
async def get_current_user(
|
|
350
|
-
|
|
351
|
-
db:
|
|
76
|
+
token: str = Depends(oauth2_scheme),
|
|
77
|
+
db: AsyncSession = Depends(get_db),
|
|
352
78
|
) -> User:
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
79
|
+
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
|
|
80
|
+
user = await db.get(User, payload["sub"])
|
|
81
|
+
if not user:
|
|
82
|
+
raise HTTPException(status_code=401)
|
|
383
83
|
return user
|
|
384
84
|
|
|
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}
|
|
432
|
-
|
|
433
|
-
|
|
434
85
|
# Type aliases for cleaner signatures
|
|
435
86
|
DB = Annotated[AsyncSession, Depends(get_db)]
|
|
436
87
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
|
437
|
-
Pagination = Annotated[PaginationParams, Depends(get_pagination)]
|
|
438
|
-
AdminUser = Annotated[User, Depends(require_role("admin"))]
|
|
439
88
|
```
|
|
440
89
|
|
|
441
|
-
###
|
|
90
|
+
### Route with Service Layer
|
|
442
91
|
|
|
443
92
|
```python
|
|
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
93
|
@router.get("/", response_model=PaginatedResponse[UserResponse])
|
|
470
94
|
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
95
|
db: DB,
|
|
505
96
|
current_user: CurrentUser,
|
|
506
|
-
|
|
97
|
+
page: int = Query(1, ge=1),
|
|
98
|
+
limit: int = Query(20, ge=1, le=100),
|
|
507
99
|
):
|
|
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
100
|
service = UserService(db)
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
raise HTTPException(
|
|
520
|
-
status_code=status.HTTP_404_NOT_FOUND,
|
|
521
|
-
detail="User not found",
|
|
522
|
-
)
|
|
523
|
-
return user
|
|
101
|
+
users, total = await service.list(offset=(page - 1) * limit, limit=limit)
|
|
102
|
+
return PaginatedResponse.create(data=users, total=total, page=page, limit=limit)
|
|
524
103
|
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
async def create_user(db: DB, current_user: AdminUser, user_in: UserCreate):
|
|
528
|
-
"""Create new user (admin only)."""
|
|
104
|
+
@router.post("/", response_model=UserResponse, status_code=201)
|
|
105
|
+
async def create_user(db: DB, user_in: UserCreate):
|
|
529
106
|
service = UserService(db)
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
raise HTTPException(
|
|
533
|
-
status_code=status.HTTP_409_CONFLICT,
|
|
534
|
-
detail="Email already registered",
|
|
535
|
-
)
|
|
107
|
+
if await service.get_by_email(user_in.email):
|
|
108
|
+
raise HTTPException(status_code=409, detail="Email exists")
|
|
536
109
|
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
|
|
568
|
-
password: str
|
|
569
|
-
|
|
570
|
-
|
|
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
|
|
651
110
|
```
|
|
652
111
|
|
|
653
|
-
|
|
112
|
+
## Workflows
|
|
654
113
|
|
|
655
|
-
|
|
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
|
|
114
|
+
### API Development
|
|
662
115
|
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
116
|
+
1. Define Pydantic schemas for request/response
|
|
117
|
+
2. Create service layer for business logic
|
|
118
|
+
3. Add route with dependency injection
|
|
119
|
+
4. Write tests with pytest-asyncio
|
|
120
|
+
5. Document with OpenAPI (automatic)
|
|
667
121
|
|
|
122
|
+
### Service Pattern
|
|
668
123
|
|
|
124
|
+
```python
|
|
669
125
|
class UserService:
|
|
670
126
|
def __init__(self, db: AsyncSession):
|
|
671
127
|
self.db = db
|
|
672
128
|
|
|
673
|
-
async def get_by_id(self, user_id: UUID) ->
|
|
129
|
+
async def get_by_id(self, user_id: UUID) -> User | None:
|
|
674
130
|
result = await self.db.execute(
|
|
675
|
-
select(User).where(User.id == user_id
|
|
131
|
+
select(User).where(User.id == user_id)
|
|
676
132
|
)
|
|
677
133
|
return result.scalar_one_or_none()
|
|
678
134
|
|
|
679
|
-
async def
|
|
680
|
-
|
|
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
|
-
)
|
|
135
|
+
async def create(self, data: UserCreate) -> User:
|
|
136
|
+
user = User(**data.model_dump(), hashed_password=hash_password(data.password))
|
|
737
137
|
self.db.add(user)
|
|
738
138
|
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
139
|
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
140
|
```
|
|
1159
141
|
|
|
1160
|
-
##
|
|
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):
|
|
1174
|
-
try:
|
|
1175
|
-
# Send email logic
|
|
1176
|
-
pass
|
|
1177
|
-
except Exception as exc:
|
|
1178
|
-
raise self.retry(exc=exc, countdown=60)
|
|
142
|
+
## Best Practices
|
|
1179
143
|
|
|
144
|
+
| Do | Avoid |
|
|
145
|
+
|----|-------|
|
|
146
|
+
| Use async/await everywhere | Sync operations in async code |
|
|
147
|
+
| Validate with Pydantic v2 | Manual validation |
|
|
148
|
+
| Use dependency injection | Direct imports |
|
|
149
|
+
| Handle errors with HTTPException | Generic exceptions |
|
|
150
|
+
| Use type hints | `Any` types |
|
|
1180
151
|
|
|
1181
|
-
|
|
1182
|
-
from fastapi import BackgroundTasks
|
|
152
|
+
## Project Structure
|
|
1183
153
|
|
|
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"}
|
|
1197
154
|
```
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
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)
|
|
155
|
+
app/
|
|
156
|
+
├── main.py
|
|
157
|
+
├── core/
|
|
158
|
+
│ ├── config.py
|
|
159
|
+
│ ├── security.py
|
|
160
|
+
│ └── deps.py
|
|
161
|
+
├── api/
|
|
162
|
+
│ └── v1/
|
|
163
|
+
│ ├── __init__.py
|
|
164
|
+
│ ├── users.py
|
|
165
|
+
│ └── auth.py
|
|
166
|
+
├── models/
|
|
167
|
+
├── schemas/
|
|
168
|
+
├── services/
|
|
169
|
+
└── db/
|
|
170
|
+
├── base.py
|
|
171
|
+
└── session.py
|
|
172
|
+
tests/
|
|
173
|
+
├── conftest.py
|
|
174
|
+
└── test_users.py
|
|
1249
175
|
```
|
|
1250
176
|
|
|
1251
|
-
|
|
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/)
|
|
177
|
+
For detailed examples and patterns, see reference files above.
|