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.
- fastapi_forge/__init__.py +7 -0
- fastapi_forge/__main__.py +6 -0
- fastapi_forge/cli.py +211 -0
- fastapi_forge/templates/with_rbac/Dockerfile +31 -0
- fastapi_forge/templates/with_rbac/README.md +121 -0
- fastapi_forge/templates/with_rbac/_dockerignore +16 -0
- fastapi_forge/templates/with_rbac/_github/workflows/ci.yml +23 -0
- fastapi_forge/templates/with_rbac/_gitignore +19 -0
- fastapi_forge/templates/with_rbac/alembic/README +1 -0
- fastapi_forge/templates/with_rbac/alembic/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/alembic/env.py +51 -0
- fastapi_forge/templates/with_rbac/alembic/script.py.mako +28 -0
- fastapi_forge/templates/with_rbac/alembic/versions/2255ba4f9604_fresh_baseline.py +204 -0
- fastapi_forge/templates/with_rbac/alembic.ini +35 -0
- fastapi_forge/templates/with_rbac/app/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/api/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/api/v1/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/api/v1/api.py +34 -0
- fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/repository.py +38 -0
- fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/router.py +50 -0
- fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/schema.py +21 -0
- fastapi_forge/templates/with_rbac/app/api/v1/auth/__init__.py +0 -0
- fastapi_forge/templates/with_rbac/app/api/v1/auth/repository.py +179 -0
- fastapi_forge/templates/with_rbac/app/api/v1/auth/router.py +209 -0
- fastapi_forge/templates/with_rbac/app/api/v1/auth/schema.py +98 -0
- fastapi_forge/templates/with_rbac/app/api/v1/auth/service.py +383 -0
- fastapi_forge/templates/with_rbac/app/api/v1/health/__init__.py +3 -0
- fastapi_forge/templates/with_rbac/app/api/v1/health/router.py +23 -0
- fastapi_forge/templates/with_rbac/app/api/v1/health/schema.py +5 -0
- fastapi_forge/templates/with_rbac/app/api/v1/health/service.py +25 -0
- fastapi_forge/templates/with_rbac/app/api/v1/permissions/__init__.py +0 -0
- fastapi_forge/templates/with_rbac/app/api/v1/permissions/repository.py +80 -0
- fastapi_forge/templates/with_rbac/app/api/v1/permissions/router.py +151 -0
- fastapi_forge/templates/with_rbac/app/api/v1/permissions/schema.py +40 -0
- fastapi_forge/templates/with_rbac/app/api/v1/permissions/service.py +156 -0
- fastapi_forge/templates/with_rbac/app/api/v1/roles/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/api/v1/roles/repository.py +161 -0
- fastapi_forge/templates/with_rbac/app/api/v1/roles/router.py +169 -0
- fastapi_forge/templates/with_rbac/app/api/v1/roles/schema.py +51 -0
- fastapi_forge/templates/with_rbac/app/api/v1/roles/service.py +319 -0
- fastapi_forge/templates/with_rbac/app/api/v1/schema.py +7 -0
- fastapi_forge/templates/with_rbac/app/api/v1/users/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/api/v1/users/repository.py +181 -0
- fastapi_forge/templates/with_rbac/app/api/v1/users/router.py +146 -0
- fastapi_forge/templates/with_rbac/app/api/v1/users/schema.py +112 -0
- fastapi_forge/templates/with_rbac/app/api/v1/users/service.py +291 -0
- fastapi_forge/templates/with_rbac/app/core/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/core/config.py +131 -0
- fastapi_forge/templates/with_rbac/app/core/dependencies.py +131 -0
- fastapi_forge/templates/with_rbac/app/core/exceptions.py +162 -0
- fastapi_forge/templates/with_rbac/app/core/logging.py +231 -0
- fastapi_forge/templates/with_rbac/app/core/middleware.py +188 -0
- fastapi_forge/templates/with_rbac/app/core/responses.py +108 -0
- fastapi_forge/templates/with_rbac/app/core/security.py +115 -0
- fastapi_forge/templates/with_rbac/app/db/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/db/base.py +5 -0
- fastapi_forge/templates/with_rbac/app/db/models/__init__.py +16 -0
- fastapi_forge/templates/with_rbac/app/db/models/audit_log.py +58 -0
- fastapi_forge/templates/with_rbac/app/db/models/auth_token.py +72 -0
- fastapi_forge/templates/with_rbac/app/db/models/notification.py +49 -0
- fastapi_forge/templates/with_rbac/app/db/models/permission.py +174 -0
- fastapi_forge/templates/with_rbac/app/db/models/revoked_token.py +21 -0
- fastapi_forge/templates/with_rbac/app/db/models/user.py +53 -0
- fastapi_forge/templates/with_rbac/app/db/schemas/__init__.py +8 -0
- fastapi_forge/templates/with_rbac/app/db/schemas/common.py +70 -0
- fastapi_forge/templates/with_rbac/app/db/schemas/names.py +9 -0
- fastapi_forge/templates/with_rbac/app/db/session.py +86 -0
- fastapi_forge/templates/with_rbac/app/helper/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/helper/pagination_helper.py +44 -0
- fastapi_forge/templates/with_rbac/app/helper/search.py +51 -0
- fastapi_forge/templates/with_rbac/app/helper/sorting.py +77 -0
- fastapi_forge/templates/with_rbac/app/main.py +66 -0
- fastapi_forge/templates/with_rbac/app/repositories/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/repositories/base.py +347 -0
- fastapi_forge/templates/with_rbac/app/services/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/services/audit.py +58 -0
- fastapi_forge/templates/with_rbac/app/services/email.py +118 -0
- fastapi_forge/templates/with_rbac/app/services/notification.py +82 -0
- fastapi_forge/templates/with_rbac/app/templates/email/notification.html +7 -0
- fastapi_forge/templates/with_rbac/app/templates/email/password_reset.html +7 -0
- fastapi_forge/templates/with_rbac/app/templates/email/verify_email.html +7 -0
- fastapi_forge/templates/with_rbac/app/templates/email/welcome.html +6 -0
- fastapi_forge/templates/with_rbac/app/utils/casing.py +31 -0
- fastapi_forge/templates/with_rbac/compose.yaml +33 -0
- fastapi_forge/templates/with_rbac/pyproject.toml +14 -0
- fastapi_forge/templates/with_rbac/requirements-dev.txt +5 -0
- fastapi_forge/templates/with_rbac/requirements.txt +16 -0
- fastapi_forge/templates/with_rbac/sample.env +42 -0
- fastapi_forge/templates/with_rbac/scripts/seed_first_user.py +166 -0
- fastapi_forge/templates/with_rbac/tests/test_audit.py +42 -0
- fastapi_forge/templates/with_rbac/tests/test_config.py +27 -0
- fastapi_forge/templates/with_rbac/tests/test_generator.py +20 -0
- fastapi_forge/templates/with_rbac/tests/test_permissions.py +36 -0
- fastapi_forge/templates/with_rbac/tests/test_security.py +68 -0
- fastapi_forge/templates/without_rbac/Dockerfile +31 -0
- fastapi_forge/templates/without_rbac/README.md +106 -0
- fastapi_forge/templates/without_rbac/_dockerignore +16 -0
- fastapi_forge/templates/without_rbac/_github/workflows/ci.yml +23 -0
- fastapi_forge/templates/without_rbac/_gitignore +19 -0
- fastapi_forge/templates/without_rbac/alembic/README +1 -0
- fastapi_forge/templates/without_rbac/alembic/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/alembic/env.py +51 -0
- fastapi_forge/templates/without_rbac/alembic/script.py.mako +28 -0
- fastapi_forge/templates/without_rbac/alembic/versions/2255ba4f9604_fresh_baseline.py +125 -0
- fastapi_forge/templates/without_rbac/alembic.ini +35 -0
- fastapi_forge/templates/without_rbac/app/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/api/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/api/v1/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/api/v1/api.py +29 -0
- fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/repository.py +38 -0
- fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/router.py +45 -0
- fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/schema.py +21 -0
- fastapi_forge/templates/without_rbac/app/api/v1/auth/__init__.py +0 -0
- fastapi_forge/templates/without_rbac/app/api/v1/auth/repository.py +127 -0
- fastapi_forge/templates/without_rbac/app/api/v1/auth/router.py +207 -0
- fastapi_forge/templates/without_rbac/app/api/v1/auth/schema.py +96 -0
- fastapi_forge/templates/without_rbac/app/api/v1/auth/service.py +373 -0
- fastapi_forge/templates/without_rbac/app/api/v1/health/__init__.py +3 -0
- fastapi_forge/templates/without_rbac/app/api/v1/health/router.py +23 -0
- fastapi_forge/templates/without_rbac/app/api/v1/health/schema.py +5 -0
- fastapi_forge/templates/without_rbac/app/api/v1/health/service.py +25 -0
- fastapi_forge/templates/without_rbac/app/api/v1/schema.py +7 -0
- fastapi_forge/templates/without_rbac/app/api/v1/users/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/api/v1/users/repository.py +69 -0
- fastapi_forge/templates/without_rbac/app/api/v1/users/router.py +94 -0
- fastapi_forge/templates/without_rbac/app/api/v1/users/schema.py +50 -0
- fastapi_forge/templates/without_rbac/app/api/v1/users/service.py +92 -0
- fastapi_forge/templates/without_rbac/app/core/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/core/config.py +131 -0
- fastapi_forge/templates/without_rbac/app/core/dependencies.py +71 -0
- fastapi_forge/templates/without_rbac/app/core/exceptions.py +162 -0
- fastapi_forge/templates/without_rbac/app/core/logging.py +231 -0
- fastapi_forge/templates/without_rbac/app/core/middleware.py +188 -0
- fastapi_forge/templates/without_rbac/app/core/responses.py +108 -0
- fastapi_forge/templates/without_rbac/app/core/security.py +115 -0
- fastapi_forge/templates/without_rbac/app/db/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/db/base.py +5 -0
- fastapi_forge/templates/without_rbac/app/db/models/__init__.py +10 -0
- fastapi_forge/templates/without_rbac/app/db/models/audit_log.py +58 -0
- fastapi_forge/templates/without_rbac/app/db/models/auth_token.py +72 -0
- fastapi_forge/templates/without_rbac/app/db/models/notification.py +49 -0
- fastapi_forge/templates/without_rbac/app/db/models/revoked_token.py +21 -0
- fastapi_forge/templates/without_rbac/app/db/models/user.py +33 -0
- fastapi_forge/templates/without_rbac/app/db/schemas/__init__.py +8 -0
- fastapi_forge/templates/without_rbac/app/db/schemas/common.py +70 -0
- fastapi_forge/templates/without_rbac/app/db/schemas/names.py +5 -0
- fastapi_forge/templates/without_rbac/app/db/session.py +86 -0
- fastapi_forge/templates/without_rbac/app/helper/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/helper/pagination_helper.py +44 -0
- fastapi_forge/templates/without_rbac/app/helper/search.py +51 -0
- fastapi_forge/templates/without_rbac/app/helper/sorting.py +77 -0
- fastapi_forge/templates/without_rbac/app/main.py +66 -0
- fastapi_forge/templates/without_rbac/app/repositories/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/repositories/base.py +347 -0
- fastapi_forge/templates/without_rbac/app/services/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/services/audit.py +58 -0
- fastapi_forge/templates/without_rbac/app/services/email.py +118 -0
- fastapi_forge/templates/without_rbac/app/services/notification.py +82 -0
- fastapi_forge/templates/without_rbac/app/templates/email/notification.html +7 -0
- fastapi_forge/templates/without_rbac/app/templates/email/password_reset.html +7 -0
- fastapi_forge/templates/without_rbac/app/templates/email/verify_email.html +7 -0
- fastapi_forge/templates/without_rbac/app/templates/email/welcome.html +6 -0
- fastapi_forge/templates/without_rbac/app/utils/casing.py +31 -0
- fastapi_forge/templates/without_rbac/compose.yaml +33 -0
- fastapi_forge/templates/without_rbac/pyproject.toml +14 -0
- fastapi_forge/templates/without_rbac/requirements-dev.txt +5 -0
- fastapi_forge/templates/without_rbac/requirements.txt +16 -0
- fastapi_forge/templates/without_rbac/sample.env +42 -0
- fastapi_forge/templates/without_rbac/scripts/seed_first_user.py +51 -0
- fastapi_forge/templates/without_rbac/tests/test_audit.py +42 -0
- fastapi_forge/templates/without_rbac/tests/test_config.py +27 -0
- fastapi_forge/templates/without_rbac/tests/test_generator.py +20 -0
- fastapi_forge/templates/without_rbac/tests/test_security.py +68 -0
- fastapi_forge_cli-0.1.0.dist-info/METADATA +225 -0
- fastapi_forge_cli-0.1.0.dist-info/RECORD +181 -0
- fastapi_forge_cli-0.1.0.dist-info/WHEEL +5 -0
- fastapi_forge_cli-0.1.0.dist-info/entry_points.txt +2 -0
- fastapi_forge_cli-0.1.0.dist-info/licenses/LICENSE +18 -0
- 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,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,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
|
+
)
|