fastapi-fullstack 0.1.7__py3-none-any.whl → 0.1.15__py3-none-any.whl

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.
Files changed (71) hide show
  1. {fastapi_fullstack-0.1.7.dist-info → fastapi_fullstack-0.1.15.dist-info}/METADATA +9 -2
  2. {fastapi_fullstack-0.1.7.dist-info → fastapi_fullstack-0.1.15.dist-info}/RECORD +71 -55
  3. fastapi_gen/__init__.py +6 -1
  4. fastapi_gen/cli.py +9 -0
  5. fastapi_gen/config.py +154 -2
  6. fastapi_gen/generator.py +34 -14
  7. fastapi_gen/prompts.py +172 -31
  8. fastapi_gen/template/VARIABLES.md +33 -4
  9. fastapi_gen/template/cookiecutter.json +10 -0
  10. fastapi_gen/template/hooks/post_gen_project.py +87 -2
  11. fastapi_gen/template/{{cookiecutter.project_slug}}/.env.prod.example +9 -0
  12. fastapi_gen/template/{{cookiecutter.project_slug}}/.gitlab-ci.yml +178 -0
  13. fastapi_gen/template/{{cookiecutter.project_slug}}/CLAUDE.md +3 -0
  14. fastapi_gen/template/{{cookiecutter.project_slug}}/README.md +334 -0
  15. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/.env.example +32 -0
  16. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/alembic/env.py +10 -1
  17. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/admin.py +1 -1
  18. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/agents/__init__.py +31 -0
  19. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/agents/crewai_assistant.py +563 -0
  20. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/agents/deepagents_assistant.py +526 -0
  21. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/agents/langchain_assistant.py +4 -3
  22. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/agents/langgraph_assistant.py +371 -0
  23. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/api/routes/v1/agent.py +1472 -0
  24. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/api/routes/v1/oauth.py +3 -7
  25. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/commands/cleanup.py +2 -2
  26. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/commands/seed.py +7 -2
  27. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/core/config.py +44 -7
  28. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/db/__init__.py +7 -0
  29. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/db/base.py +42 -0
  30. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/db/models/conversation.py +262 -1
  31. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/db/models/item.py +76 -1
  32. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/db/models/session.py +118 -1
  33. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/db/models/user.py +158 -1
  34. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/db/models/webhook.py +185 -3
  35. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/main.py +29 -2
  36. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/repositories/base.py +6 -0
  37. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/repositories/session.py +4 -4
  38. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/services/conversation.py +9 -9
  39. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/services/session.py +6 -6
  40. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/services/webhook.py +7 -7
  41. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/worker/__init__.py +1 -1
  42. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/worker/arq_app.py +165 -0
  43. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/worker/tasks/__init__.py +10 -1
  44. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/pyproject.toml +40 -0
  45. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/tests/api/test_metrics.py +53 -0
  46. fastapi_gen/template/{{cookiecutter.project_slug}}/backend/tests/test_agents.py +2 -0
  47. fastapi_gen/template/{{cookiecutter.project_slug}}/docker-compose.dev.yml +6 -0
  48. fastapi_gen/template/{{cookiecutter.project_slug}}/docker-compose.prod.yml +100 -0
  49. fastapi_gen/template/{{cookiecutter.project_slug}}/docker-compose.yml +39 -0
  50. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/.env.example +5 -0
  51. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/chat/chat-container.tsx +28 -1
  52. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/chat/index.ts +1 -0
  53. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/chat/message-item.tsx +22 -4
  54. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/chat/message-list.tsx +23 -3
  55. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/chat/tool-approval-dialog.tsx +138 -0
  56. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/hooks/use-chat.ts +242 -18
  57. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/hooks/use-local-chat.ts +242 -17
  58. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/lib/constants.ts +1 -1
  59. fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/types/chat.ts +57 -1
  60. fastapi_gen/template/{{cookiecutter.project_slug}}/kubernetes/configmap.yaml +63 -0
  61. fastapi_gen/template/{{cookiecutter.project_slug}}/kubernetes/deployment.yaml +242 -0
  62. fastapi_gen/template/{{cookiecutter.project_slug}}/kubernetes/ingress.yaml +44 -0
  63. fastapi_gen/template/{{cookiecutter.project_slug}}/kubernetes/kustomization.yaml +28 -0
  64. fastapi_gen/template/{{cookiecutter.project_slug}}/kubernetes/namespace.yaml +12 -0
  65. fastapi_gen/template/{{cookiecutter.project_slug}}/kubernetes/secret.yaml +59 -0
  66. fastapi_gen/template/{{cookiecutter.project_slug}}/kubernetes/service.yaml +23 -0
  67. fastapi_gen/template/{{cookiecutter.project_slug}}/nginx/nginx.conf +225 -0
  68. fastapi_gen/template/{{cookiecutter.project_slug}}/nginx/ssl/.gitkeep +18 -0
  69. {fastapi_fullstack-0.1.7.dist-info → fastapi_fullstack-0.1.15.dist-info}/WHEEL +0 -0
  70. {fastapi_fullstack-0.1.7.dist-info → fastapi_fullstack-0.1.15.dist-info}/entry_points.txt +0 -0
  71. {fastapi_fullstack-0.1.7.dist-info → fastapi_fullstack-0.1.15.dist-info}/licenses/LICENSE +0 -0
@@ -4,7 +4,7 @@
4
4
  Contains business logic for conversation, message, and tool call operations.
5
5
  """
6
6
 
7
- from datetime import datetime
7
+ from datetime import UTC, datetime
8
8
  from uuid import UUID
9
9
 
10
10
  from sqlalchemy.ext.asyncio import AsyncSession
@@ -264,7 +264,7 @@ class ConversationService:
264
264
  tool_call_id=data.tool_call_id,
265
265
  tool_name=data.tool_name,
266
266
  args=data.args,
267
- started_at=data.started_at or datetime.utcnow(),
267
+ started_at=data.started_at or datetime.now(UTC),
268
268
  )
269
269
 
270
270
  async def complete_tool_call(
@@ -282,7 +282,7 @@ class ConversationService:
282
282
  self.db,
283
283
  db_tool_call=tool_call,
284
284
  result=data.result,
285
- completed_at=data.completed_at or datetime.utcnow(),
285
+ completed_at=data.completed_at or datetime.now(UTC),
286
286
  success=data.success,
287
287
  )
288
288
 
@@ -293,7 +293,7 @@ class ConversationService:
293
293
  Contains business logic for conversation, message, and tool call operations.
294
294
  """
295
295
 
296
- from datetime import datetime
296
+ from datetime import UTC, datetime
297
297
 
298
298
  from sqlalchemy.orm import Session
299
299
 
@@ -550,7 +550,7 @@ class ConversationService:
550
550
  tool_call_id=data.tool_call_id,
551
551
  tool_name=data.tool_name,
552
552
  args=data.args,
553
- started_at=data.started_at or datetime.utcnow(),
553
+ started_at=data.started_at or datetime.now(UTC),
554
554
  )
555
555
 
556
556
  def complete_tool_call(
@@ -568,7 +568,7 @@ class ConversationService:
568
568
  self.db,
569
569
  db_tool_call=tool_call,
570
570
  result=data.result,
571
- completed_at=data.completed_at or datetime.utcnow(),
571
+ completed_at=data.completed_at or datetime.now(UTC),
572
572
  success=data.success,
573
573
  )
574
574
 
@@ -579,7 +579,7 @@ class ConversationService:
579
579
  Contains business logic for conversation, message, and tool call operations.
580
580
  """
581
581
 
582
- from datetime import datetime
582
+ from datetime import UTC, datetime
583
583
 
584
584
  from app.core.exceptions import NotFoundError
585
585
  from app.db.models.conversation import Conversation, Message, ToolCall
@@ -823,7 +823,7 @@ class ConversationService:
823
823
  tool_call_id=data.tool_call_id,
824
824
  tool_name=data.tool_name,
825
825
  args=data.args,
826
- started_at=data.started_at or datetime.utcnow(),
826
+ started_at=data.started_at or datetime.now(UTC),
827
827
  )
828
828
 
829
829
  async def complete_tool_call(
@@ -840,7 +840,7 @@ class ConversationService:
840
840
  return await conversation_repo.complete_tool_call(
841
841
  db_tool_call=tool_call,
842
842
  result=data.result,
843
- completed_at=data.completed_at or datetime.utcnow(),
843
+ completed_at=data.completed_at or datetime.now(UTC),
844
844
  success=data.success,
845
845
  )
846
846
 
@@ -3,7 +3,7 @@
3
3
  """Session service (PostgreSQL async)."""
4
4
 
5
5
  import hashlib
6
- from datetime import datetime, timedelta
6
+ from datetime import UTC, datetime, timedelta
7
7
  from uuid import UUID
8
8
 
9
9
  from sqlalchemy.ext.asyncio import AsyncSession
@@ -64,7 +64,7 @@ class SessionService:
64
64
  ) -> Session:
65
65
  """Create a new session for a user."""
66
66
  device_name, device_type = _parse_user_agent(user_agent)
67
- expires_at = datetime.utcnow() + timedelta(minutes=settings.REFRESH_TOKEN_EXPIRE_MINUTES)
67
+ expires_at = datetime.now(UTC) + timedelta(minutes=settings.REFRESH_TOKEN_EXPIRE_MINUTES)
68
68
 
69
69
  return await session_repo.create(
70
70
  self.db,
@@ -86,7 +86,7 @@ class SessionService:
86
86
  token_hash = _hash_token(refresh_token)
87
87
  session = await session_repo.get_by_refresh_token_hash(self.db, token_hash)
88
88
 
89
- if session and session.expires_at > datetime.utcnow():
89
+ if session and session.expires_at > datetime.now(UTC):
90
90
  await session_repo.update_last_used(self.db, session.id)
91
91
  return session
92
92
 
@@ -115,7 +115,7 @@ class SessionService:
115
115
  """Session service (SQLite sync)."""
116
116
 
117
117
  import hashlib
118
- from datetime import datetime, timedelta
118
+ from datetime import UTC, datetime, timedelta
119
119
 
120
120
  from sqlalchemy.orm import Session as DBSession
121
121
 
@@ -175,7 +175,7 @@ class SessionService:
175
175
  ) -> Session:
176
176
  """Create a new session for a user."""
177
177
  device_name, device_type = _parse_user_agent(user_agent)
178
- expires_at = datetime.utcnow() + timedelta(minutes=settings.REFRESH_TOKEN_EXPIRE_MINUTES)
178
+ expires_at = datetime.now(UTC) + timedelta(minutes=settings.REFRESH_TOKEN_EXPIRE_MINUTES)
179
179
 
180
180
  return session_repo.create(
181
181
  self.db,
@@ -197,7 +197,7 @@ class SessionService:
197
197
  token_hash = _hash_token(refresh_token)
198
198
  session = session_repo.get_by_refresh_token_hash(self.db, token_hash)
199
199
 
200
- if session and session.expires_at > datetime.utcnow():
200
+ if session and session.expires_at > datetime.now(UTC):
201
201
  session_repo.update_last_used(self.db, session.id)
202
202
  return session
203
203
 
@@ -6,7 +6,7 @@ import hashlib
6
6
  import hmac
7
7
  import json
8
8
  import secrets
9
- from datetime import datetime
9
+ from datetime import UTC, datetime
10
10
  from uuid import UUID
11
11
 
12
12
  import httpx
@@ -89,7 +89,7 @@ class WebhookService:
89
89
 
90
90
  test_payload = {
91
91
  "event": "webhook.test",
92
- "timestamp": datetime.utcnow().isoformat(),
92
+ "timestamp": datetime.now(UTC).isoformat(),
93
93
  "data": {"message": "This is a test webhook delivery"},
94
94
  }
95
95
 
@@ -106,7 +106,7 @@ class WebhookService:
106
106
 
107
107
  payload = {
108
108
  "event": event_type,
109
- "timestamp": datetime.utcnow().isoformat(),
109
+ "timestamp": datetime.now(UTC).isoformat(),
110
110
  "data": data,
111
111
  }
112
112
 
@@ -159,7 +159,7 @@ class WebhookService:
159
159
  delivery.response_status = response.status_code
160
160
  delivery.response_body = response.text[:10000] # Limit size
161
161
  delivery.success = 200 <= response.status_code < 300
162
- delivery.delivered_at = datetime.utcnow()
162
+ delivery.delivered_at = datetime.now(UTC)
163
163
 
164
164
  logfire.info(
165
165
  "Webhook delivered",
@@ -228,7 +228,7 @@ import hashlib
228
228
  import hmac
229
229
  import json
230
230
  import secrets
231
- from datetime import datetime
231
+ from datetime import UTC, datetime
232
232
 
233
233
  import httpx
234
234
  import logfire
@@ -306,7 +306,7 @@ class WebhookService:
306
306
 
307
307
  payload = {
308
308
  "event": event_type,
309
- "timestamp": datetime.utcnow().isoformat(),
309
+ "timestamp": datetime.now(UTC).isoformat(),
310
310
  "data": data,
311
311
  }
312
312
 
@@ -356,7 +356,7 @@ class WebhookService:
356
356
  delivery.response_status = response.status_code
357
357
  delivery.response_body = response.text[:10000]
358
358
  delivery.success = 200 <= response.status_code < 300
359
- delivery.delivered_at = datetime.utcnow()
359
+ delivery.delivered_at = datetime.now(UTC)
360
360
 
361
361
  except Exception as e:
362
362
  delivery.error_message = str(e)
@@ -1,4 +1,4 @@
1
- {%- if cookiecutter.use_celery or cookiecutter.use_taskiq %}
1
+ {%- if cookiecutter.use_celery or cookiecutter.use_taskiq or cookiecutter.use_arq %}
2
2
  """Background workers."""
3
3
  {%- else %}
4
4
  # Background workers not enabled
@@ -0,0 +1,165 @@
1
+ {%- if cookiecutter.use_arq %}
2
+ """ARQ (Async Redis Queue) application configuration."""
3
+
4
+ import asyncio
5
+ import logging
6
+ from typing import Any
7
+
8
+ from arq import cron
9
+ from arq.connections import RedisSettings
10
+
11
+ from app.core.config import settings
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ async def startup(ctx: dict[str, Any]) -> None:
17
+ """Initialize resources on worker startup."""
18
+ logger.info("ARQ worker starting up...")
19
+ # Add any startup initialization here
20
+ # e.g., database connections, external clients
21
+
22
+
23
+ async def shutdown(ctx: dict[str, Any]) -> None:
24
+ """Cleanup resources on worker shutdown."""
25
+ logger.info("ARQ worker shutting down...")
26
+ # Add any cleanup here
27
+
28
+
29
+ # === Example Tasks ===
30
+ # Tasks are defined as regular async functions
31
+
32
+
33
+ async def example_task(ctx: dict[str, Any], message: str) -> dict[str, Any]:
34
+ """
35
+ Example task that processes a message.
36
+
37
+ Args:
38
+ ctx: ARQ context dictionary (contains redis connection, job info, etc.)
39
+ message: Message to process
40
+
41
+ Returns:
42
+ Result dictionary with processed message
43
+ """
44
+ logger.info(f"Processing message: {message}")
45
+
46
+ # Simulate async work
47
+ await asyncio.sleep(1)
48
+
49
+ result = {
50
+ "status": "completed",
51
+ "message": f"Processed: {message}",
52
+ "job_id": ctx.get("job_id"),
53
+ }
54
+ logger.info(f"Task completed: {result}")
55
+ return result
56
+
57
+
58
+ async def long_running_task(ctx: dict[str, Any], duration: int = 10) -> dict[str, Any]:
59
+ """
60
+ Example long-running async task.
61
+
62
+ Args:
63
+ ctx: ARQ context dictionary
64
+ duration: Duration in seconds
65
+
66
+ Returns:
67
+ Result dictionary
68
+ """
69
+ logger.info(f"Starting long-running task for {duration} seconds")
70
+
71
+ for i in range(duration):
72
+ await asyncio.sleep(1)
73
+ logger.info(f"Progress: {i + 1}/{duration}")
74
+
75
+ return {
76
+ "status": "completed",
77
+ "duration": duration,
78
+ "job_id": ctx.get("job_id"),
79
+ }
80
+
81
+
82
+ async def send_email_task(
83
+ ctx: dict[str, Any], to: str, subject: str, body: str
84
+ ) -> dict[str, Any]:
85
+ """
86
+ Example email sending task.
87
+
88
+ Replace with actual email sending logic (e.g., using aiosmtplib, sendgrid, etc.)
89
+
90
+ Args:
91
+ ctx: ARQ context dictionary
92
+ to: Recipient email
93
+ subject: Email subject
94
+ body: Email body
95
+
96
+ Returns:
97
+ Result dictionary
98
+ """
99
+ logger.info(f"Sending email to {to}: {subject}")
100
+
101
+ # TODO: Implement actual email sending
102
+ # Example with aiosmtplib:
103
+ # import aiosmtplib
104
+ # ...
105
+
106
+ # Simulate sending
107
+ await asyncio.sleep(0.5)
108
+
109
+ return {
110
+ "status": "sent",
111
+ "to": to,
112
+ "subject": subject,
113
+ }
114
+
115
+
116
+ # === Scheduled Task (runs periodically) ===
117
+
118
+
119
+ async def scheduled_example(ctx: dict[str, Any]) -> dict[str, Any]:
120
+ """Example scheduled task that runs every minute."""
121
+ logger.info("Running scheduled example task")
122
+ return await example_task(ctx, "scheduled")
123
+
124
+
125
+ # === Worker Settings ===
126
+ # This class is used by the ARQ CLI: arq app.worker.arq_app.WorkerSettings
127
+
128
+
129
+ class WorkerSettings:
130
+ """ARQ Worker configuration."""
131
+
132
+ # Redis connection settings
133
+ redis_settings = RedisSettings(
134
+ host=settings.ARQ_REDIS_HOST,
135
+ port=settings.ARQ_REDIS_PORT,
136
+ password=settings.ARQ_REDIS_PASSWORD or None,
137
+ database=settings.ARQ_REDIS_DB,
138
+ )
139
+
140
+ # Register task functions
141
+ functions = [
142
+ example_task,
143
+ long_running_task,
144
+ send_email_task,
145
+ ]
146
+
147
+ # Scheduled/cron jobs
148
+ cron_jobs = [
149
+ cron(scheduled_example, minute={0, 15, 30, 45}), # Every 15 minutes
150
+ # cron(scheduled_example, minute=0, hour=0), # Daily at midnight
151
+ ]
152
+
153
+ # Worker lifecycle hooks
154
+ on_startup = startup
155
+ on_shutdown = shutdown
156
+
157
+ # Worker settings
158
+ max_jobs = 10 # Maximum concurrent jobs
159
+ job_timeout = 300 # Job timeout in seconds (5 minutes)
160
+ keep_result = 3600 # Keep results for 1 hour
161
+ poll_delay = 0.5 # Polling delay in seconds
162
+ queue_read_limit = 100 # Number of jobs to read at once
163
+ {%- else %}
164
+ # ARQ not enabled for this project
165
+ {%- endif %}
@@ -1,4 +1,4 @@
1
- {%- if cookiecutter.use_celery or cookiecutter.use_taskiq %}
1
+ {%- if cookiecutter.use_celery or cookiecutter.use_taskiq or cookiecutter.use_arq %}
2
2
  """Background tasks."""
3
3
 
4
4
  {%- if cookiecutter.use_celery %}
@@ -10,6 +10,11 @@ from app.worker.tasks.taskiq_examples import example_task as taskiq_example_task
10
10
  from app.worker.tasks.taskiq_examples import long_running_task as taskiq_long_running_task
11
11
  {%- endif %}
12
12
 
13
+ {%- if cookiecutter.use_arq %}
14
+ from app.worker.arq_app import example_task as arq_example_task
15
+ from app.worker.arq_app import long_running_task as arq_long_running_task
16
+ {%- endif %}
17
+
13
18
  __all__ = [
14
19
  {%- if cookiecutter.use_celery %}
15
20
  "example_task",
@@ -19,6 +24,10 @@ __all__ = [
19
24
  "taskiq_example_task",
20
25
  "taskiq_long_running_task",
21
26
  {%- endif %}
27
+ {%- if cookiecutter.use_arq %}
28
+ "arq_example_task",
29
+ "arq_long_running_task",
30
+ {%- endif %}
22
31
  ]
23
32
  {%- else %}
24
33
  # Background tasks not enabled
@@ -31,7 +31,11 @@ dependencies = [
31
31
  "logfire[{{ logfire_extras | join(',') }}]>=2.0.0",
32
32
  {%- endif %}
33
33
  {%- if cookiecutter.use_postgresql %}
34
+ {%- if cookiecutter.use_sqlmodel %}
35
+ "sqlmodel>=0.0.22",
36
+ {%- else %}
34
37
  "sqlalchemy[asyncio]>=2.0.0",
38
+ {%- endif %}
35
39
  "asyncpg>=0.30.0",
36
40
  "psycopg2-binary>=2.9.0",
37
41
  "alembic>=1.14.0",
@@ -42,7 +46,11 @@ dependencies = [
42
46
  "beanie>=1.27.0",
43
47
  {%- endif %}
44
48
  {%- if cookiecutter.use_sqlite %}
49
+ {%- if cookiecutter.use_sqlmodel %}
50
+ "sqlmodel>=0.0.22",
51
+ {%- else %}
45
52
  "sqlalchemy>=2.0.0",
53
+ {%- endif %}
46
54
  "alembic>=1.14.0",
47
55
  {%- endif %}
48
56
  {%- if cookiecutter.use_jwt %}
@@ -69,6 +77,9 @@ dependencies = [
69
77
  {%- if cookiecutter.enable_sentry %}
70
78
  "sentry-sdk[fastapi]>=2.18.0",
71
79
  {%- endif %}
80
+ {%- if cookiecutter.enable_prometheus %}
81
+ "prometheus-fastapi-instrumentator>=7.0.0",
82
+ {%- endif %}
72
83
  {%- if cookiecutter.enable_admin_panel %}
73
84
  "sqladmin>=0.19.0",
74
85
  {%- if cookiecutter.admin_require_auth %}
@@ -111,6 +122,35 @@ dependencies = [
111
122
  {%- endif %}
112
123
  "langgraph>=0.2.0",
113
124
  {%- endif %}
125
+ {%- if cookiecutter.enable_ai_agent and cookiecutter.use_langgraph %}
126
+ "langchain-core>=0.3.0",
127
+ {%- if cookiecutter.use_openai %}
128
+ "langchain-openai>=0.3.0",
129
+ {%- endif %}
130
+ {%- if cookiecutter.use_anthropic %}
131
+ "langchain-anthropic>=0.3.0",
132
+ {%- endif %}
133
+ "langgraph>=0.2.0",
134
+ "langgraph-checkpoint>=2.0.0",
135
+ {%- endif %}
136
+ {%- if cookiecutter.enable_ai_agent and cookiecutter.use_crewai %}
137
+ "crewai>=1.0.0",
138
+ {%- if cookiecutter.use_openai %}
139
+ "langchain-openai>=0.3.0",
140
+ {%- endif %}
141
+ {%- if cookiecutter.use_anthropic %}
142
+ "langchain-anthropic>=0.3.0",
143
+ {%- endif %}
144
+ {%- endif %}
145
+ {%- if cookiecutter.enable_ai_agent and cookiecutter.use_deepagents %}
146
+ "deepagents>=0.1.0",
147
+ {%- if cookiecutter.use_openai %}
148
+ "langchain-openai>=0.3.0",
149
+ {%- endif %}
150
+ {%- if cookiecutter.use_anthropic %}
151
+ "langchain-anthropic>=0.3.0",
152
+ {%- endif %}
153
+ {%- endif %}
114
154
  {%- if cookiecutter.logfire_httpx or cookiecutter.enable_file_storage %}
115
155
  "httpx>=0.27.0",
116
156
  {%- endif %}
@@ -0,0 +1,53 @@
1
+ {%- if cookiecutter.enable_prometheus %}
2
+ """Prometheus metrics endpoint tests."""
3
+
4
+ import pytest
5
+ from httpx import AsyncClient
6
+
7
+ from app.core.config import settings
8
+
9
+
10
+ @pytest.mark.anyio
11
+ async def test_metrics_endpoint(client: AsyncClient):
12
+ """Test that /metrics endpoint returns Prometheus metrics."""
13
+ response = await client.get(settings.PROMETHEUS_METRICS_PATH)
14
+ assert response.status_code == 200
15
+ assert response.headers["content-type"].startswith("text/plain")
16
+
17
+ # Check for standard prometheus-fastapi-instrumentator metrics
18
+ content = response.text
19
+ assert "http_requests_total" in content
20
+ assert "http_request_duration_seconds" in content
21
+
22
+
23
+ @pytest.mark.anyio
24
+ async def test_metrics_increments_on_request(client: AsyncClient):
25
+ """Test that metrics are incremented after making requests."""
26
+ # Make a health check request first
27
+ await client.get(f"{settings.API_V1_STR}/health")
28
+
29
+ # Then check metrics
30
+ response = await client.get(settings.PROMETHEUS_METRICS_PATH)
31
+ assert response.status_code == 200
32
+
33
+ content = response.text
34
+ # Should have recorded the health check request
35
+ assert "http_requests_total" in content
36
+
37
+
38
+ @pytest.mark.anyio
39
+ async def test_metrics_excluded_from_own_metrics(client: AsyncClient):
40
+ """Test that /metrics endpoint doesn't count itself in metrics."""
41
+ # Make multiple requests to /metrics
42
+ for _ in range(3):
43
+ await client.get(settings.PROMETHEUS_METRICS_PATH)
44
+
45
+ response = await client.get(settings.PROMETHEUS_METRICS_PATH)
46
+ content = response.text
47
+
48
+ # The /metrics endpoint should be excluded from instrumentation
49
+ # so we shouldn't see it in the metrics path labels
50
+ assert f'path="{settings.PROMETHEUS_METRICS_PATH}"' not in content
51
+ {%- else %}
52
+ # Prometheus metrics are not enabled for this project
53
+ {%- endif %}
@@ -58,6 +58,7 @@ class TestAssistantAgent:
58
58
  assert agent.temperature == 0.5
59
59
  assert agent.system_prompt == "Custom prompt"
60
60
 
61
+ @patch.dict("os.environ", {"OPENAI_API_KEY": "test-key"})
61
62
  @patch("app.agents.assistant.OpenAIProvider")
62
63
  @patch("app.agents.assistant.OpenAIChatModel")
63
64
  def test_agent_property_creates_agent(self, mock_model, mock_provider):
@@ -67,6 +68,7 @@ class TestAssistantAgent:
67
68
  assert agent._agent is not None
68
69
  mock_model.assert_called_once()
69
70
 
71
+ @patch.dict("os.environ", {"OPENAI_API_KEY": "test-key"})
70
72
  @patch("app.agents.assistant.OpenAIProvider")
71
73
  @patch("app.agents.assistant.OpenAIChatModel")
72
74
  def test_agent_property_caches_agent(self, mock_model, mock_provider):
@@ -50,6 +50,12 @@ services:
50
50
  {%- endif %}
51
51
  {%- endif %}
52
52
  restart: unless-stopped
53
+ {%- if cookiecutter.enable_prometheus %}
54
+ labels:
55
+ - "prometheus.scrape=true"
56
+ - "prometheus.port={{ cookiecutter.backend_port }}"
57
+ - "prometheus.path=/metrics"
58
+ {%- endif %}
53
59
 
54
60
  {%- if cookiecutter.use_postgresql %}
55
61
 
@@ -4,6 +4,10 @@
4
4
  # with Traefik reverse proxy
5
5
  {%- elif cookiecutter.include_traefik_labels %}
6
6
  # with external Traefik (connect to traefik-public network)
7
+ {%- elif cookiecutter.include_nginx_service %}
8
+ # with Nginx reverse proxy
9
+ {%- elif cookiecutter.include_nginx_config %}
10
+ # with external Nginx (config template provided)
7
11
  {%- else %}
8
12
  # without reverse proxy (ports exposed directly)
9
13
  {%- endif %}
@@ -24,6 +28,19 @@
24
28
  # 1. Create external traefik-public network: docker network create traefik-public
25
29
  # 2. Run your Traefik instance connected to traefik-public network
26
30
  # 3. Configure .env.prod with DOMAIN
31
+ {%- elif cookiecutter.include_nginx_service %}
32
+ #
33
+ # Prerequisites:
34
+ # 1. Place SSL certificates in nginx/ssl/ directory:
35
+ # - nginx/ssl/cert.pem (certificate)
36
+ # - nginx/ssl/key.pem (private key)
37
+ # 2. Configure .env.prod with DOMAIN
38
+ # 3. Ensure ports 80 and 443 are available
39
+ {%- elif cookiecutter.include_nginx_config %}
40
+ #
41
+ # Prerequisites:
42
+ # 1. Configure your external Nginx using nginx/nginx.conf as reference
43
+ # 2. Configure .env.prod with DOMAIN
27
44
  {%- endif %}
28
45
 
29
46
  services:
@@ -82,6 +99,36 @@ services:
82
99
  memory: 256M
83
100
  {%- endif %}
84
101
 
102
+ {%- if cookiecutter.include_nginx_service %}
103
+ nginx:
104
+ image: nginx:alpine
105
+ container_name: {{ cookiecutter.project_slug }}_nginx
106
+ ports:
107
+ - "80:80"
108
+ - "443:443"
109
+ volumes:
110
+ - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
111
+ - ./nginx/ssl:/etc/nginx/ssl:ro
112
+ networks:
113
+ - backend-internal
114
+ depends_on:
115
+ - app
116
+ {%- if cookiecutter.use_frontend %}
117
+ - frontend
118
+ {%- endif %}
119
+ healthcheck:
120
+ test: ["CMD", "nginx", "-t"]
121
+ interval: 30s
122
+ timeout: 10s
123
+ retries: 3
124
+ restart: unless-stopped
125
+ deploy:
126
+ resources:
127
+ limits:
128
+ cpus: '0.5'
129
+ memory: 128M
130
+ {%- endif %}
131
+
85
132
  app:
86
133
  build:
87
134
  context: ./backend
@@ -120,6 +167,10 @@ services:
120
167
  - "traefik.http.routers.{{ cookiecutter.project_slug }}-api.middlewares={{ cookiecutter.project_slug }}-security-headers@docker"
121
168
  - "traefik.http.services.{{ cookiecutter.project_slug }}-api.loadbalancer.server.port={{ cookiecutter.backend_port }}"
122
169
  - "traefik.docker.network=traefik-public"
170
+ {%- elif cookiecutter.include_nginx_service %}
171
+ networks:
172
+ - backend-internal
173
+ # No ports exposed - nginx handles external traffic
123
174
  {%- else %}
124
175
  networks:
125
176
  - backend-internal
@@ -289,6 +340,10 @@ services:
289
340
  - "traefik.http.routers.{{ cookiecutter.project_slug }}-flower.tls.certresolver=letsencrypt"
290
341
  - "traefik.http.services.{{ cookiecutter.project_slug }}-flower.loadbalancer.server.port=5555"
291
342
  - "traefik.docker.network=traefik-public"
343
+ {%- elif cookiecutter.include_nginx_service %}
344
+ networks:
345
+ - backend-internal
346
+ # No ports exposed - nginx handles external traffic
292
347
  {%- else %}
293
348
  networks:
294
349
  - backend-internal
@@ -372,6 +427,45 @@ services:
372
427
  memory: 128M
373
428
  {%- endif %}
374
429
 
430
+ {%- if cookiecutter.use_arq %}
431
+
432
+ arq_worker:
433
+ build:
434
+ context: ./backend
435
+ dockerfile: Dockerfile
436
+ container_name: {{ cookiecutter.project_slug }}_arq_worker
437
+ command: arq app.worker.arq_app.WorkerSettings
438
+ env_file:
439
+ - .env.prod
440
+ - ./backend/.env
441
+ environment:
442
+ - DEBUG=false
443
+ {%- if cookiecutter.use_postgresql %}
444
+ - POSTGRES_HOST=db
445
+ - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
446
+ {%- endif %}
447
+ - REDIS_HOST=redis
448
+ - REDIS_PASSWORD=${REDIS_PASSWORD}
449
+ - ARQ_REDIS_HOST=redis
450
+ - ARQ_REDIS_PORT=6379
451
+ - ARQ_REDIS_DB=2
452
+ networks:
453
+ - backend-internal
454
+ depends_on:
455
+ redis:
456
+ condition: service_healthy
457
+ {%- if cookiecutter.use_postgresql %}
458
+ db:
459
+ condition: service_healthy
460
+ {%- endif %}
461
+ restart: unless-stopped
462
+ deploy:
463
+ resources:
464
+ limits:
465
+ cpus: '0.5'
466
+ memory: 256M
467
+ {%- endif %}
468
+
375
469
  {%- if cookiecutter.use_frontend %}
376
470
 
377
471
  frontend:
@@ -394,7 +488,13 @@ services:
394
488
  - "traefik.http.routers.{{ cookiecutter.project_slug }}-frontend.middlewares={{ cookiecutter.project_slug }}-security-headers@docker"
395
489
  - "traefik.http.services.{{ cookiecutter.project_slug }}-frontend.loadbalancer.server.port={{ cookiecutter.frontend_port }}"
396
490
  - "traefik.docker.network=traefik-public"
491
+ {%- elif cookiecutter.include_nginx_service %}
492
+ networks:
493
+ - backend-internal
494
+ # No ports exposed - nginx handles external traffic
397
495
  {%- else %}
496
+ networks:
497
+ - backend-internal
398
498
  ports:
399
499
  - "{{ cookiecutter.frontend_port }}:{{ cookiecutter.frontend_port }}"
400
500
  {%- endif %}