@jaimevalasek/aioson 1.3.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/CHANGELOG.md +456 -0
- package/CODE_OF_CONDUCT.md +12 -0
- package/CONTRIBUTING.md +13 -0
- package/LICENSE +21 -0
- package/README.md +254 -0
- package/bin/aioson.js +4 -0
- package/docs/en/cli-reference.md +398 -0
- package/docs/en/i18n.md +52 -0
- package/docs/en/json-schemas.md +41 -0
- package/docs/en/mcp.md +56 -0
- package/docs/en/parallel.md +82 -0
- package/docs/en/qa-browser.md +339 -0
- package/docs/en/release-flow.md +22 -0
- package/docs/en/release-notes-template.md +41 -0
- package/docs/en/release.md +28 -0
- package/docs/en/schemas/agent-prompt.schema.json +17 -0
- package/docs/en/schemas/agents.schema.json +32 -0
- package/docs/en/schemas/context-validate.schema.json +36 -0
- package/docs/en/schemas/doctor.schema.json +89 -0
- package/docs/en/schemas/error.schema.json +24 -0
- package/docs/en/schemas/i18n-add.schema.json +15 -0
- package/docs/en/schemas/index.json +116 -0
- package/docs/en/schemas/info.schema.json +39 -0
- package/docs/en/schemas/init.schema.json +48 -0
- package/docs/en/schemas/install.schema.json +60 -0
- package/docs/en/schemas/locale-apply.schema.json +30 -0
- package/docs/en/schemas/mcp-doctor.schema.json +95 -0
- package/docs/en/schemas/mcp-init.schema.json +122 -0
- package/docs/en/schemas/package-test.schema.json +24 -0
- package/docs/en/schemas/parallel-assign.schema.json +57 -0
- package/docs/en/schemas/parallel-doctor.schema.json +86 -0
- package/docs/en/schemas/parallel-init.schema.json +53 -0
- package/docs/en/schemas/parallel-status.schema.json +94 -0
- package/docs/en/schemas/setup-context.schema.json +39 -0
- package/docs/en/schemas/smoke.schema.json +23 -0
- package/docs/en/schemas/update.schema.json +48 -0
- package/docs/en/schemas/workflow-plan.schema.json +30 -0
- package/docs/en/web3.md +54 -0
- package/docs/pt/README.md +46 -0
- package/docs/pt/advisor-spec.md +335 -0
- package/docs/pt/agentes.md +453 -0
- package/docs/pt/cenarios.md +1230 -0
- package/docs/pt/clientes-ai.md +224 -0
- package/docs/pt/comandos-cli.md +511 -0
- package/docs/pt/genome-3.0-spec.md +296 -0
- package/docs/pt/guia-engineer.md +226 -0
- package/docs/pt/inicio-rapido.md +138 -0
- package/docs/pt/profiler-system.md +214 -0
- package/docs/pt/runtime-observability.md +72 -0
- package/docs/pt/squad-genoma.md +777 -0
- package/docs/pt/web3.md +797 -0
- package/docs/testing/genome-2.0-manual-regression.md +23 -0
- package/docs/testing/genome-2.0-matrix.md +36 -0
- package/docs/testing/genome-2.0-rollout.md +184 -0
- package/package.json +50 -0
- package/src/agents.js +56 -0
- package/src/cli.js +497 -0
- package/src/commands/agents.js +142 -0
- package/src/commands/cloud.js +1767 -0
- package/src/commands/config.js +90 -0
- package/src/commands/context-validate.js +91 -0
- package/src/commands/doctor.js +123 -0
- package/src/commands/genome-doctor.js +41 -0
- package/src/commands/genome-migrate.js +49 -0
- package/src/commands/i18n-add.js +56 -0
- package/src/commands/info.js +41 -0
- package/src/commands/init.js +75 -0
- package/src/commands/install.js +68 -0
- package/src/commands/locale-apply.js +51 -0
- package/src/commands/locale-diff.js +126 -0
- package/src/commands/mcp-doctor.js +406 -0
- package/src/commands/mcp-init.js +379 -0
- package/src/commands/package-e2e.js +273 -0
- package/src/commands/parallel-assign.js +403 -0
- package/src/commands/parallel-doctor.js +437 -0
- package/src/commands/parallel-init.js +249 -0
- package/src/commands/parallel-status.js +290 -0
- package/src/commands/qa-doctor.js +185 -0
- package/src/commands/qa-init.js +161 -0
- package/src/commands/qa-report.js +58 -0
- package/src/commands/qa-run.js +873 -0
- package/src/commands/qa-scan.js +337 -0
- package/src/commands/runtime.js +948 -0
- package/src/commands/scan-project.js +1107 -0
- package/src/commands/setup-context.js +650 -0
- package/src/commands/smoke.js +426 -0
- package/src/commands/squad-doctor.js +358 -0
- package/src/commands/squad-export.js +46 -0
- package/src/commands/squad-pipeline.js +97 -0
- package/src/commands/squad-repair-genomes.js +39 -0
- package/src/commands/squad-status.js +424 -0
- package/src/commands/squad-validate.js +230 -0
- package/src/commands/test-agents.js +194 -0
- package/src/commands/update.js +55 -0
- package/src/commands/workflow-next.js +594 -0
- package/src/commands/workflow-plan.js +108 -0
- package/src/constants.js +314 -0
- package/src/context-parse-reason.js +22 -0
- package/src/context-writer.js +150 -0
- package/src/context.js +217 -0
- package/src/detector.js +261 -0
- package/src/doctor.js +289 -0
- package/src/execution-gateway.js +461 -0
- package/src/genome-files.js +198 -0
- package/src/genome-format.js +442 -0
- package/src/genome-schema.js +215 -0
- package/src/genomes/bindings.js +281 -0
- package/src/genomes.js +467 -0
- package/src/i18n/index.js +103 -0
- package/src/i18n/messages/en.js +784 -0
- package/src/i18n/messages/es.js +718 -0
- package/src/i18n/messages/fr.js +725 -0
- package/src/i18n/messages/pt-BR.js +818 -0
- package/src/i18n/scaffold.js +64 -0
- package/src/installer.js +232 -0
- package/src/lib/genomes/compat.js +206 -0
- package/src/lib/genomes/migrate.js +90 -0
- package/src/lib/squads/genome-repair.js +49 -0
- package/src/locales.js +84 -0
- package/src/onboarding.js +305 -0
- package/src/parser.js +53 -0
- package/src/prompt-tool.js +20 -0
- package/src/qa-html-report.js +472 -0
- package/src/runtime-store.js +1527 -0
- package/src/squads/apply-genome.js +21 -0
- package/src/squads/genome-binding-service.js +154 -0
- package/src/updater.js +32 -0
- package/src/utils.js +46 -0
- package/src/version.js +50 -0
- package/template/.aioson/advisors/.gitkeep +1 -0
- package/template/.aioson/agents/analyst.md +225 -0
- package/template/.aioson/agents/architect.md +221 -0
- package/template/.aioson/agents/dev.md +201 -0
- package/template/.aioson/agents/discovery-design-doc.md +196 -0
- package/template/.aioson/agents/genoma.md +300 -0
- package/template/.aioson/agents/orchestrator.md +107 -0
- package/template/.aioson/agents/pm.md +89 -0
- package/template/.aioson/agents/product.md +361 -0
- package/template/.aioson/agents/profiler-enricher.md +266 -0
- package/template/.aioson/agents/profiler-forge.md +188 -0
- package/template/.aioson/agents/profiler-researcher.md +245 -0
- package/template/.aioson/agents/qa.md +344 -0
- package/template/.aioson/agents/setup.md +381 -0
- package/template/.aioson/agents/squad.md +837 -0
- package/template/.aioson/agents/ux-ui.md +416 -0
- package/template/.aioson/config.md +56 -0
- package/template/.aioson/context/.gitkeep +0 -0
- package/template/.aioson/context/parallel/.gitkeep +0 -0
- package/template/.aioson/context/spec.md.template +37 -0
- package/template/.aioson/genomas/.gitkeep +0 -0
- package/template/.aioson/locales/en/agents/analyst.md +214 -0
- package/template/.aioson/locales/en/agents/architect.md +210 -0
- package/template/.aioson/locales/en/agents/dev.md +187 -0
- package/template/.aioson/locales/en/agents/discovery-design-doc.md +27 -0
- package/template/.aioson/locales/en/agents/genoma.md +212 -0
- package/template/.aioson/locales/en/agents/orchestrator.md +105 -0
- package/template/.aioson/locales/en/agents/pm.md +77 -0
- package/template/.aioson/locales/en/agents/product.md +310 -0
- package/template/.aioson/locales/en/agents/profiler-enricher.md +5 -0
- package/template/.aioson/locales/en/agents/profiler-forge.md +5 -0
- package/template/.aioson/locales/en/agents/profiler-researcher.md +5 -0
- package/template/.aioson/locales/en/agents/qa.md +214 -0
- package/template/.aioson/locales/en/agents/setup.md +342 -0
- package/template/.aioson/locales/en/agents/squad.md +247 -0
- package/template/.aioson/locales/en/agents/ux-ui.md +320 -0
- package/template/.aioson/locales/es/agents/analyst.md +203 -0
- package/template/.aioson/locales/es/agents/architect.md +208 -0
- package/template/.aioson/locales/es/agents/dev.md +183 -0
- package/template/.aioson/locales/es/agents/discovery-design-doc.md +19 -0
- package/template/.aioson/locales/es/agents/genoma.md +102 -0
- package/template/.aioson/locales/es/agents/orchestrator.md +108 -0
- package/template/.aioson/locales/es/agents/pm.md +81 -0
- package/template/.aioson/locales/es/agents/product.md +310 -0
- package/template/.aioson/locales/es/agents/profiler-enricher.md +5 -0
- package/template/.aioson/locales/es/agents/profiler-forge.md +5 -0
- package/template/.aioson/locales/es/agents/profiler-researcher.md +5 -0
- package/template/.aioson/locales/es/agents/qa.md +163 -0
- package/template/.aioson/locales/es/agents/setup.md +347 -0
- package/template/.aioson/locales/es/agents/squad.md +247 -0
- package/template/.aioson/locales/es/agents/ux-ui.md +201 -0
- package/template/.aioson/locales/fr/agents/analyst.md +203 -0
- package/template/.aioson/locales/fr/agents/architect.md +208 -0
- package/template/.aioson/locales/fr/agents/dev.md +183 -0
- package/template/.aioson/locales/fr/agents/discovery-design-doc.md +19 -0
- package/template/.aioson/locales/fr/agents/genoma.md +102 -0
- package/template/.aioson/locales/fr/agents/orchestrator.md +108 -0
- package/template/.aioson/locales/fr/agents/pm.md +81 -0
- package/template/.aioson/locales/fr/agents/product.md +310 -0
- package/template/.aioson/locales/fr/agents/profiler-enricher.md +5 -0
- package/template/.aioson/locales/fr/agents/profiler-forge.md +5 -0
- package/template/.aioson/locales/fr/agents/profiler-researcher.md +5 -0
- package/template/.aioson/locales/fr/agents/qa.md +163 -0
- package/template/.aioson/locales/fr/agents/setup.md +347 -0
- package/template/.aioson/locales/fr/agents/squad.md +247 -0
- package/template/.aioson/locales/fr/agents/ux-ui.md +201 -0
- package/template/.aioson/locales/pt-BR/agents/analyst.md +217 -0
- package/template/.aioson/locales/pt-BR/agents/architect.md +213 -0
- package/template/.aioson/locales/pt-BR/agents/dev.md +198 -0
- package/template/.aioson/locales/pt-BR/agents/discovery-design-doc.md +198 -0
- package/template/.aioson/locales/pt-BR/agents/genoma.md +297 -0
- package/template/.aioson/locales/pt-BR/agents/orchestrator.md +108 -0
- package/template/.aioson/locales/pt-BR/agents/pm.md +81 -0
- package/template/.aioson/locales/pt-BR/agents/product.md +316 -0
- package/template/.aioson/locales/pt-BR/agents/profiler-enricher.md +5 -0
- package/template/.aioson/locales/pt-BR/agents/profiler-forge.md +5 -0
- package/template/.aioson/locales/pt-BR/agents/profiler-researcher.md +5 -0
- package/template/.aioson/locales/pt-BR/agents/qa.md +217 -0
- package/template/.aioson/locales/pt-BR/agents/setup.md +371 -0
- package/template/.aioson/locales/pt-BR/agents/squad.md +772 -0
- package/template/.aioson/locales/pt-BR/agents/ux-ui.md +322 -0
- package/template/.aioson/mcp/servers.md +24 -0
- package/template/.aioson/profiler-reports/.gitkeep +1 -0
- package/template/.aioson/schemas/content-blueprint.schema.json +30 -0
- package/template/.aioson/schemas/genome-meta.schema.json +150 -0
- package/template/.aioson/schemas/genome.schema.json +115 -0
- package/template/.aioson/schemas/readiness.schema.json +27 -0
- package/template/.aioson/schemas/squad-blueprint.schema.json +172 -0
- package/template/.aioson/schemas/squad-manifest.schema.json +276 -0
- package/template/.aioson/skills/dynamic/README.md +30 -0
- package/template/.aioson/skills/dynamic/cardano-docs.md +16 -0
- package/template/.aioson/skills/dynamic/ethereum-docs.md +17 -0
- package/template/.aioson/skills/dynamic/flux-ui-docs.md +13 -0
- package/template/.aioson/skills/dynamic/laravel-docs.md +41 -0
- package/template/.aioson/skills/dynamic/npm-packages.md +16 -0
- package/template/.aioson/skills/dynamic/solana-docs.md +16 -0
- package/template/.aioson/skills/references/premium-command-center-ui/master-application-prompt.md +79 -0
- package/template/.aioson/skills/references/premium-command-center-ui/operational-ux-playbook.md +253 -0
- package/template/.aioson/skills/references/premium-command-center-ui/quality-validation-checklist.md +82 -0
- package/template/.aioson/skills/references/premium-command-center-ui/visual-system-and-component-patterns.md +270 -0
- package/template/.aioson/skills/static/django-patterns.md +342 -0
- package/template/.aioson/skills/static/fastapi-patterns.md +344 -0
- package/template/.aioson/skills/static/filament-patterns.md +267 -0
- package/template/.aioson/skills/static/flux-ui-components.md +262 -0
- package/template/.aioson/skills/static/git-conventions.md +227 -0
- package/template/.aioson/skills/static/interface-design.md +372 -0
- package/template/.aioson/skills/static/jetstream-setup.md +200 -0
- package/template/.aioson/skills/static/laravel-conventions.md +491 -0
- package/template/.aioson/skills/static/nextjs-patterns.md +321 -0
- package/template/.aioson/skills/static/node-express-patterns.md +317 -0
- package/template/.aioson/skills/static/node-typescript-patterns.md +282 -0
- package/template/.aioson/skills/static/premium-command-center-ui.md +190 -0
- package/template/.aioson/skills/static/rails-conventions.md +307 -0
- package/template/.aioson/skills/static/react-motion-patterns.md +577 -0
- package/template/.aioson/skills/static/static-html-patterns.md +1935 -0
- package/template/.aioson/skills/static/tall-stack-patterns.md +286 -0
- package/template/.aioson/skills/static/ui-ux-modern.md +75 -0
- package/template/.aioson/skills/static/web3-cardano-patterns.md +337 -0
- package/template/.aioson/skills/static/web3-ethereum-patterns.md +310 -0
- package/template/.aioson/skills/static/web3-security-checklist.md +284 -0
- package/template/.aioson/skills/static/web3-solana-patterns.md +324 -0
- package/template/.aioson/squads/.artisan/.gitkeep +0 -0
- package/template/.aioson/squads/.gitkeep +0 -0
- package/template/.aioson/squads/memory.md +5 -0
- package/template/.aioson/tasks/squad-analyze.md +83 -0
- package/template/.aioson/tasks/squad-create.md +99 -0
- package/template/.aioson/tasks/squad-design.md +100 -0
- package/template/.aioson/tasks/squad-export.md +20 -0
- package/template/.aioson/tasks/squad-extend.md +68 -0
- package/template/.aioson/tasks/squad-pipeline.md +122 -0
- package/template/.aioson/tasks/squad-repair.md +85 -0
- package/template/.aioson/tasks/squad-validate.md +58 -0
- package/template/.aioson/templates/squads/content-basic/template.json +21 -0
- package/template/.aioson/templates/squads/media-channel/template.json +24 -0
- package/template/.aioson/templates/squads/research-analysis/template.json +22 -0
- package/template/.aioson/templates/squads/software-delivery/template.json +21 -0
- package/template/.claude/commands/aioson/analyst.md +5 -0
- package/template/.claude/commands/aioson/architect.md +5 -0
- package/template/.claude/commands/aioson/dev.md +5 -0
- package/template/.claude/commands/aioson/orchestrator.md +5 -0
- package/template/.claude/commands/aioson/pm.md +5 -0
- package/template/.claude/commands/aioson/qa.md +5 -0
- package/template/.claude/commands/aioson/setup.md +5 -0
- package/template/.claude/commands/aioson/ux-ui.md +5 -0
- package/template/.gemini/GEMINI.md +10 -0
- package/template/.gemini/commands/aios-analyst.toml +4 -0
- package/template/.gemini/commands/aios-architect.toml +7 -0
- package/template/.gemini/commands/aios-dev.toml +8 -0
- package/template/.gemini/commands/aios-discovery-design-doc.toml +4 -0
- package/template/.gemini/commands/aios-orchestrator.toml +8 -0
- package/template/.gemini/commands/aios-pm.toml +8 -0
- package/template/.gemini/commands/aios-product.toml +4 -0
- package/template/.gemini/commands/aios-qa.toml +6 -0
- package/template/.gemini/commands/aios-setup.toml +3 -0
- package/template/.gemini/commands/aios-ux-ui.toml +8 -0
- package/template/AGENTS.md +67 -0
- package/template/CLAUDE.md +31 -0
- package/template/OPENCODE.md +24 -0
- package/template/aioson-models.json +40 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
# Django Conventions
|
|
2
|
+
|
|
3
|
+
> Django's batteries are included for a reason. Use them — don't reinvent the ORM, admin, or auth system.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Project structure
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
myproject/
|
|
11
|
+
├── manage.py
|
|
12
|
+
├── config/
|
|
13
|
+
│ ├── settings/
|
|
14
|
+
│ │ ├── base.py
|
|
15
|
+
│ │ ├── development.py
|
|
16
|
+
│ │ └── production.py
|
|
17
|
+
│ ├── urls.py
|
|
18
|
+
│ └── wsgi.py
|
|
19
|
+
├── apps/
|
|
20
|
+
│ ├── users/
|
|
21
|
+
│ │ ├── models.py
|
|
22
|
+
│ │ ├── views.py
|
|
23
|
+
│ │ ├── serializers.py
|
|
24
|
+
│ │ ├── urls.py
|
|
25
|
+
│ │ ├── services.py
|
|
26
|
+
│ │ └── tests/
|
|
27
|
+
│ └── core/
|
|
28
|
+
│ └── models.py # abstract base models (TimestampMixin, etc.)
|
|
29
|
+
├── requirements/
|
|
30
|
+
│ ├── base.txt
|
|
31
|
+
│ ├── development.txt
|
|
32
|
+
│ └── production.txt
|
|
33
|
+
└── templates/
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**Convention:** keep apps small and domain-focused. One app = one bounded context.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Models
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
# core/models.py — reusable base
|
|
44
|
+
from django.db import models
|
|
45
|
+
|
|
46
|
+
class TimestampMixin(models.Model):
|
|
47
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
48
|
+
updated_at = models.DateTimeField(auto_now=True)
|
|
49
|
+
|
|
50
|
+
class Meta:
|
|
51
|
+
abstract = True
|
|
52
|
+
|
|
53
|
+
# users/models.py
|
|
54
|
+
class UserProfile(TimestampMixin):
|
|
55
|
+
user = models.OneToOneField(
|
|
56
|
+
settings.AUTH_USER_MODEL,
|
|
57
|
+
on_delete=models.CASCADE,
|
|
58
|
+
related_name='profile'
|
|
59
|
+
)
|
|
60
|
+
bio = models.TextField(blank=True)
|
|
61
|
+
|
|
62
|
+
class Meta:
|
|
63
|
+
db_table = 'user_profiles'
|
|
64
|
+
|
|
65
|
+
def __str__(self):
|
|
66
|
+
return f'Profile({self.user.email})'
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**Rules:**
|
|
70
|
+
- Always define `__str__`
|
|
71
|
+
- Always set `db_table` in `Meta` (avoid auto-generated names)
|
|
72
|
+
- Use `TimestampMixin` for all models that need audit fields
|
|
73
|
+
- `blank=True` for optional string fields; `null=True` only for non-string nullable fields
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Views — use class-based views for CRUD, function-based for one-offs
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
# WRONG — fat view with business logic
|
|
81
|
+
def create_order(request):
|
|
82
|
+
if request.method == 'POST':
|
|
83
|
+
# 30 lines of business logic here
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
# RIGHT — view delegates to service
|
|
87
|
+
from django.views import View
|
|
88
|
+
from .services import OrderService
|
|
89
|
+
|
|
90
|
+
class OrderCreateView(LoginRequiredMixin, View):
|
|
91
|
+
def post(self, request):
|
|
92
|
+
result = OrderService.create(user=request.user, data=request.POST)
|
|
93
|
+
if result.ok:
|
|
94
|
+
return redirect('orders:detail', pk=result.order.id)
|
|
95
|
+
return render(request, 'orders/create.html', {'errors': result.errors})
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
**Rules:**
|
|
99
|
+
- Views handle HTTP only: parse request → call service → format response
|
|
100
|
+
- Business logic goes in `services.py`
|
|
101
|
+
- Always use `LoginRequiredMixin` for authenticated views
|
|
102
|
+
- Use `get_object_or_404` instead of manual `try/except`
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Services — business logic layer
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
# orders/services.py
|
|
110
|
+
from dataclasses import dataclass
|
|
111
|
+
from typing import Optional
|
|
112
|
+
from .models import Order
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class OrderResult:
|
|
116
|
+
ok: bool
|
|
117
|
+
order: Optional[Order] = None
|
|
118
|
+
errors: Optional[dict] = None
|
|
119
|
+
|
|
120
|
+
class OrderService:
|
|
121
|
+
@staticmethod
|
|
122
|
+
def create(user, data) -> OrderResult:
|
|
123
|
+
# validate
|
|
124
|
+
if not data.get('items'):
|
|
125
|
+
return OrderResult(ok=False, errors={'items': 'At least one item required'})
|
|
126
|
+
|
|
127
|
+
# execute
|
|
128
|
+
order = Order.objects.create(
|
|
129
|
+
user=user,
|
|
130
|
+
total=sum(item['price'] for item in data['items'])
|
|
131
|
+
)
|
|
132
|
+
return OrderResult(ok=True, order=order)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## Django REST Framework (DRF)
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
# serializers.py
|
|
141
|
+
from rest_framework import serializers
|
|
142
|
+
from .models import Order
|
|
143
|
+
|
|
144
|
+
class OrderSerializer(serializers.ModelSerializer):
|
|
145
|
+
class Meta:
|
|
146
|
+
model = Order
|
|
147
|
+
fields = ['id', 'status', 'total', 'created_at']
|
|
148
|
+
read_only_fields = ['id', 'created_at']
|
|
149
|
+
|
|
150
|
+
def validate_total(self, value):
|
|
151
|
+
if value <= 0:
|
|
152
|
+
raise serializers.ValidationError('Total must be positive')
|
|
153
|
+
return value
|
|
154
|
+
|
|
155
|
+
# views.py (DRF)
|
|
156
|
+
from rest_framework import generics, permissions
|
|
157
|
+
from .serializers import OrderSerializer
|
|
158
|
+
|
|
159
|
+
class OrderListCreateView(generics.ListCreateAPIView):
|
|
160
|
+
serializer_class = OrderSerializer
|
|
161
|
+
permission_classes = [permissions.IsAuthenticated]
|
|
162
|
+
|
|
163
|
+
def get_queryset(self):
|
|
164
|
+
return Order.objects.filter(user=self.request.user).select_related('user')
|
|
165
|
+
|
|
166
|
+
def perform_create(self, serializer):
|
|
167
|
+
serializer.save(user=self.request.user)
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
**Rules:**
|
|
171
|
+
- Always define `read_only_fields` to prevent mass assignment
|
|
172
|
+
- Use `select_related` / `prefetch_related` in `get_queryset` to prevent N+1
|
|
173
|
+
- `perform_create` / `perform_update` for attaching request context (user, etc.)
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
## URL routing
|
|
178
|
+
|
|
179
|
+
```python
|
|
180
|
+
# apps/orders/urls.py
|
|
181
|
+
from django.urls import path
|
|
182
|
+
from . import views
|
|
183
|
+
|
|
184
|
+
app_name = 'orders'
|
|
185
|
+
|
|
186
|
+
urlpatterns = [
|
|
187
|
+
path('', views.OrderListCreateView.as_view(), name='list'),
|
|
188
|
+
path('<int:pk>/', views.OrderDetailView.as_view(), name='detail'),
|
|
189
|
+
]
|
|
190
|
+
|
|
191
|
+
# config/urls.py
|
|
192
|
+
from django.urls import path, include
|
|
193
|
+
|
|
194
|
+
urlpatterns = [
|
|
195
|
+
path('admin/', admin.site.urls),
|
|
196
|
+
path('api/orders/', include('apps.orders.urls')),
|
|
197
|
+
]
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
**Always use `app_name`** in each app's `urls.py` for namespaced reversals.
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
## Authentication
|
|
205
|
+
|
|
206
|
+
- **Built-in auth:** `django.contrib.auth` for session-based apps
|
|
207
|
+
- **JWT (API):** `djangorestframework-simplejwt`
|
|
208
|
+
- **Social auth:** `django-allauth`
|
|
209
|
+
- **Permission check:** always use `@login_required` / `LoginRequiredMixin` or DRF `permission_classes`
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
# DRF JWT setup
|
|
213
|
+
REST_FRAMEWORK = {
|
|
214
|
+
'DEFAULT_AUTHENTICATION_CLASSES': [
|
|
215
|
+
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
|
216
|
+
],
|
|
217
|
+
'DEFAULT_PERMISSION_CLASSES': [
|
|
218
|
+
'rest_framework.permissions.IsAuthenticated',
|
|
219
|
+
],
|
|
220
|
+
}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
## Database
|
|
226
|
+
|
|
227
|
+
```python
|
|
228
|
+
# Migrations — never edit after applied to production
|
|
229
|
+
python manage.py makemigrations
|
|
230
|
+
python manage.py migrate
|
|
231
|
+
|
|
232
|
+
# Always write reversible migrations (define both operations)
|
|
233
|
+
# Squash if > 50 unapplied migrations in dev
|
|
234
|
+
|
|
235
|
+
# Query optimization
|
|
236
|
+
# BAD — N+1
|
|
237
|
+
orders = Order.objects.all()
|
|
238
|
+
for o in orders:
|
|
239
|
+
print(o.user.email) # query per iteration
|
|
240
|
+
|
|
241
|
+
# GOOD
|
|
242
|
+
orders = Order.objects.select_related('user').all()
|
|
243
|
+
|
|
244
|
+
# Use .only() for large models when you need few fields
|
|
245
|
+
orders = Order.objects.only('id', 'status', 'total')
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
---
|
|
249
|
+
|
|
250
|
+
## Settings split
|
|
251
|
+
|
|
252
|
+
```python
|
|
253
|
+
# config/settings/base.py — shared
|
|
254
|
+
INSTALLED_APPS = [
|
|
255
|
+
'django.contrib.admin',
|
|
256
|
+
'django.contrib.auth',
|
|
257
|
+
'rest_framework',
|
|
258
|
+
'apps.users',
|
|
259
|
+
'apps.orders',
|
|
260
|
+
]
|
|
261
|
+
|
|
262
|
+
# config/settings/development.py
|
|
263
|
+
from .base import *
|
|
264
|
+
DEBUG = True
|
|
265
|
+
DATABASES = {'default': {'ENGINE': 'django.db.backends.sqlite3', 'NAME': BASE_DIR / 'db.sqlite3'}}
|
|
266
|
+
|
|
267
|
+
# config/settings/production.py
|
|
268
|
+
from .base import *
|
|
269
|
+
DEBUG = False
|
|
270
|
+
DATABASES = {'default': dj_database_url.config(conn_max_age=600)}
|
|
271
|
+
SECRET_KEY = os.environ['DJANGO_SECRET_KEY']
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
**Never commit secrets.** Use `python-decouple` or `django-environ`.
|
|
275
|
+
|
|
276
|
+
---
|
|
277
|
+
|
|
278
|
+
## Admin
|
|
279
|
+
|
|
280
|
+
```python
|
|
281
|
+
# orders/admin.py
|
|
282
|
+
from django.contrib import admin
|
|
283
|
+
from .models import Order
|
|
284
|
+
|
|
285
|
+
@admin.register(Order)
|
|
286
|
+
class OrderAdmin(admin.ModelAdmin):
|
|
287
|
+
list_display = ['id', 'user', 'status', 'total', 'created_at']
|
|
288
|
+
list_filter = ['status']
|
|
289
|
+
search_fields = ['user__email']
|
|
290
|
+
readonly_fields = ['created_at', 'updated_at']
|
|
291
|
+
ordering = ['-created_at']
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
Always register models with `@admin.register` (not `admin.site.register`).
|
|
295
|
+
|
|
296
|
+
---
|
|
297
|
+
|
|
298
|
+
## Tests (pytest-django)
|
|
299
|
+
|
|
300
|
+
```python
|
|
301
|
+
# conftest.py
|
|
302
|
+
import pytest
|
|
303
|
+
from django.contrib.auth import get_user_model
|
|
304
|
+
|
|
305
|
+
@pytest.fixture
|
|
306
|
+
def user(db):
|
|
307
|
+
return get_user_model().objects.create_user(
|
|
308
|
+
email='test@example.com', password='testpass123'
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
@pytest.fixture
|
|
312
|
+
def api_client(user):
|
|
313
|
+
from rest_framework.test import APIClient
|
|
314
|
+
client = APIClient()
|
|
315
|
+
client.force_authenticate(user=user)
|
|
316
|
+
return client
|
|
317
|
+
|
|
318
|
+
# orders/tests/test_views.py
|
|
319
|
+
import pytest
|
|
320
|
+
|
|
321
|
+
@pytest.mark.django_db
|
|
322
|
+
def test_create_order_authenticated(api_client):
|
|
323
|
+
response = api_client.post('/api/orders/', {'items': [{'price': 10}]})
|
|
324
|
+
assert response.status_code == 201
|
|
325
|
+
|
|
326
|
+
@pytest.mark.django_db
|
|
327
|
+
def test_create_order_unauthenticated(client):
|
|
328
|
+
response = client.post('/api/orders/', {})
|
|
329
|
+
assert response.status_code == 401
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
**Use `pytest-django` over `unittest`** — fixtures are composable and tests are shorter.
|
|
333
|
+
|
|
334
|
+
---
|
|
335
|
+
|
|
336
|
+
## Hard rules
|
|
337
|
+
|
|
338
|
+
- Never put business logic in views, models, or serializers — use services
|
|
339
|
+
- Never use raw SQL unless `ORM` genuinely can't express it
|
|
340
|
+
- Always use `select_related`/`prefetch_related` for related objects in list views
|
|
341
|
+
- Always split settings by environment (base / dev / prod)
|
|
342
|
+
- Always use `get_object_or_404` in views — never `Model.objects.get()` bare
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
# FastAPI Conventions
|
|
2
|
+
|
|
3
|
+
> Async by default, typed by contract. If it's not typed, it's not FastAPI.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Project structure
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
myproject/
|
|
11
|
+
├── main.py # app entrypoint
|
|
12
|
+
├── app/
|
|
13
|
+
│ ├── api/
|
|
14
|
+
│ │ ├── v1/
|
|
15
|
+
│ │ │ ├── router.py # aggregates all v1 routes
|
|
16
|
+
│ │ │ ├── users.py
|
|
17
|
+
│ │ │ └── orders.py
|
|
18
|
+
│ ├── core/
|
|
19
|
+
│ │ ├── config.py # settings via pydantic-settings
|
|
20
|
+
│ │ ├── database.py # async engine + session
|
|
21
|
+
│ │ └── security.py # password hashing, JWT
|
|
22
|
+
│ ├── models/
|
|
23
|
+
│ │ └── user.py # SQLAlchemy ORM models
|
|
24
|
+
│ ├── schemas/
|
|
25
|
+
│ │ └── user.py # Pydantic request/response models
|
|
26
|
+
│ ├── services/
|
|
27
|
+
│ │ └── user_service.py # business logic
|
|
28
|
+
│ └── repositories/
|
|
29
|
+
│ └── user_repo.py # DB queries (optional for SMALL+)
|
|
30
|
+
├── tests/
|
|
31
|
+
│ ├── conftest.py
|
|
32
|
+
│ └── test_users.py
|
|
33
|
+
├── alembic/ # migrations
|
|
34
|
+
└── requirements.txt
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## App bootstrap
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
# main.py
|
|
43
|
+
from fastapi import FastAPI
|
|
44
|
+
from app.api.v1.router import router as v1_router
|
|
45
|
+
from app.core.config import settings
|
|
46
|
+
|
|
47
|
+
app = FastAPI(
|
|
48
|
+
title=settings.PROJECT_NAME,
|
|
49
|
+
version="1.0.0",
|
|
50
|
+
docs_url="/docs" if settings.DEBUG else None,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
app.include_router(v1_router, prefix="/api/v1")
|
|
54
|
+
|
|
55
|
+
@app.get("/health")
|
|
56
|
+
async def health():
|
|
57
|
+
return {"status": "ok"}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Settings (pydantic-settings)
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
# app/core/config.py
|
|
66
|
+
from pydantic_settings import BaseSettings
|
|
67
|
+
|
|
68
|
+
class Settings(BaseSettings):
|
|
69
|
+
PROJECT_NAME: str = "My App"
|
|
70
|
+
DEBUG: bool = False
|
|
71
|
+
DATABASE_URL: str
|
|
72
|
+
SECRET_KEY: str
|
|
73
|
+
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
|
74
|
+
|
|
75
|
+
class Config:
|
|
76
|
+
env_file = ".env"
|
|
77
|
+
|
|
78
|
+
settings = Settings()
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
**Never hardcode secrets.** Always read from environment via `pydantic-settings`.
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## Database (SQLAlchemy async)
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
# app/core/database.py
|
|
89
|
+
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
|
90
|
+
from app.core.config import settings
|
|
91
|
+
|
|
92
|
+
engine = create_async_engine(settings.DATABASE_URL, echo=settings.DEBUG)
|
|
93
|
+
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
|
94
|
+
|
|
95
|
+
async def get_db() -> AsyncSession:
|
|
96
|
+
async with AsyncSessionLocal() as session:
|
|
97
|
+
yield session
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
# app/models/user.py
|
|
102
|
+
from sqlalchemy import Column, Integer, String, DateTime, func
|
|
103
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
104
|
+
|
|
105
|
+
class Base(DeclarativeBase):
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
class User(Base):
|
|
109
|
+
__tablename__ = "users"
|
|
110
|
+
|
|
111
|
+
id = Column(Integer, primary_key=True, index=True)
|
|
112
|
+
email = Column(String, unique=True, nullable=False, index=True)
|
|
113
|
+
hashed_password = Column(String, nullable=False)
|
|
114
|
+
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## Schemas (Pydantic)
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
# app/schemas/user.py
|
|
123
|
+
from pydantic import BaseModel, EmailStr
|
|
124
|
+
from datetime import datetime
|
|
125
|
+
|
|
126
|
+
class UserCreate(BaseModel):
|
|
127
|
+
email: EmailStr
|
|
128
|
+
password: str
|
|
129
|
+
|
|
130
|
+
class UserResponse(BaseModel):
|
|
131
|
+
id: int
|
|
132
|
+
email: EmailStr
|
|
133
|
+
created_at: datetime
|
|
134
|
+
|
|
135
|
+
model_config = {"from_attributes": True} # pydantic v2
|
|
136
|
+
|
|
137
|
+
class UserLogin(BaseModel):
|
|
138
|
+
email: EmailStr
|
|
139
|
+
password: str
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
**Rules:**
|
|
143
|
+
- Separate schemas for input (`UserCreate`) and output (`UserResponse`)
|
|
144
|
+
- Use `EmailStr` for email fields — free format validation
|
|
145
|
+
- `model_config = {"from_attributes": True}` to convert from ORM models
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## Routers — thin, delegate to services
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
# app/api/v1/users.py
|
|
153
|
+
from fastapi import APIRouter, Depends, HTTPException, status
|
|
154
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
155
|
+
from app.core.database import get_db
|
|
156
|
+
from app.schemas.user import UserCreate, UserResponse
|
|
157
|
+
from app.services.user_service import UserService
|
|
158
|
+
|
|
159
|
+
router = APIRouter(prefix="/users", tags=["users"])
|
|
160
|
+
|
|
161
|
+
@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
|
162
|
+
async def create_user(payload: UserCreate, db: AsyncSession = Depends(get_db)):
|
|
163
|
+
service = UserService(db)
|
|
164
|
+
user = await service.create(payload)
|
|
165
|
+
if not user:
|
|
166
|
+
raise HTTPException(status_code=409, detail="Email already registered")
|
|
167
|
+
return user
|
|
168
|
+
|
|
169
|
+
@router.get("/{user_id}", response_model=UserResponse)
|
|
170
|
+
async def get_user(user_id: int, db: AsyncSession = Depends(get_db)):
|
|
171
|
+
service = UserService(db)
|
|
172
|
+
user = await service.get_by_id(user_id)
|
|
173
|
+
if not user:
|
|
174
|
+
raise HTTPException(status_code=404, detail="User not found")
|
|
175
|
+
return user
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## Services — business logic
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
# app/services/user_service.py
|
|
184
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
185
|
+
from sqlalchemy import select
|
|
186
|
+
from app.models.user import User
|
|
187
|
+
from app.schemas.user import UserCreate
|
|
188
|
+
from app.core.security import hash_password
|
|
189
|
+
|
|
190
|
+
class UserService:
|
|
191
|
+
def __init__(self, db: AsyncSession):
|
|
192
|
+
self.db = db
|
|
193
|
+
|
|
194
|
+
async def create(self, payload: UserCreate) -> User | None:
|
|
195
|
+
existing = await self.db.execute(select(User).where(User.email == payload.email))
|
|
196
|
+
if existing.scalar_one_or_none():
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
user = User(
|
|
200
|
+
email=payload.email,
|
|
201
|
+
hashed_password=hash_password(payload.password)
|
|
202
|
+
)
|
|
203
|
+
self.db.add(user)
|
|
204
|
+
await self.db.commit()
|
|
205
|
+
await self.db.refresh(user)
|
|
206
|
+
return user
|
|
207
|
+
|
|
208
|
+
async def get_by_id(self, user_id: int) -> User | None:
|
|
209
|
+
result = await self.db.execute(select(User).where(User.id == user_id))
|
|
210
|
+
return result.scalar_one_or_none()
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
## Authentication (JWT)
|
|
216
|
+
|
|
217
|
+
```python
|
|
218
|
+
# app/core/security.py
|
|
219
|
+
from passlib.context import CryptContext
|
|
220
|
+
from jose import jwt
|
|
221
|
+
from datetime import datetime, timedelta
|
|
222
|
+
from app.core.config import settings
|
|
223
|
+
|
|
224
|
+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
225
|
+
|
|
226
|
+
def hash_password(password: str) -> str:
|
|
227
|
+
return pwd_context.hash(password)
|
|
228
|
+
|
|
229
|
+
def verify_password(plain: str, hashed: str) -> bool:
|
|
230
|
+
return pwd_context.verify(plain, hashed)
|
|
231
|
+
|
|
232
|
+
def create_access_token(subject: str) -> str:
|
|
233
|
+
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
|
234
|
+
return jwt.encode({"sub": subject, "exp": expire}, settings.SECRET_KEY, algorithm="HS256")
|
|
235
|
+
|
|
236
|
+
# Dependency for protected routes
|
|
237
|
+
from fastapi import Security
|
|
238
|
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|
239
|
+
|
|
240
|
+
security = HTTPBearer()
|
|
241
|
+
|
|
242
|
+
async def get_current_user(
|
|
243
|
+
credentials: HTTPAuthorizationCredentials = Security(security),
|
|
244
|
+
db: AsyncSession = Depends(get_db)
|
|
245
|
+
):
|
|
246
|
+
try:
|
|
247
|
+
payload = jwt.decode(credentials.credentials, settings.SECRET_KEY, algorithms=["HS256"])
|
|
248
|
+
user_id = int(payload["sub"])
|
|
249
|
+
except Exception:
|
|
250
|
+
raise HTTPException(status_code=401, detail="Invalid token")
|
|
251
|
+
user = await UserService(db).get_by_id(user_id)
|
|
252
|
+
if not user:
|
|
253
|
+
raise HTTPException(status_code=401, detail="User not found")
|
|
254
|
+
return user
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
---
|
|
258
|
+
|
|
259
|
+
## Dependency injection pattern
|
|
260
|
+
|
|
261
|
+
```python
|
|
262
|
+
# Reuse db dependency across all routes
|
|
263
|
+
@router.get("/me", response_model=UserResponse)
|
|
264
|
+
async def get_me(current_user: User = Depends(get_current_user)):
|
|
265
|
+
return current_user
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
---
|
|
269
|
+
|
|
270
|
+
## Migrations (Alembic)
|
|
271
|
+
|
|
272
|
+
```bash
|
|
273
|
+
alembic init alembic
|
|
274
|
+
# Set sqlalchemy.url in alembic.ini or use env.py to read from config
|
|
275
|
+
alembic revision --autogenerate -m "create users table"
|
|
276
|
+
alembic upgrade head
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
```python
|
|
280
|
+
# alembic/env.py — connect to async engine
|
|
281
|
+
from app.models.user import Base
|
|
282
|
+
target_metadata = Base.metadata
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
---
|
|
286
|
+
|
|
287
|
+
## Tests (pytest + httpx)
|
|
288
|
+
|
|
289
|
+
```python
|
|
290
|
+
# tests/conftest.py
|
|
291
|
+
import pytest
|
|
292
|
+
from httpx import AsyncClient, ASGITransport
|
|
293
|
+
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
|
294
|
+
from app.core.database import get_db
|
|
295
|
+
from app.models.user import Base
|
|
296
|
+
from main import app
|
|
297
|
+
|
|
298
|
+
TEST_DB_URL = "sqlite+aiosqlite:///./test.db"
|
|
299
|
+
|
|
300
|
+
@pytest.fixture
|
|
301
|
+
async def db():
|
|
302
|
+
engine = create_async_engine(TEST_DB_URL)
|
|
303
|
+
async with engine.begin() as conn:
|
|
304
|
+
await conn.run_sync(Base.metadata.create_all)
|
|
305
|
+
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
|
306
|
+
async with session_factory() as session:
|
|
307
|
+
yield session
|
|
308
|
+
async with engine.begin() as conn:
|
|
309
|
+
await conn.run_sync(Base.metadata.drop_all)
|
|
310
|
+
|
|
311
|
+
@pytest.fixture
|
|
312
|
+
async def client(db):
|
|
313
|
+
app.dependency_overrides[get_db] = lambda: db
|
|
314
|
+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
|
315
|
+
yield c
|
|
316
|
+
app.dependency_overrides.clear()
|
|
317
|
+
|
|
318
|
+
# tests/test_users.py
|
|
319
|
+
import pytest
|
|
320
|
+
|
|
321
|
+
@pytest.mark.anyio
|
|
322
|
+
async def test_create_user(client):
|
|
323
|
+
response = await client.post("/api/v1/users/", json={"email": "a@test.com", "password": "pass123"})
|
|
324
|
+
assert response.status_code == 201
|
|
325
|
+
assert response.json()["email"] == "a@test.com"
|
|
326
|
+
|
|
327
|
+
@pytest.mark.anyio
|
|
328
|
+
async def test_create_user_duplicate(client):
|
|
329
|
+
await client.post("/api/v1/users/", json={"email": "a@test.com", "password": "pass123"})
|
|
330
|
+
response = await client.post("/api/v1/users/", json={"email": "a@test.com", "password": "pass123"})
|
|
331
|
+
assert response.status_code == 409
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
---
|
|
335
|
+
|
|
336
|
+
## Hard rules
|
|
337
|
+
|
|
338
|
+
- All route handlers must be `async def` — never mix sync I/O in async routes
|
|
339
|
+
- Business logic goes in `services/`, not in route handlers
|
|
340
|
+
- Always use Pydantic schemas for both input validation and response serialization
|
|
341
|
+
- Always use `response_model` in route decorators — never return raw dicts
|
|
342
|
+
- Use `Depends()` for all cross-cutting concerns (auth, db, rate limiting)
|
|
343
|
+
- Use `select_related` equivalent (`selectinload`/`joinedload`) to avoid N+1
|
|
344
|
+
- Never catch bare `Exception` — catch specific error types
|