hinbert-fastapi 0.1.0__tar.gz

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 (101) hide show
  1. hinbert_fastapi-0.1.0/LICENSE +21 -0
  2. hinbert_fastapi-0.1.0/PKG-INFO +0 -0
  3. hinbert_fastapi-0.1.0/README.md +0 -0
  4. hinbert_fastapi-0.1.0/app/__init__.py +8 -0
  5. hinbert_fastapi-0.1.0/app/api/__init__.py +1 -0
  6. hinbert_fastapi-0.1.0/app/api/deps/__init__.py +1 -0
  7. hinbert_fastapi-0.1.0/app/api/deps/auth.py +40 -0
  8. hinbert_fastapi-0.1.0/app/api/deps/pagination.py +8 -0
  9. hinbert_fastapi-0.1.0/app/api/v1/__init__.py +1 -0
  10. hinbert_fastapi-0.1.0/app/api/v1/endpoints/__init__.py +1 -0
  11. hinbert_fastapi-0.1.0/app/api/v1/endpoints/auth.py +255 -0
  12. hinbert_fastapi-0.1.0/app/api/v1/endpoints/dashboard.py +23 -0
  13. hinbert_fastapi-0.1.0/app/api/v1/endpoints/products.py +102 -0
  14. hinbert_fastapi-0.1.0/app/api/v1/endpoints/users.py +89 -0
  15. hinbert_fastapi-0.1.0/app/api/v1/routers/__init__.py +1 -0
  16. hinbert_fastapi-0.1.0/app/api/v1/routers/api_router.py +11 -0
  17. hinbert_fastapi-0.1.0/app/core/__init__.py +1 -0
  18. hinbert_fastapi-0.1.0/app/core/config/__init__.py +1 -0
  19. hinbert_fastapi-0.1.0/app/core/config/database.py +13 -0
  20. hinbert_fastapi-0.1.0/app/core/config/settings.py +83 -0
  21. hinbert_fastapi-0.1.0/app/core/exceptions/__init__.py +1 -0
  22. hinbert_fastapi-0.1.0/app/core/exceptions/base_exception.py +9 -0
  23. hinbert_fastapi-0.1.0/app/core/exceptions/custom_exceptions.py +17 -0
  24. hinbert_fastapi-0.1.0/app/core/middleware/__init__.py +1 -0
  25. hinbert_fastapi-0.1.0/app/core/middleware/cors.py +5 -0
  26. hinbert_fastapi-0.1.0/app/core/middleware/error_handler.py +23 -0
  27. hinbert_fastapi-0.1.0/app/core/middleware/logging.py +29 -0
  28. hinbert_fastapi-0.1.0/app/core/middleware/rate_limit.py +27 -0
  29. hinbert_fastapi-0.1.0/app/core/security/__init__.py +1 -0
  30. hinbert_fastapi-0.1.0/app/core/security/auth.py +8 -0
  31. hinbert_fastapi-0.1.0/app/core/security/jwt.py +33 -0
  32. hinbert_fastapi-0.1.0/app/core/security/oauth.py +8 -0
  33. hinbert_fastapi-0.1.0/app/core/security/password.py +18 -0
  34. hinbert_fastapi-0.1.0/app/core/security/totp.py +13 -0
  35. hinbert_fastapi-0.1.0/app/db/__init__.py +1 -0
  36. hinbert_fastapi-0.1.0/app/db/base.py +7 -0
  37. hinbert_fastapi-0.1.0/app/db/migrations/env.py +39 -0
  38. hinbert_fastapi-0.1.0/app/db/migrations/versions/97ed1bc05f4a_complete_product_and_totp_fields.py +49 -0
  39. hinbert_fastapi-0.1.0/app/db/migrations/versions/f8b632aa11da_initial_migration.py +113 -0
  40. hinbert_fastapi-0.1.0/app/db/session.py +11 -0
  41. hinbert_fastapi-0.1.0/app/main.py +50 -0
  42. hinbert_fastapi-0.1.0/app/models/__init__.py +10 -0
  43. hinbert_fastapi-0.1.0/app/models/domain/__init__.py +1 -0
  44. hinbert_fastapi-0.1.0/app/models/domain/base.py +20 -0
  45. hinbert_fastapi-0.1.0/app/models/domain/email_verification.py +24 -0
  46. hinbert_fastapi-0.1.0/app/models/domain/password_reset.py +24 -0
  47. hinbert_fastapi-0.1.0/app/models/domain/product.py +22 -0
  48. hinbert_fastapi-0.1.0/app/models/domain/refresh_token.py +24 -0
  49. hinbert_fastapi-0.1.0/app/models/domain/totp_secret.py +44 -0
  50. hinbert_fastapi-0.1.0/app/models/domain/user.py +34 -0
  51. hinbert_fastapi-0.1.0/app/models/schemas/__init__.py +1 -0
  52. hinbert_fastapi-0.1.0/app/models/schemas/auth.py +30 -0
  53. hinbert_fastapi-0.1.0/app/models/schemas/password.py +16 -0
  54. hinbert_fastapi-0.1.0/app/models/schemas/product.py +31 -0
  55. hinbert_fastapi-0.1.0/app/models/schemas/response.py +17 -0
  56. hinbert_fastapi-0.1.0/app/models/schemas/token.py +17 -0
  57. hinbert_fastapi-0.1.0/app/models/schemas/totp.py +16 -0
  58. hinbert_fastapi-0.1.0/app/models/schemas/user.py +37 -0
  59. hinbert_fastapi-0.1.0/app/repositories/__init__.py +1 -0
  60. hinbert_fastapi-0.1.0/app/repositories/base/__init__.py +1 -0
  61. hinbert_fastapi-0.1.0/app/repositories/base/base_repository.py +51 -0
  62. hinbert_fastapi-0.1.0/app/repositories/email_verification_repository.py +37 -0
  63. hinbert_fastapi-0.1.0/app/repositories/password_reset_repository.py +33 -0
  64. hinbert_fastapi-0.1.0/app/repositories/product_repository.py +40 -0
  65. hinbert_fastapi-0.1.0/app/repositories/refresh_token_repository.py +40 -0
  66. hinbert_fastapi-0.1.0/app/repositories/totp_secret_repository.py +23 -0
  67. hinbert_fastapi-0.1.0/app/repositories/user_repository.py +44 -0
  68. hinbert_fastapi-0.1.0/app/services/__init__.py +1 -0
  69. hinbert_fastapi-0.1.0/app/services/auth_service.py +32 -0
  70. hinbert_fastapi-0.1.0/app/services/base/__init__.py +1 -0
  71. hinbert_fastapi-0.1.0/app/services/base/base_service.py +21 -0
  72. hinbert_fastapi-0.1.0/app/services/email_service.py +32 -0
  73. hinbert_fastapi-0.1.0/app/services/product_service.py +24 -0
  74. hinbert_fastapi-0.1.0/app/services/social_auth_service.py +47 -0
  75. hinbert_fastapi-0.1.0/app/services/totp_service.py +13 -0
  76. hinbert_fastapi-0.1.0/app/services/user_service.py +27 -0
  77. hinbert_fastapi-0.1.0/app/tests/__init__.py +1 -0
  78. hinbert_fastapi-0.1.0/app/tests/conftest.py +36 -0
  79. hinbert_fastapi-0.1.0/app/tests/integration/test_auth_api.py +115 -0
  80. hinbert_fastapi-0.1.0/app/tests/integration/test_product_api.py +58 -0
  81. hinbert_fastapi-0.1.0/app/tests/integration/test_user_api.py +68 -0
  82. hinbert_fastapi-0.1.0/app/tests/unit/test_auth_service.py +48 -0
  83. hinbert_fastapi-0.1.0/app/tests/unit/test_product_service.py +33 -0
  84. hinbert_fastapi-0.1.0/app/tests/unit/test_user_service.py +32 -0
  85. hinbert_fastapi-0.1.0/app/utils/__init__.py +1 -0
  86. hinbert_fastapi-0.1.0/app/utils/date_utils.py +8 -0
  87. hinbert_fastapi-0.1.0/app/utils/file_utils.py +8 -0
  88. hinbert_fastapi-0.1.0/app/utils/logger.py +11 -0
  89. hinbert_fastapi-0.1.0/app/utils/validators.py +13 -0
  90. hinbert_fastapi-0.1.0/hinbert_fastapi.egg-info/PKG-INFO +0 -0
  91. hinbert_fastapi-0.1.0/hinbert_fastapi.egg-info/SOURCES.txt +99 -0
  92. hinbert_fastapi-0.1.0/hinbert_fastapi.egg-info/dependency_links.txt +1 -0
  93. hinbert_fastapi-0.1.0/hinbert_fastapi.egg-info/entry_points.txt +5 -0
  94. hinbert_fastapi-0.1.0/hinbert_fastapi.egg-info/requires.txt +20 -0
  95. hinbert_fastapi-0.1.0/hinbert_fastapi.egg-info/top_level.txt +2 -0
  96. hinbert_fastapi-0.1.0/pyproject.toml +42 -0
  97. hinbert_fastapi-0.1.0/scripts/__init__.py +1 -0
  98. hinbert_fastapi-0.1.0/scripts/create_admin.py +41 -0
  99. hinbert_fastapi-0.1.0/scripts/run_migrations.py +13 -0
  100. hinbert_fastapi-0.1.0/scripts/seed_data.py +44 -0
  101. hinbert_fastapi-0.1.0/setup.cfg +4 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hinbert Engineering
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
Binary file
Binary file
@@ -0,0 +1,8 @@
1
+ """Hinbert FastAPI application package.
2
+
3
+ This package contains the HTTP application and its independently testable layers.
4
+ It exists so the boilerplate can be installed as a reusable PyPI package while
5
+ still permitting teams to replace individual modules with project-specific code.
6
+ """
7
+
8
+ __version__ = "0.1.0"
@@ -0,0 +1 @@
1
+ """HTTP API namespace."""
@@ -0,0 +1 @@
1
+ """Reusable FastAPI dependencies."""
@@ -0,0 +1,40 @@
1
+ """Bearer-token dependencies for protected routes."""
2
+
3
+ from uuid import UUID
4
+
5
+ from fastapi import Depends
6
+ from fastapi.security import OAuth2PasswordBearer
7
+ from sqlalchemy.ext.asyncio import AsyncSession
8
+
9
+ from app.core.exceptions.custom_exceptions import UnauthorizedError
10
+ from app.core.security.jwt import decode_token
11
+ from app.db.session import get_db
12
+ from app.models.domain.user import User
13
+
14
+ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
15
+
16
+
17
+ async def get_current_user(token: str = Depends(oauth2_scheme), session: AsyncSession = Depends(get_db)) -> User:
18
+ """Decode an access token and load its user, failing closed on any error."""
19
+ try:
20
+ subject = UUID(decode_token(token)["sub"])
21
+ except (ValueError, KeyError) as exc:
22
+ raise UnauthorizedError() from exc
23
+ user = await session.get(User, subject)
24
+ if user is None:
25
+ raise UnauthorizedError()
26
+ return user
27
+
28
+
29
+ async def get_current_active_user(user: User = Depends(get_current_user)) -> User:
30
+ """Reject deactivated accounts before protected business operations."""
31
+ if not user.is_active:
32
+ raise UnauthorizedError("Inactive account")
33
+ return user
34
+
35
+
36
+ async def get_current_admin(user: User = Depends(get_current_active_user)) -> User:
37
+ """Require an active account with administrator privileges."""
38
+ if not user.is_admin:
39
+ raise UnauthorizedError("Administrator privileges required")
40
+ return user
@@ -0,0 +1,8 @@
1
+ """Validated pagination dependency shared by collection endpoints."""
2
+
3
+ from fastapi import Query
4
+
5
+
6
+ def pagination(page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100)) -> tuple[int, int]:
7
+ """Return SQL offset and bounded page size."""
8
+ return (page - 1) * page_size, page_size
@@ -0,0 +1 @@
1
+ """Version one API namespace."""
@@ -0,0 +1 @@
1
+ """Version one endpoint modules."""
@@ -0,0 +1,255 @@
1
+ """Authentication endpoints for credentials, recovery, MFA, and OAuth."""
2
+
3
+ import secrets
4
+ from datetime import UTC, datetime, timedelta
5
+ from urllib.parse import urlencode
6
+
7
+ from fastapi import APIRouter, Depends, HTTPException, Request
8
+ from fastapi.responses import RedirectResponse
9
+ from sqlalchemy.ext.asyncio import AsyncSession
10
+
11
+ from app.api.deps.auth import get_current_active_user
12
+ from app.core.config.settings import get_settings
13
+ from app.core.exceptions.custom_exceptions import UnauthorizedError
14
+ from app.core.middleware.rate_limit import limiter
15
+ from app.core.security.password import hash_password, verify_password
16
+ from app.core.security.totp import new_secret, verify_code
17
+ from app.db.session import get_db
18
+ from app.models.domain.email_verification import EmailVerification
19
+ from app.models.domain.password_reset import PasswordReset
20
+ from app.models.domain.refresh_token import RefreshToken
21
+ from app.models.domain.totp_secret import TotpSecret
22
+ from app.models.domain.user import User
23
+ from app.models.schemas.auth import EmailTokenRequest, LoginRequest, SignupRequest
24
+ from app.models.schemas.password import ForgotPassword, ResetPassword
25
+ from app.models.schemas.response import APIResponse
26
+ from app.models.schemas.token import RefreshRequest, TokenResponse
27
+ from app.models.schemas.totp import TotpSetupResponse, TotpVerify
28
+ from app.models.schemas.user import UserOut
29
+ from app.repositories.email_verification_repository import create as create_verification
30
+ from app.repositories.email_verification_repository import get_by_token as get_verification
31
+ from app.repositories.email_verification_repository import mark_used as mark_verification_used
32
+ from app.repositories.password_reset_repository import create as create_reset
33
+ from app.repositories.password_reset_repository import get_by_token as get_reset
34
+ from app.repositories.password_reset_repository import mark_used as mark_reset_used
35
+ from app.repositories.refresh_token_repository import find_active, revoke
36
+ from app.repositories.user_repository import get_by_email
37
+ from app.services.auth_service import hash_refresh_token, issue_tokens
38
+ from app.services.email_service import send_reset_email, send_verification_email
39
+ from app.services.social_auth_service import exchange_provider_code
40
+ from app.services.user_service import create_user
41
+
42
+ router = APIRouter(prefix="/auth", tags=["auth"])
43
+
44
+
45
+ @router.post("/signup", response_model=APIResponse[UserOut], status_code=201)
46
+ @limiter.limit("10/minute")
47
+ async def signup(request: Request, payload: SignupRequest, session: AsyncSession = Depends(get_db)):
48
+ """Create a user and persist a one-time email verification token."""
49
+ user = await create_user(session, payload)
50
+ raw_token = secrets.token_urlsafe(32)
51
+ await create_verification(
52
+ session,
53
+ EmailVerification(
54
+ user_id=user.id,
55
+ token_hash=hash_refresh_token(raw_token),
56
+ expires_at=datetime.now(UTC) + timedelta(hours=24),
57
+ ),
58
+ )
59
+ send_verification_email(user, raw_token)
60
+ return APIResponse(message="Account created", data=user, status_code=201)
61
+
62
+
63
+ @router.post("/login", response_model=APIResponse[TokenResponse])
64
+ @limiter.limit("10/minute")
65
+ async def login(request: Request, payload: LoginRequest, session: AsyncSession = Depends(get_db)):
66
+ """Verify credentials and issue access plus opaque refresh credentials."""
67
+ user = await get_by_email(session, str(payload.email))
68
+ if user is None or not user.is_active or not verify_password(payload.password, user.password_hash):
69
+ raise UnauthorizedError("Invalid credentials")
70
+ access, refresh, digest, expiry = issue_tokens(user.id)
71
+ session.add(RefreshToken(user_id=user.id, token_hash=digest, expires_at=expiry))
72
+ await session.commit()
73
+ return APIResponse(message="Login successful", data=TokenResponse(access_token=access, refresh_token=refresh))
74
+
75
+
76
+ @router.post("/refresh", response_model=APIResponse[TokenResponse])
77
+ @limiter.limit("30/minute")
78
+ async def refresh(request: Request, payload: RefreshRequest, session: AsyncSession = Depends(get_db)):
79
+ """Rotate a valid refresh token and revoke the predecessor."""
80
+ stored = await find_active(session, hash_refresh_token(payload.refresh_token))
81
+ if stored is None:
82
+ raise UnauthorizedError("Invalid refresh token")
83
+ user = await session.get(User, stored.user_id)
84
+ if user is None or not user.is_active:
85
+ raise UnauthorizedError("Inactive account")
86
+ access, raw_refresh, digest, expiry = issue_tokens(user.id)
87
+ await revoke(session, stored)
88
+ session.add(RefreshToken(user_id=user.id, token_hash=digest, expires_at=expiry))
89
+ await session.commit()
90
+ return APIResponse(message="Token refreshed", data=TokenResponse(access_token=access, refresh_token=raw_refresh))
91
+
92
+
93
+ @router.post("/logout", response_model=APIResponse[None])
94
+ @limiter.limit("30/minute")
95
+ async def logout(request: Request, payload: RefreshRequest, session: AsyncSession = Depends(get_db)):
96
+ """Revoke the supplied refresh token without revealing whether it existed."""
97
+ stored = await find_active(session, hash_refresh_token(payload.refresh_token))
98
+ if stored is not None:
99
+ await revoke(session, stored)
100
+ return APIResponse(message="Logged out", data=None)
101
+
102
+
103
+ @router.post("/verify-email", response_model=APIResponse[None])
104
+ @limiter.limit("10/minute")
105
+ async def verify_email(request: Request, payload: EmailTokenRequest, session: AsyncSession = Depends(get_db)):
106
+ """Consume a verification token and activate its account."""
107
+ stored = await get_verification(session, hash_refresh_token(payload.token))
108
+ if stored is None:
109
+ raise HTTPException(status_code=400, detail="Invalid verification token")
110
+ user = await session.get(User, stored.user_id)
111
+ if user is None:
112
+ raise HTTPException(status_code=400, detail="Invalid verification token")
113
+ user.is_verified = True
114
+ user.is_active = True
115
+ await mark_verification_used(session, stored)
116
+ return APIResponse(message="Email verified", data=None)
117
+
118
+
119
+ @router.post("/forgot-password", response_model=APIResponse[None])
120
+ @limiter.limit("5/minute")
121
+ async def forgot_password(request: Request, payload: ForgotPassword, session: AsyncSession = Depends(get_db)):
122
+ """Issue a reset token and send it without disclosing account existence."""
123
+ user = await get_by_email(session, str(payload.email))
124
+ if user is not None:
125
+ raw_token = secrets.token_urlsafe(32)
126
+ await create_reset(
127
+ session,
128
+ PasswordReset(
129
+ user_id=user.id,
130
+ token_hash=hash_refresh_token(raw_token),
131
+ expires_at=datetime.now(UTC) + timedelta(hours=1),
132
+ ),
133
+ )
134
+ send_reset_email(user, raw_token)
135
+ return APIResponse(message="If the account exists, a reset email was sent", data=None)
136
+
137
+
138
+ @router.post("/reset-password", response_model=APIResponse[None])
139
+ @limiter.limit("5/minute")
140
+ async def reset_password(request: Request, payload: ResetPassword, session: AsyncSession = Depends(get_db)):
141
+ """Consume a reset token and replace the stored bcrypt password hash."""
142
+ stored = await get_reset(session, hash_refresh_token(payload.token))
143
+ if stored is None:
144
+ raise HTTPException(status_code=400, detail="Invalid reset token")
145
+ user = await session.get(User, stored.user_id)
146
+ if user is None:
147
+ raise HTTPException(status_code=400, detail="Invalid reset token")
148
+ user.password_hash = hash_password(payload.password)
149
+ await mark_reset_used(session, stored)
150
+ return APIResponse(message="Password reset", data=None)
151
+
152
+
153
+ @router.post("/totp/setup", response_model=APIResponse[TotpSetupResponse])
154
+ @limiter.limit("10/minute")
155
+ async def setup_totp(
156
+ request: Request, user: User = Depends(get_current_active_user), session: AsyncSession = Depends(get_db)
157
+ ):
158
+ """Generate encrypted TOTP provisioning data for the current user."""
159
+ secret = new_secret()
160
+ record = TotpSecret(user_id=user.id)
161
+ record.secret = secret
162
+ session.add(record)
163
+ await session.commit()
164
+ uri = __import__("pyotp").TOTP(secret).provisioning_uri(name=user.email, issuer_name=get_settings().app_name)
165
+ return APIResponse(message="TOTP configured", data=TotpSetupResponse(secret=secret, provisioning_uri=uri))
166
+
167
+
168
+ @router.post("/totp/verify", response_model=APIResponse[None])
169
+ @limiter.limit("10/minute")
170
+ async def verify_totp(
171
+ request: Request,
172
+ payload: TotpVerify,
173
+ user: User = Depends(get_current_active_user),
174
+ session: AsyncSession = Depends(get_db),
175
+ ):
176
+ """Verify the user's encrypted TOTP secret and enable the factor."""
177
+ record = await session.get(TotpSecret, user.id)
178
+ if record is None or not verify_code(record.secret, payload.code):
179
+ raise HTTPException(status_code=400, detail="Invalid TOTP code")
180
+ return APIResponse(message="TOTP verified", data=None)
181
+
182
+
183
+ @router.get("/google")
184
+ @limiter.limit("30/minute")
185
+ async def google_login(request: Request):
186
+ """Redirect to Google's OAuth authorization endpoint."""
187
+ settings = get_settings()
188
+ params = urlencode(
189
+ {
190
+ "client_id": settings.google_client_id,
191
+ "redirect_uri": "/api/v1/auth/google/callback",
192
+ "response_type": "code",
193
+ "scope": "openid email profile",
194
+ }
195
+ )
196
+ return RedirectResponse(f"https://accounts.google.com/o/oauth2/v2/auth?{params}")
197
+
198
+
199
+ @router.get("/google/callback", response_model=APIResponse[TokenResponse])
200
+ @limiter.limit("30/minute")
201
+ async def google_callback(request: Request, code: str, session: AsyncSession = Depends(get_db)):
202
+ """Exchange Google's code and provision or authenticate the user."""
203
+ return await _social_login("google", code, session)
204
+
205
+
206
+ @router.get("/facebook")
207
+ @limiter.limit("30/minute")
208
+ async def facebook_login(request: Request):
209
+ """Redirect to Facebook's OAuth authorization endpoint."""
210
+ settings = get_settings()
211
+ params = urlencode(
212
+ {
213
+ "client_id": settings.facebook_client_id,
214
+ "redirect_uri": "/api/v1/auth/facebook/callback",
215
+ "response_type": "code",
216
+ "scope": "email,public_profile",
217
+ }
218
+ )
219
+ return RedirectResponse(f"https://www.facebook.com/v19.0/dialog/oauth?{params}")
220
+
221
+
222
+ @router.get("/facebook/callback", response_model=APIResponse[TokenResponse])
223
+ @limiter.limit("30/minute")
224
+ async def facebook_callback(request: Request, code: str, session: AsyncSession = Depends(get_db)):
225
+ """Exchange Facebook's code and provision or authenticate the user."""
226
+ return await _social_login("facebook", code, session)
227
+
228
+
229
+ async def _social_login(provider: str, code: str, session: AsyncSession):
230
+ """Normalize a provider profile and issue the same token pair as password login."""
231
+ profile = await exchange_provider_code(provider, code)
232
+ user = await get_by_email(session, profile["email"])
233
+ if user is None:
234
+ user = User(
235
+ email=profile["email"],
236
+ full_name=profile["full_name"],
237
+ password_hash=hash_password(secrets.token_urlsafe(32)),
238
+ is_verified=True,
239
+ )
240
+ session.add(user)
241
+ await session.flush()
242
+ access, refresh_token, digest, expiry = issue_tokens(user.id)
243
+ session.add(RefreshToken(user_id=user.id, token_hash=digest, expires_at=expiry))
244
+ await session.commit()
245
+ return APIResponse(
246
+ message=f"{provider.title()} login successful",
247
+ data=TokenResponse(access_token=access, refresh_token=refresh_token),
248
+ )
249
+
250
+
251
+ @router.get("/me", response_model=APIResponse[UserOut])
252
+ @limiter.limit("60/minute")
253
+ async def me(request: Request, user: User = Depends(get_current_active_user)):
254
+ """Return the authenticated user's safe profile."""
255
+ return APIResponse(data=user)
@@ -0,0 +1,23 @@
1
+ """Operational dashboard endpoint placeholder for aggregated metrics."""
2
+
3
+ from fastapi import APIRouter, Depends, Request
4
+ from sqlalchemy import func, select
5
+ from sqlalchemy.ext.asyncio import AsyncSession
6
+
7
+ from app.api.deps.auth import get_current_admin
8
+ from app.core.middleware.rate_limit import limiter
9
+ from app.db.session import get_db
10
+ from app.models.domain.product import Product
11
+ from app.models.domain.user import User
12
+ from app.models.schemas.response import APIResponse
13
+
14
+ router = APIRouter(prefix="/dashboard", tags=["dashboard"])
15
+
16
+
17
+ @router.get("/stats", response_model=APIResponse[dict[str, int]])
18
+ @limiter.limit("60/minute")
19
+ async def stats(request: Request, _: User = Depends(get_current_admin), session: AsyncSession = Depends(get_db)):
20
+ """Return current user and product counts for administrators."""
21
+ users = await session.scalar(select(func.count()).select_from(User))
22
+ products = await session.scalar(select(func.count()).select_from(Product))
23
+ return APIResponse(data={"users": users or 0, "products": products or 0, "active_sessions": 0})
@@ -0,0 +1,102 @@
1
+ """Product CRUD endpoints with bounded filtering, sorting, and pagination."""
2
+
3
+ from decimal import Decimal
4
+ from uuid import UUID
5
+
6
+ from fastapi import APIRouter, Depends, Query, Request
7
+ from sqlalchemy import func, select
8
+ from sqlalchemy.ext.asyncio import AsyncSession
9
+
10
+ from app.api.deps.auth import get_current_active_user
11
+ from app.api.deps.pagination import pagination
12
+ from app.core.exceptions.custom_exceptions import NotFoundError, UnauthorizedError
13
+ from app.core.middleware.rate_limit import limiter
14
+ from app.db.session import get_db
15
+ from app.models.domain.product import Product
16
+ from app.models.domain.user import User
17
+ from app.models.schemas.product import ProductCreate, ProductOut, ProductPage
18
+ from app.models.schemas.response import APIResponse
19
+ from app.repositories.product_repository import ProductRepository
20
+ from app.services.product_service import create_product, update_product
21
+
22
+ router = APIRouter(prefix="/products", tags=["products"])
23
+
24
+
25
+ @router.post("", response_model=APIResponse[ProductOut], status_code=201)
26
+ @limiter.limit("60/minute")
27
+ async def create(
28
+ request: Request,
29
+ payload: ProductCreate,
30
+ user: User = Depends(get_current_active_user),
31
+ session: AsyncSession = Depends(get_db),
32
+ ):
33
+ """Create a product owned by the authenticated user."""
34
+ product = await create_product(session, payload)
35
+ product.owner_id = user.id
36
+ await session.commit()
37
+ return APIResponse(message="Product created", data=product, status_code=201)
38
+
39
+
40
+ @router.get("", response_model=APIResponse[ProductPage])
41
+ @limiter.limit("120/minute")
42
+ async def list_products(
43
+ request: Request,
44
+ page: tuple[int, int] = Depends(pagination),
45
+ category: str | None = None,
46
+ min_price: Decimal | None = Query(None, ge=0),
47
+ max_price: Decimal | None = Query(None, ge=0),
48
+ sort_by: str = Query("created_at", pattern="^(name|price|created_at)$"),
49
+ session: AsyncSession = Depends(get_db),
50
+ ):
51
+ """Return products and deterministic pagination metadata."""
52
+ offset, limit = page
53
+ items = await ProductRepository(session).get_all(offset, limit, category, min_price, max_price, sort_by)
54
+ total = await session.scalar(select(func.count()).select_from(Product))
55
+ return APIResponse(data={"items": items, "total_count": total or 0, "page": offset // limit + 1, "limit": limit})
56
+
57
+
58
+ @router.get("/{product_id}", response_model=APIResponse[ProductOut])
59
+ @limiter.limit("120/minute")
60
+ async def get_product(request: Request, product_id: UUID, session: AsyncSession = Depends(get_db)):
61
+ """Return one product by UUID."""
62
+ product = await session.get(Product, product_id)
63
+ if product is None:
64
+ raise NotFoundError("Product not found")
65
+ return APIResponse(data=product)
66
+
67
+
68
+ @router.put("/{product_id}", response_model=APIResponse[ProductOut])
69
+ @limiter.limit("60/minute")
70
+ async def update_product_endpoint(
71
+ request: Request,
72
+ product_id: UUID,
73
+ payload: ProductCreate,
74
+ user: User = Depends(get_current_active_user),
75
+ session: AsyncSession = Depends(get_db),
76
+ ):
77
+ """Update a product when the caller owns it or is an administrator."""
78
+ product = await session.get(Product, product_id)
79
+ if product is None:
80
+ raise NotFoundError("Product not found")
81
+ if product.owner_id not in {None, user.id} and not user.is_admin:
82
+ raise UnauthorizedError("Product ownership required")
83
+ return APIResponse(data=await update_product(session, product, payload), message="Product updated")
84
+
85
+
86
+ @router.delete("/{product_id}", response_model=APIResponse[None])
87
+ @limiter.limit("60/minute")
88
+ async def delete_product(
89
+ request: Request,
90
+ product_id: UUID,
91
+ user: User = Depends(get_current_active_user),
92
+ session: AsyncSession = Depends(get_db),
93
+ ):
94
+ """Delete a product when the caller owns it or is an administrator."""
95
+ product = await session.get(Product, product_id)
96
+ if product is None:
97
+ raise NotFoundError("Product not found")
98
+ if product.owner_id not in {None, user.id} and not user.is_admin:
99
+ raise UnauthorizedError("Product ownership required")
100
+ await session.delete(product)
101
+ await session.commit()
102
+ return APIResponse(message="Product deleted", data=None)
@@ -0,0 +1,89 @@
1
+ """Authenticated profile and administrator user-management endpoints."""
2
+
3
+ from uuid import UUID
4
+
5
+ from fastapi import APIRouter, Depends, Request
6
+ from sqlalchemy.ext.asyncio import AsyncSession
7
+
8
+ from app.api.deps.auth import get_current_active_user, get_current_admin
9
+ from app.api.deps.pagination import pagination
10
+ from app.core.middleware.rate_limit import limiter
11
+ from app.db.session import get_db
12
+ from app.models.domain.user import User
13
+ from app.models.schemas.response import APIResponse
14
+ from app.models.schemas.user import AdminUserUpdate, UserOut, UserUpdate
15
+ from app.repositories import user_repository
16
+ from app.services.user_service import update_user
17
+
18
+ router = APIRouter(prefix="/users", tags=["users"])
19
+
20
+
21
+ @router.patch("/me", response_model=APIResponse[UserOut])
22
+ @limiter.limit("60/minute")
23
+ async def update_me(
24
+ request: Request,
25
+ payload: UserUpdate,
26
+ user: User = Depends(get_current_active_user),
27
+ session: AsyncSession = Depends(get_db),
28
+ ):
29
+ """Update allowed profile fields for the current account."""
30
+ return APIResponse(data=await update_user(session, user, payload), message="Profile updated")
31
+
32
+
33
+ @router.get("", response_model=APIResponse[list[UserOut]])
34
+ @limiter.limit("60/minute")
35
+ async def list_users(
36
+ request: Request,
37
+ page: tuple[int, int] = Depends(pagination),
38
+ _: User = Depends(get_current_admin),
39
+ session: AsyncSession = Depends(get_db),
40
+ ):
41
+ """List users for administrators with bounded pagination."""
42
+ return APIResponse(data=await user_repository.get_all(session, *page))
43
+
44
+
45
+ @router.get("/{user_id}", response_model=APIResponse[UserOut])
46
+ @limiter.limit("60/minute")
47
+ async def get_user(
48
+ request: Request, user_id: UUID, _: User = Depends(get_current_admin), session: AsyncSession = Depends(get_db)
49
+ ):
50
+ """Return one user to an administrator."""
51
+ user = await user_repository.get_by_id(session, user_id)
52
+ if user is None:
53
+ from app.core.exceptions.custom_exceptions import NotFoundError
54
+
55
+ raise NotFoundError("User not found")
56
+ return APIResponse(data=user)
57
+
58
+
59
+ @router.patch("/{user_id}", response_model=APIResponse[UserOut])
60
+ @limiter.limit("30/minute")
61
+ async def update_user_admin(
62
+ request: Request,
63
+ user_id: UUID,
64
+ payload: AdminUserUpdate,
65
+ _: User = Depends(get_current_admin),
66
+ session: AsyncSession = Depends(get_db),
67
+ ):
68
+ """Update profile and role fields as an administrator."""
69
+ user = await user_repository.get_by_id(session, user_id)
70
+ if user is None:
71
+ from app.core.exceptions.custom_exceptions import NotFoundError
72
+
73
+ raise NotFoundError("User not found")
74
+ return APIResponse(data=await user_repository.update(session, user, payload.model_dump(exclude_none=True)))
75
+
76
+
77
+ @router.delete("/{user_id}", response_model=APIResponse[None])
78
+ @limiter.limit("30/minute")
79
+ async def delete_user(
80
+ request: Request, user_id: UUID, _: User = Depends(get_current_admin), session: AsyncSession = Depends(get_db)
81
+ ):
82
+ """Hard-delete a user and dependent records as an administrator."""
83
+ user = await user_repository.get_by_id(session, user_id)
84
+ if user is None:
85
+ from app.core.exceptions.custom_exceptions import NotFoundError
86
+
87
+ raise NotFoundError("User not found")
88
+ await user_repository.delete(session, user)
89
+ return APIResponse(message="User deleted", data=None)
@@ -0,0 +1 @@
1
+ """Router registration namespace."""
@@ -0,0 +1,11 @@
1
+ """Central versioned router registry."""
2
+
3
+ from fastapi import APIRouter
4
+
5
+ from app.api.v1.endpoints import auth, dashboard, products, users
6
+
7
+ api_router = APIRouter()
8
+ api_router.include_router(auth.router)
9
+ api_router.include_router(users.router)
10
+ api_router.include_router(products.router)
11
+ api_router.include_router(dashboard.router)
@@ -0,0 +1 @@
1
+ """Cross-cutting configuration, security, middleware, and exception modules."""
@@ -0,0 +1 @@
1
+ """Configuration and database setup namespace."""
@@ -0,0 +1,13 @@
1
+ """Async SQLAlchemy engine and session factory.
2
+
3
+ The engine is created lazily at import time and sessions are request-scoped by
4
+ dependency. Pool values should be tuned to deployment capacity, not guessed in
5
+ individual endpoints.
6
+ """
7
+
8
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
9
+
10
+ from app.core.config.settings import get_settings
11
+
12
+ engine = create_async_engine(get_settings().database_url, pool_pre_ping=True)
13
+ SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)