fastapi-forge-cli 0.1.0__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 (181) hide show
  1. fastapi_forge/__init__.py +7 -0
  2. fastapi_forge/__main__.py +6 -0
  3. fastapi_forge/cli.py +211 -0
  4. fastapi_forge/templates/with_rbac/Dockerfile +31 -0
  5. fastapi_forge/templates/with_rbac/README.md +121 -0
  6. fastapi_forge/templates/with_rbac/_dockerignore +16 -0
  7. fastapi_forge/templates/with_rbac/_github/workflows/ci.yml +23 -0
  8. fastapi_forge/templates/with_rbac/_gitignore +19 -0
  9. fastapi_forge/templates/with_rbac/alembic/README +1 -0
  10. fastapi_forge/templates/with_rbac/alembic/__init__.py +1 -0
  11. fastapi_forge/templates/with_rbac/alembic/env.py +51 -0
  12. fastapi_forge/templates/with_rbac/alembic/script.py.mako +28 -0
  13. fastapi_forge/templates/with_rbac/alembic/versions/2255ba4f9604_fresh_baseline.py +204 -0
  14. fastapi_forge/templates/with_rbac/alembic.ini +35 -0
  15. fastapi_forge/templates/with_rbac/app/__init__.py +1 -0
  16. fastapi_forge/templates/with_rbac/app/api/__init__.py +1 -0
  17. fastapi_forge/templates/with_rbac/app/api/v1/__init__.py +1 -0
  18. fastapi_forge/templates/with_rbac/app/api/v1/api.py +34 -0
  19. fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/__init__.py +1 -0
  20. fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/repository.py +38 -0
  21. fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/router.py +50 -0
  22. fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/schema.py +21 -0
  23. fastapi_forge/templates/with_rbac/app/api/v1/auth/__init__.py +0 -0
  24. fastapi_forge/templates/with_rbac/app/api/v1/auth/repository.py +179 -0
  25. fastapi_forge/templates/with_rbac/app/api/v1/auth/router.py +209 -0
  26. fastapi_forge/templates/with_rbac/app/api/v1/auth/schema.py +98 -0
  27. fastapi_forge/templates/with_rbac/app/api/v1/auth/service.py +383 -0
  28. fastapi_forge/templates/with_rbac/app/api/v1/health/__init__.py +3 -0
  29. fastapi_forge/templates/with_rbac/app/api/v1/health/router.py +23 -0
  30. fastapi_forge/templates/with_rbac/app/api/v1/health/schema.py +5 -0
  31. fastapi_forge/templates/with_rbac/app/api/v1/health/service.py +25 -0
  32. fastapi_forge/templates/with_rbac/app/api/v1/permissions/__init__.py +0 -0
  33. fastapi_forge/templates/with_rbac/app/api/v1/permissions/repository.py +80 -0
  34. fastapi_forge/templates/with_rbac/app/api/v1/permissions/router.py +151 -0
  35. fastapi_forge/templates/with_rbac/app/api/v1/permissions/schema.py +40 -0
  36. fastapi_forge/templates/with_rbac/app/api/v1/permissions/service.py +156 -0
  37. fastapi_forge/templates/with_rbac/app/api/v1/roles/__init__.py +1 -0
  38. fastapi_forge/templates/with_rbac/app/api/v1/roles/repository.py +161 -0
  39. fastapi_forge/templates/with_rbac/app/api/v1/roles/router.py +169 -0
  40. fastapi_forge/templates/with_rbac/app/api/v1/roles/schema.py +51 -0
  41. fastapi_forge/templates/with_rbac/app/api/v1/roles/service.py +319 -0
  42. fastapi_forge/templates/with_rbac/app/api/v1/schema.py +7 -0
  43. fastapi_forge/templates/with_rbac/app/api/v1/users/__init__.py +1 -0
  44. fastapi_forge/templates/with_rbac/app/api/v1/users/repository.py +181 -0
  45. fastapi_forge/templates/with_rbac/app/api/v1/users/router.py +146 -0
  46. fastapi_forge/templates/with_rbac/app/api/v1/users/schema.py +112 -0
  47. fastapi_forge/templates/with_rbac/app/api/v1/users/service.py +291 -0
  48. fastapi_forge/templates/with_rbac/app/core/__init__.py +1 -0
  49. fastapi_forge/templates/with_rbac/app/core/config.py +131 -0
  50. fastapi_forge/templates/with_rbac/app/core/dependencies.py +131 -0
  51. fastapi_forge/templates/with_rbac/app/core/exceptions.py +162 -0
  52. fastapi_forge/templates/with_rbac/app/core/logging.py +231 -0
  53. fastapi_forge/templates/with_rbac/app/core/middleware.py +188 -0
  54. fastapi_forge/templates/with_rbac/app/core/responses.py +108 -0
  55. fastapi_forge/templates/with_rbac/app/core/security.py +115 -0
  56. fastapi_forge/templates/with_rbac/app/db/__init__.py +1 -0
  57. fastapi_forge/templates/with_rbac/app/db/base.py +5 -0
  58. fastapi_forge/templates/with_rbac/app/db/models/__init__.py +16 -0
  59. fastapi_forge/templates/with_rbac/app/db/models/audit_log.py +58 -0
  60. fastapi_forge/templates/with_rbac/app/db/models/auth_token.py +72 -0
  61. fastapi_forge/templates/with_rbac/app/db/models/notification.py +49 -0
  62. fastapi_forge/templates/with_rbac/app/db/models/permission.py +174 -0
  63. fastapi_forge/templates/with_rbac/app/db/models/revoked_token.py +21 -0
  64. fastapi_forge/templates/with_rbac/app/db/models/user.py +53 -0
  65. fastapi_forge/templates/with_rbac/app/db/schemas/__init__.py +8 -0
  66. fastapi_forge/templates/with_rbac/app/db/schemas/common.py +70 -0
  67. fastapi_forge/templates/with_rbac/app/db/schemas/names.py +9 -0
  68. fastapi_forge/templates/with_rbac/app/db/session.py +86 -0
  69. fastapi_forge/templates/with_rbac/app/helper/__init__.py +1 -0
  70. fastapi_forge/templates/with_rbac/app/helper/pagination_helper.py +44 -0
  71. fastapi_forge/templates/with_rbac/app/helper/search.py +51 -0
  72. fastapi_forge/templates/with_rbac/app/helper/sorting.py +77 -0
  73. fastapi_forge/templates/with_rbac/app/main.py +66 -0
  74. fastapi_forge/templates/with_rbac/app/repositories/__init__.py +1 -0
  75. fastapi_forge/templates/with_rbac/app/repositories/base.py +347 -0
  76. fastapi_forge/templates/with_rbac/app/services/__init__.py +1 -0
  77. fastapi_forge/templates/with_rbac/app/services/audit.py +58 -0
  78. fastapi_forge/templates/with_rbac/app/services/email.py +118 -0
  79. fastapi_forge/templates/with_rbac/app/services/notification.py +82 -0
  80. fastapi_forge/templates/with_rbac/app/templates/email/notification.html +7 -0
  81. fastapi_forge/templates/with_rbac/app/templates/email/password_reset.html +7 -0
  82. fastapi_forge/templates/with_rbac/app/templates/email/verify_email.html +7 -0
  83. fastapi_forge/templates/with_rbac/app/templates/email/welcome.html +6 -0
  84. fastapi_forge/templates/with_rbac/app/utils/casing.py +31 -0
  85. fastapi_forge/templates/with_rbac/compose.yaml +33 -0
  86. fastapi_forge/templates/with_rbac/pyproject.toml +14 -0
  87. fastapi_forge/templates/with_rbac/requirements-dev.txt +5 -0
  88. fastapi_forge/templates/with_rbac/requirements.txt +16 -0
  89. fastapi_forge/templates/with_rbac/sample.env +42 -0
  90. fastapi_forge/templates/with_rbac/scripts/seed_first_user.py +166 -0
  91. fastapi_forge/templates/with_rbac/tests/test_audit.py +42 -0
  92. fastapi_forge/templates/with_rbac/tests/test_config.py +27 -0
  93. fastapi_forge/templates/with_rbac/tests/test_generator.py +20 -0
  94. fastapi_forge/templates/with_rbac/tests/test_permissions.py +36 -0
  95. fastapi_forge/templates/with_rbac/tests/test_security.py +68 -0
  96. fastapi_forge/templates/without_rbac/Dockerfile +31 -0
  97. fastapi_forge/templates/without_rbac/README.md +106 -0
  98. fastapi_forge/templates/without_rbac/_dockerignore +16 -0
  99. fastapi_forge/templates/without_rbac/_github/workflows/ci.yml +23 -0
  100. fastapi_forge/templates/without_rbac/_gitignore +19 -0
  101. fastapi_forge/templates/without_rbac/alembic/README +1 -0
  102. fastapi_forge/templates/without_rbac/alembic/__init__.py +1 -0
  103. fastapi_forge/templates/without_rbac/alembic/env.py +51 -0
  104. fastapi_forge/templates/without_rbac/alembic/script.py.mako +28 -0
  105. fastapi_forge/templates/without_rbac/alembic/versions/2255ba4f9604_fresh_baseline.py +125 -0
  106. fastapi_forge/templates/without_rbac/alembic.ini +35 -0
  107. fastapi_forge/templates/without_rbac/app/__init__.py +1 -0
  108. fastapi_forge/templates/without_rbac/app/api/__init__.py +1 -0
  109. fastapi_forge/templates/without_rbac/app/api/v1/__init__.py +1 -0
  110. fastapi_forge/templates/without_rbac/app/api/v1/api.py +29 -0
  111. fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/__init__.py +1 -0
  112. fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/repository.py +38 -0
  113. fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/router.py +45 -0
  114. fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/schema.py +21 -0
  115. fastapi_forge/templates/without_rbac/app/api/v1/auth/__init__.py +0 -0
  116. fastapi_forge/templates/without_rbac/app/api/v1/auth/repository.py +127 -0
  117. fastapi_forge/templates/without_rbac/app/api/v1/auth/router.py +207 -0
  118. fastapi_forge/templates/without_rbac/app/api/v1/auth/schema.py +96 -0
  119. fastapi_forge/templates/without_rbac/app/api/v1/auth/service.py +373 -0
  120. fastapi_forge/templates/without_rbac/app/api/v1/health/__init__.py +3 -0
  121. fastapi_forge/templates/without_rbac/app/api/v1/health/router.py +23 -0
  122. fastapi_forge/templates/without_rbac/app/api/v1/health/schema.py +5 -0
  123. fastapi_forge/templates/without_rbac/app/api/v1/health/service.py +25 -0
  124. fastapi_forge/templates/without_rbac/app/api/v1/schema.py +7 -0
  125. fastapi_forge/templates/without_rbac/app/api/v1/users/__init__.py +1 -0
  126. fastapi_forge/templates/without_rbac/app/api/v1/users/repository.py +69 -0
  127. fastapi_forge/templates/without_rbac/app/api/v1/users/router.py +94 -0
  128. fastapi_forge/templates/without_rbac/app/api/v1/users/schema.py +50 -0
  129. fastapi_forge/templates/without_rbac/app/api/v1/users/service.py +92 -0
  130. fastapi_forge/templates/without_rbac/app/core/__init__.py +1 -0
  131. fastapi_forge/templates/without_rbac/app/core/config.py +131 -0
  132. fastapi_forge/templates/without_rbac/app/core/dependencies.py +71 -0
  133. fastapi_forge/templates/without_rbac/app/core/exceptions.py +162 -0
  134. fastapi_forge/templates/without_rbac/app/core/logging.py +231 -0
  135. fastapi_forge/templates/without_rbac/app/core/middleware.py +188 -0
  136. fastapi_forge/templates/without_rbac/app/core/responses.py +108 -0
  137. fastapi_forge/templates/without_rbac/app/core/security.py +115 -0
  138. fastapi_forge/templates/without_rbac/app/db/__init__.py +1 -0
  139. fastapi_forge/templates/without_rbac/app/db/base.py +5 -0
  140. fastapi_forge/templates/without_rbac/app/db/models/__init__.py +10 -0
  141. fastapi_forge/templates/without_rbac/app/db/models/audit_log.py +58 -0
  142. fastapi_forge/templates/without_rbac/app/db/models/auth_token.py +72 -0
  143. fastapi_forge/templates/without_rbac/app/db/models/notification.py +49 -0
  144. fastapi_forge/templates/without_rbac/app/db/models/revoked_token.py +21 -0
  145. fastapi_forge/templates/without_rbac/app/db/models/user.py +33 -0
  146. fastapi_forge/templates/without_rbac/app/db/schemas/__init__.py +8 -0
  147. fastapi_forge/templates/without_rbac/app/db/schemas/common.py +70 -0
  148. fastapi_forge/templates/without_rbac/app/db/schemas/names.py +5 -0
  149. fastapi_forge/templates/without_rbac/app/db/session.py +86 -0
  150. fastapi_forge/templates/without_rbac/app/helper/__init__.py +1 -0
  151. fastapi_forge/templates/without_rbac/app/helper/pagination_helper.py +44 -0
  152. fastapi_forge/templates/without_rbac/app/helper/search.py +51 -0
  153. fastapi_forge/templates/without_rbac/app/helper/sorting.py +77 -0
  154. fastapi_forge/templates/without_rbac/app/main.py +66 -0
  155. fastapi_forge/templates/without_rbac/app/repositories/__init__.py +1 -0
  156. fastapi_forge/templates/without_rbac/app/repositories/base.py +347 -0
  157. fastapi_forge/templates/without_rbac/app/services/__init__.py +1 -0
  158. fastapi_forge/templates/without_rbac/app/services/audit.py +58 -0
  159. fastapi_forge/templates/without_rbac/app/services/email.py +118 -0
  160. fastapi_forge/templates/without_rbac/app/services/notification.py +82 -0
  161. fastapi_forge/templates/without_rbac/app/templates/email/notification.html +7 -0
  162. fastapi_forge/templates/without_rbac/app/templates/email/password_reset.html +7 -0
  163. fastapi_forge/templates/without_rbac/app/templates/email/verify_email.html +7 -0
  164. fastapi_forge/templates/without_rbac/app/templates/email/welcome.html +6 -0
  165. fastapi_forge/templates/without_rbac/app/utils/casing.py +31 -0
  166. fastapi_forge/templates/without_rbac/compose.yaml +33 -0
  167. fastapi_forge/templates/without_rbac/pyproject.toml +14 -0
  168. fastapi_forge/templates/without_rbac/requirements-dev.txt +5 -0
  169. fastapi_forge/templates/without_rbac/requirements.txt +16 -0
  170. fastapi_forge/templates/without_rbac/sample.env +42 -0
  171. fastapi_forge/templates/without_rbac/scripts/seed_first_user.py +51 -0
  172. fastapi_forge/templates/without_rbac/tests/test_audit.py +42 -0
  173. fastapi_forge/templates/without_rbac/tests/test_config.py +27 -0
  174. fastapi_forge/templates/without_rbac/tests/test_generator.py +20 -0
  175. fastapi_forge/templates/without_rbac/tests/test_security.py +68 -0
  176. fastapi_forge_cli-0.1.0.dist-info/METADATA +225 -0
  177. fastapi_forge_cli-0.1.0.dist-info/RECORD +181 -0
  178. fastapi_forge_cli-0.1.0.dist-info/WHEEL +5 -0
  179. fastapi_forge_cli-0.1.0.dist-info/entry_points.txt +2 -0
  180. fastapi_forge_cli-0.1.0.dist-info/licenses/LICENSE +18 -0
  181. fastapi_forge_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,58 @@
1
+ from typing import Optional
2
+ from uuid import UUID
3
+
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+
6
+ from app.core.logging import get_logger
7
+ from app.db.models.audit_log import AuditLog
8
+
9
+ logger = get_logger(__name__)
10
+
11
+
12
+ class AuditService:
13
+ def __init__(self, session: AsyncSession):
14
+ self.session = session
15
+
16
+ async def log(
17
+ self,
18
+ action: str,
19
+ resource: str,
20
+ resource_id: Optional[str] = None,
21
+ user_id: Optional[str | UUID] = None,
22
+ user_email: Optional[str] = None,
23
+ old_values: Optional[dict] = None,
24
+ new_values: Optional[dict] = None,
25
+ metadata: Optional[dict] = None,
26
+ ip_address: Optional[str] = None,
27
+ user_agent: Optional[str] = None,
28
+ request_id: Optional[str] = None,
29
+ ) -> AuditLog:
30
+ """
31
+ Append a single audit log entry. All arguments are optional except
32
+ action and resource so callers can provide as much or as little
33
+ context as they have.
34
+ """
35
+ entry = AuditLog(
36
+ action=action,
37
+ resource=resource,
38
+ resource_id=resource_id,
39
+ user_id=UUID(str(user_id)) if user_id else None,
40
+ user_email=user_email,
41
+ old_values=old_values,
42
+ new_values=new_values,
43
+ log_metadata=metadata,
44
+ ip_address=ip_address,
45
+ user_agent=user_agent,
46
+ request_id=request_id,
47
+ )
48
+ self.session.add(entry)
49
+ await self.session.flush()
50
+
51
+ logger.debug(
52
+ "Audit log written",
53
+ action=action,
54
+ resource=resource,
55
+ resource_id=resource_id,
56
+ user_id=str(user_id) if user_id else None,
57
+ )
58
+ return entry
@@ -0,0 +1,118 @@
1
+ from pathlib import Path
2
+ from typing import Any, Dict, List
3
+ from uuid import UUID
4
+
5
+ from fastapi_mail import ConnectionConfig, FastMail, MessageSchema, MessageType
6
+ from pydantic import EmailStr
7
+ from sqlalchemy.ext.asyncio import AsyncSession
8
+
9
+ from app.core.config import settings
10
+ from app.core.logging import get_logger
11
+
12
+ logger = get_logger(__name__)
13
+
14
+ # ── FastMail configuration ───────────────────────────────────────
15
+ _mail_config = ConnectionConfig(
16
+ MAIL_USERNAME=settings.mail_username,
17
+ MAIL_PASSWORD=settings.mail_password,
18
+ MAIL_FROM=settings.mail_from,
19
+ MAIL_PORT=settings.mail_port,
20
+ MAIL_SERVER=settings.mail_server,
21
+ MAIL_STARTTLS=settings.mail_starttls,
22
+ MAIL_SSL_TLS=settings.mail_ssl_tls,
23
+ MAIL_FROM_NAME=settings.mail_from_name,
24
+ TEMPLATE_FOLDER=Path(__file__).parent.parent / "templates" / "email",
25
+ USE_CREDENTIALS=True,
26
+ VALIDATE_CERTS=True,
27
+ )
28
+
29
+ _fast_mail = FastMail(_mail_config)
30
+
31
+
32
+ class EmailService:
33
+ async def _send(
34
+ self,
35
+ recipients: List[EmailStr],
36
+ subject: str,
37
+ template_name: str,
38
+ template_body: Dict[str, Any],
39
+ ) -> None:
40
+ message = MessageSchema(
41
+ subject=subject,
42
+ recipients=recipients,
43
+ template_body=template_body,
44
+ subtype=MessageType.html,
45
+ )
46
+ try:
47
+ await _fast_mail.send_message(message, template_name=template_name)
48
+ logger.info("Email sent", subject=subject, recipients=recipients)
49
+ except Exception as exc:
50
+ logger.error("Email send failed", subject=subject, error=str(exc))
51
+ raise
52
+
53
+ # ── Public send helpers ──────────────────────────────────────
54
+ async def send_welcome_email(self, to_email: str, full_name: str) -> None:
55
+ await self._send(
56
+ recipients=[to_email],
57
+ subject=f"Welcome to {settings.APP_NAME}!",
58
+ template_name="welcome.html",
59
+ template_body={"full_name": full_name, "app_name": settings.APP_NAME},
60
+ )
61
+
62
+ async def send_password_reset_email(self, to_email: str, reset_link: str) -> None:
63
+ await self._send(
64
+ recipients=[to_email],
65
+ subject="Reset your password",
66
+ template_name="password_reset.html",
67
+ template_body={"reset_link": reset_link, "app_name": settings.APP_NAME},
68
+ )
69
+
70
+ async def send_email_verification(self, to_email: str, verify_link: str) -> None:
71
+ await self._send(
72
+ recipients=[to_email],
73
+ subject="Verify your email address",
74
+ template_name="verify_email.html",
75
+ template_body={"verify_link": verify_link, "app_name": settings.APP_NAME},
76
+ )
77
+
78
+ async def send_notification_email(
79
+ self,
80
+ to_user_id: UUID,
81
+ subject: str,
82
+ body: str,
83
+ session: AsyncSession,
84
+ ) -> None:
85
+ from app.db.models.user import User
86
+
87
+ user = await session.get(User, to_user_id)
88
+ if not user:
89
+ logger.warning(
90
+ "Cannot send notification email — user not found",
91
+ user_id=str(to_user_id),
92
+ )
93
+ return
94
+ await self._send(
95
+ recipients=[user.email],
96
+ subject=subject,
97
+ template_name="notification.html",
98
+ template_body={
99
+ "full_name": user.full_name,
100
+ "body": body,
101
+ "app_name": settings.APP_NAME,
102
+ },
103
+ )
104
+
105
+ async def send_generic(
106
+ self,
107
+ to_email: str,
108
+ subject: str,
109
+ body_html: str,
110
+ ) -> None:
111
+ """Send a one-off HTML email without a template file."""
112
+ message = MessageSchema(
113
+ subject=subject,
114
+ recipients=[to_email],
115
+ body=body_html,
116
+ subtype=MessageType.html,
117
+ )
118
+ await _fast_mail.send_message(message)
@@ -0,0 +1,82 @@
1
+ from datetime import datetime, timezone
2
+ from typing import Any, Dict, List, Optional
3
+ from uuid import UUID
4
+
5
+ from sqlalchemy import update
6
+ from sqlalchemy.ext.asyncio import AsyncSession
7
+
8
+ from app.core.logging import get_logger
9
+ from app.db.models.notification import Notification
10
+
11
+ logger = get_logger(__name__)
12
+
13
+
14
+ class NotificationService:
15
+ def __init__(self, session: AsyncSession):
16
+ self.session = session
17
+
18
+ async def send(
19
+ self,
20
+ user_id: UUID,
21
+ title: str,
22
+ body: str,
23
+ notification_type: str = "info",
24
+ channel: str = "in_app",
25
+ payload: Optional[Dict[str, Any]] = None,
26
+ ) -> Notification:
27
+ notification = Notification(
28
+ user_id=user_id,
29
+ title=title,
30
+ body=body,
31
+ notification_type=notification_type,
32
+ channel=channel,
33
+ payload=payload,
34
+ )
35
+ self.session.add(notification)
36
+ await self.session.flush()
37
+
38
+ logger.debug(
39
+ "Notification created",
40
+ user_id=str(user_id),
41
+ type=notification_type,
42
+ channel=channel,
43
+ )
44
+
45
+ # ── Channel-specific dispatch ──────────────────────────
46
+ if channel == "email":
47
+ # Lazy import to avoid circular dependency
48
+ from app.services.email import EmailService
49
+
50
+ email_svc = EmailService()
51
+ # Fire-and-forget; failures are logged, not raised
52
+ try:
53
+ await email_svc.send_notification_email(
54
+ to_user_id=user_id,
55
+ subject=title,
56
+ body=body,
57
+ session=self.session,
58
+ )
59
+ except Exception as exc:
60
+ logger.warning("Email dispatch failed", error=str(exc))
61
+
62
+ return notification
63
+
64
+ async def bulk_send(self, user_ids: List[UUID], **kwargs) -> List[Notification]:
65
+ """Send the same notification to multiple users."""
66
+ results = []
67
+ for uid in user_ids:
68
+ n = await self.send(user_id=uid, **kwargs)
69
+ results.append(n)
70
+ return results
71
+
72
+ async def mark_all_read(self, user_id: UUID) -> int:
73
+ result = await self.session.execute(
74
+ update(Notification)
75
+ .where(
76
+ Notification.user_id == user_id,
77
+ Notification.is_read == False,
78
+ Notification.is_deleted == False,
79
+ )
80
+ .values(is_read=True, read_at=datetime.now(timezone.utc))
81
+ )
82
+ return result.rowcount
@@ -0,0 +1,7 @@
1
+ <!doctype html>
2
+ <html>
3
+ <body>
4
+ <p>Hello {{ full_name }},</p>
5
+ <div>{{ body }}</div>
6
+ </body>
7
+ </html>
@@ -0,0 +1,7 @@
1
+ <!doctype html>
2
+ <html>
3
+ <body>
4
+ <p>Use the link below to reset your {{ app_name }} password:</p>
5
+ <p><a href="{{ reset_link }}">Reset password</a></p>
6
+ </body>
7
+ </html>
@@ -0,0 +1,7 @@
1
+ <!doctype html>
2
+ <html>
3
+ <body>
4
+ <p>Use the link below to verify your {{ app_name }} email address:</p>
5
+ <p><a href="{{ verify_link }}">Verify email</a></p>
6
+ </body>
7
+ </html>
@@ -0,0 +1,6 @@
1
+ <!doctype html>
2
+ <html>
3
+ <body>
4
+ <p>Welcome {{ full_name }} to {{ app_name }}.</p>
5
+ </body>
6
+ </html>
@@ -0,0 +1,31 @@
1
+ from collections.abc import Mapping
2
+ from typing import Any
3
+
4
+ from fastapi.encoders import jsonable_encoder
5
+ from pydantic.alias_generators import to_camel
6
+ from pydantic import BaseModel, ConfigDict
7
+
8
+
9
+ class CamelModel(BaseModel):
10
+ model_config = ConfigDict(
11
+ alias_generator=to_camel,
12
+ populate_by_name=True,
13
+ from_attributes=True,
14
+ )
15
+
16
+
17
+ def keys_to_camel(value: Any) -> Any:
18
+ if value is None or isinstance(value, (str, int, float, bool)):
19
+ return value
20
+ if isinstance(value, BaseModel):
21
+ return value.model_dump(by_alias=True)
22
+ if isinstance(value, Mapping):
23
+ return {to_camel(str(key)): keys_to_camel(item) for key, item in value.items()}
24
+ if isinstance(value, list):
25
+ return [keys_to_camel(item) for item in value]
26
+ if isinstance(value, tuple):
27
+ return tuple(keys_to_camel(item) for item in value)
28
+ encoded = jsonable_encoder(value)
29
+ if encoded is not value:
30
+ return keys_to_camel(encoded)
31
+ return value
@@ -0,0 +1,33 @@
1
+ services:
2
+ db:
3
+ image: postgres:16-alpine
4
+ environment:
5
+ POSTGRES_DB: project_name
6
+ POSTGRES_USER: postgres
7
+ POSTGRES_PASSWORD: postgres
8
+ healthcheck:
9
+ test: ["CMD-SHELL", "pg_isready -U postgres -d project_name"]
10
+ interval: 5s
11
+ timeout: 5s
12
+ retries: 10
13
+ volumes:
14
+ - postgres-data:/var/lib/postgresql/data
15
+
16
+ api:
17
+ build: .
18
+ env_file: .env
19
+ environment:
20
+ PG_HOST: db
21
+ PG_SSLMODE: disable
22
+ command: ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload"]
23
+ depends_on:
24
+ db:
25
+ condition: service_healthy
26
+ ports:
27
+ - "8000:8000"
28
+ volumes:
29
+ - .:/app
30
+
31
+ volumes:
32
+ postgres-data:
33
+
@@ -0,0 +1,14 @@
1
+ [tool.pytest.ini_options]
2
+ testpaths = ["tests"]
3
+ asyncio_mode = "auto"
4
+ pythonpath = ["."]
5
+
6
+ [tool.ruff]
7
+ target-version = "py312"
8
+ line-length = 100
9
+
10
+ [tool.ruff.lint]
11
+ select = ["E9", "F63", "F7", "F82"]
12
+
13
+ [tool.ruff.lint.per-file-ignores]
14
+ "app/db/models/*.py" = ["F821"]
@@ -0,0 +1,5 @@
1
+ -r requirements.txt
2
+ pytest>=8.0,<9.0
3
+ pytest-asyncio>=0.23,<1.0
4
+ ruff>=0.8,<1.0
5
+
@@ -0,0 +1,16 @@
1
+ alembic>=1.13.0
2
+ fastapi>=0.111.0
3
+ asyncpg>=0.30.0
4
+ psycopg[binary]>=3.2.0
5
+ pydantic-settings>=2.0.0
6
+ pydantic[email]>=2.0.0
7
+ uvicorn[standard]>=0.29.0
8
+ SQLAlchemy>=2.0.30
9
+ python-jose[cryptography]>=3.3.0
10
+ passlib[bcrypt]>=1.7.4
11
+ bcrypt==4.0.1
12
+ python-multipart>=0.0.9
13
+ slowapi>=0.1.9
14
+ redis>=5.0.0
15
+ fastapi-mail>=1.4.1
16
+ structlog>=24.0.0
@@ -0,0 +1,42 @@
1
+ APP_NAME=__PROJECT_NAME__
2
+ APP_VERSION=1.0.0
3
+ APP_ENV=development
4
+ APP_DEBUG=false
5
+ SECRET_KEY=replace-with-a-long-random-secret
6
+
7
+ PG_HOST=localhost
8
+ PG_PORT=5432
9
+ PG_DATABASE=project_name
10
+ PG_USER=postgres
11
+ PG_PASSWORD=postgres
12
+ PG_SSLMODE=prefer
13
+
14
+ ACCESS_TOKEN_EXPIRE_MINUTES=30
15
+ REFRESH_TOKEN_EXPIRE_DAYS=7
16
+ PASSWORD_RESET_TOKEN_EXPIRE_MINUTES=15
17
+ ALGORITHM=HS256
18
+
19
+ ALLOWED_ORIGINS=http://localhost:3000
20
+ ALLOWED_HOSTS=localhost,127.0.0.1
21
+
22
+ USE_REDIS=false
23
+ REDIS_URL=
24
+ RATE_LIMIT_PER_MINUTE=60
25
+ LOG_DIR=logs
26
+ LOG_TO_FILES=false
27
+
28
+ # Database pool tuning
29
+ DATABASE_POOL_SIZE=10
30
+ DATABASE_MAX_OVERFLOW=20
31
+ DATABASE_POOL_TIMEOUT=30
32
+ DATABASE_DEBUG=false
33
+
34
+ MAIL_USERNAME=
35
+ MAIL_PASSWORD=
36
+ MAIL_FROM=noreply@example.com
37
+ MAIL_PORT=587
38
+ MAIL_SERVER=smtp.example.com
39
+ MAIL_STARTTLS=true
40
+ MAIL_SSL_TLS=false
41
+ MAIL_FROM_NAME=__PROJECT_NAME__
42
+ FRONTEND_URL=http://localhost:3000
@@ -0,0 +1,166 @@
1
+ import argparse
2
+ import sys
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
7
+ if str(PROJECT_ROOT) not in sys.path:
8
+ sys.path.insert(0, str(PROJECT_ROOT))
9
+
10
+ from sqlalchemy import select
11
+ from sqlalchemy.orm import selectinload
12
+
13
+ from app.core.security import hash_password
14
+ from app.db.models import Permission, Role, User
15
+ from app.db.session import SyncSessionLocal
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class PermissionSeed:
20
+ code: str
21
+ name: str
22
+ description: str
23
+
24
+
25
+ PERMISSIONS = [
26
+ PermissionSeed("admin:write", "Manage administration", "Full administration access."),
27
+ PermissionSeed("users:create", "Create users", "Create user accounts."),
28
+ PermissionSeed("users:read", "View users", "View user accounts."),
29
+ PermissionSeed("users:update", "Update users", "Update user accounts."),
30
+ PermissionSeed("users:soft_delete", "Soft delete users", "Soft delete user accounts."),
31
+ PermissionSeed("users:hard_delete", "Hard delete users", "Permanently delete user accounts."),
32
+ PermissionSeed("roles:create", "Create roles", "Create roles."),
33
+ PermissionSeed("roles:read", "View roles", "View roles."),
34
+ PermissionSeed("roles:update", "Update roles", "Update and assign roles."),
35
+ PermissionSeed("roles:soft_delete", "Soft delete roles", "Soft delete roles."),
36
+ PermissionSeed("roles:hard_delete", "Hard delete roles", "Permanently delete roles."),
37
+ PermissionSeed("permissions:create", "Create permissions", "Create permissions."),
38
+ PermissionSeed("permissions:read", "View permissions", "View permissions."),
39
+ PermissionSeed("permissions:update", "Update permissions", "Update and assign permissions."),
40
+ PermissionSeed(
41
+ "permissions:soft_delete",
42
+ "Soft delete permissions",
43
+ "Soft delete permissions.",
44
+ ),
45
+ PermissionSeed(
46
+ "permissions:hard_delete",
47
+ "Hard delete permissions",
48
+ "Permanently delete permissions.",
49
+ ),
50
+ PermissionSeed("audit_logs:read", "View audit logs", "View immutable audit history."),
51
+ ]
52
+
53
+ ROLE_DEFINITIONS = {
54
+ "SUPER ADMIN": {
55
+ "description": "Full access to every permission.",
56
+ "permissions": [item.code for item in PERMISSIONS],
57
+ },
58
+ "ADMIN": {
59
+ "description": "Manage users and view RBAC configuration.",
60
+ "permissions": [
61
+ "users:create",
62
+ "users:read",
63
+ "users:update",
64
+ "roles:read",
65
+ "permissions:read",
66
+ "audit_logs:read",
67
+ ],
68
+ },
69
+ "USER": {
70
+ "description": "Standard authenticated user.",
71
+ "permissions": [],
72
+ },
73
+ }
74
+
75
+
76
+ def username_from_email(email: str) -> str:
77
+ return email.split("@", 1)[0].replace(".", "_").replace("-", "_").lower()
78
+
79
+
80
+ def seed_permissions(session):
81
+ by_code = {}
82
+ for item in PERMISSIONS:
83
+ permission = session.scalar(
84
+ select(Permission).where(Permission.code == item.code)
85
+ )
86
+ if permission is None:
87
+ permission = Permission(code=item.code)
88
+ session.add(permission)
89
+ permission.name = item.name
90
+ permission.description = item.description
91
+ permission.is_active = True
92
+ permission.restore()
93
+ by_code[item.code] = permission
94
+ session.flush()
95
+ return by_code
96
+
97
+
98
+ def seed_roles(session, permissions_by_code):
99
+ by_name = {}
100
+ for name, data in ROLE_DEFINITIONS.items():
101
+ role = session.scalar(
102
+ select(Role)
103
+ .where(Role.name == name)
104
+ .options(selectinload(Role.permissions))
105
+ )
106
+ if role is None:
107
+ role = Role(name=name)
108
+ session.add(role)
109
+ role.description = data["description"]
110
+ role.is_active = True
111
+ role.restore()
112
+ role.permissions = [permissions_by_code[code] for code in data["permissions"]]
113
+ by_name[name] = role
114
+ session.flush()
115
+ by_name["SUPER ADMIN"].parent_role_id = None
116
+ by_name["ADMIN"].parent_role_id = by_name["SUPER ADMIN"].id
117
+ by_name["USER"].parent_role_id = by_name["ADMIN"].id
118
+ session.flush()
119
+ return by_name
120
+
121
+
122
+ def seed_first_user(session, email: str, password: str, full_name: str, role: Role):
123
+ user = session.scalar(
124
+ select(User)
125
+ .where(User.email == email.lower(), User.is_deleted == False)
126
+ .options(selectinload(User.roles))
127
+ )
128
+ if user is None:
129
+ user = User(
130
+ email=email.lower(),
131
+ username=username_from_email(email),
132
+ full_name=full_name,
133
+ hashed_password=hash_password(password),
134
+ )
135
+ session.add(user)
136
+ user.full_name = full_name
137
+ user.hashed_password = hash_password(password)
138
+ user.is_active = True
139
+ user.is_superuser = True
140
+ user.is_verified = True
141
+ user.restore()
142
+ user.roles = [role]
143
+ session.flush()
144
+ return user
145
+
146
+
147
+ def main():
148
+ parser = argparse.ArgumentParser(description="Seed RBAC and the first administrator.")
149
+ parser.add_argument("--email", required=True)
150
+ parser.add_argument("--password", required=True)
151
+ parser.add_argument("--full-name", required=True)
152
+ args = parser.parse_args()
153
+
154
+ with SyncSessionLocal() as session:
155
+ permissions = seed_permissions(session)
156
+ roles = seed_roles(session, permissions)
157
+ user = seed_first_user(
158
+ session, args.email, args.password, args.full_name, roles["SUPER ADMIN"]
159
+ )
160
+ session.commit()
161
+ print(f"Seeded {len(permissions)} permissions and {len(roles)} roles.")
162
+ print(f"SUPER ADMIN ready: {user.email}")
163
+
164
+
165
+ if __name__ == "__main__":
166
+ main()
@@ -0,0 +1,42 @@
1
+ from uuid import uuid4
2
+
3
+ import pytest
4
+
5
+ from app.db.models.audit_log import AuditLog
6
+ from app.services.audit import AuditService
7
+
8
+
9
+ class FakeSession:
10
+ def __init__(self):
11
+ self.added = []
12
+ self.flushed = False
13
+
14
+ def add(self, value):
15
+ self.added.append(value)
16
+
17
+ async def flush(self):
18
+ self.flushed = True
19
+
20
+
21
+ @pytest.mark.asyncio
22
+ async def test_audit_service_persists_context():
23
+ session = FakeSession()
24
+ user_id = uuid4()
25
+
26
+ entry = await AuditService(session).log(
27
+ action="users.update",
28
+ resource="users",
29
+ resource_id="target-user",
30
+ user_id=user_id,
31
+ old_values={"is_active": True},
32
+ new_values={"is_active": False},
33
+ metadata={"reason": "offboarding"},
34
+ request_id="request-123",
35
+ )
36
+
37
+ assert isinstance(entry, AuditLog)
38
+ assert entry.user_id == user_id
39
+ assert entry.log_metadata == {"reason": "offboarding"}
40
+ assert entry.request_id == "request-123"
41
+ assert session.added == [entry]
42
+ assert session.flushed
@@ -0,0 +1,27 @@
1
+ import pytest
2
+ from pydantic import ValidationError
3
+
4
+ from app.core.config import Settings
5
+
6
+
7
+ def test_database_urls_escape_credentials_and_enable_tls():
8
+ configured = Settings(pg_password="p@ss", pg_sslmode="require")
9
+
10
+ assert "%40" in configured.postgres_url
11
+ assert "ssl=require" in configured.postgres_async_url
12
+
13
+
14
+ def test_production_rejects_unsafe_defaults():
15
+ with pytest.raises(ValidationError):
16
+ Settings(APP_ENV="production", _env_file=None)
17
+
18
+
19
+ def test_production_accepts_explicit_secure_settings():
20
+ Settings(
21
+ APP_ENV="production",
22
+ SECRET_KEY="x" * 48,
23
+ ALLOWED_HOSTS=["api.example.com"],
24
+ ALLOWED_ORIGINS=["https://example.com"],
25
+ pg_sslmode="verify-full",
26
+ _env_file=None,
27
+ )