hinbert-fastapi 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 (96) hide show
  1. app/__init__.py +8 -0
  2. app/api/__init__.py +1 -0
  3. app/api/deps/__init__.py +1 -0
  4. app/api/deps/auth.py +40 -0
  5. app/api/deps/pagination.py +8 -0
  6. app/api/v1/__init__.py +1 -0
  7. app/api/v1/endpoints/__init__.py +1 -0
  8. app/api/v1/endpoints/auth.py +255 -0
  9. app/api/v1/endpoints/dashboard.py +23 -0
  10. app/api/v1/endpoints/products.py +102 -0
  11. app/api/v1/endpoints/users.py +89 -0
  12. app/api/v1/routers/__init__.py +1 -0
  13. app/api/v1/routers/api_router.py +11 -0
  14. app/core/__init__.py +1 -0
  15. app/core/config/__init__.py +1 -0
  16. app/core/config/database.py +13 -0
  17. app/core/config/settings.py +83 -0
  18. app/core/exceptions/__init__.py +1 -0
  19. app/core/exceptions/base_exception.py +9 -0
  20. app/core/exceptions/custom_exceptions.py +17 -0
  21. app/core/middleware/__init__.py +1 -0
  22. app/core/middleware/cors.py +5 -0
  23. app/core/middleware/error_handler.py +23 -0
  24. app/core/middleware/logging.py +29 -0
  25. app/core/middleware/rate_limit.py +27 -0
  26. app/core/security/__init__.py +1 -0
  27. app/core/security/auth.py +8 -0
  28. app/core/security/jwt.py +33 -0
  29. app/core/security/oauth.py +8 -0
  30. app/core/security/password.py +18 -0
  31. app/core/security/totp.py +13 -0
  32. app/db/__init__.py +1 -0
  33. app/db/base.py +7 -0
  34. app/db/migrations/env.py +39 -0
  35. app/db/migrations/versions/97ed1bc05f4a_complete_product_and_totp_fields.py +49 -0
  36. app/db/migrations/versions/f8b632aa11da_initial_migration.py +113 -0
  37. app/db/session.py +11 -0
  38. app/main.py +50 -0
  39. app/models/__init__.py +10 -0
  40. app/models/domain/__init__.py +1 -0
  41. app/models/domain/base.py +20 -0
  42. app/models/domain/email_verification.py +24 -0
  43. app/models/domain/password_reset.py +24 -0
  44. app/models/domain/product.py +22 -0
  45. app/models/domain/refresh_token.py +24 -0
  46. app/models/domain/totp_secret.py +44 -0
  47. app/models/domain/user.py +34 -0
  48. app/models/schemas/__init__.py +1 -0
  49. app/models/schemas/auth.py +30 -0
  50. app/models/schemas/password.py +16 -0
  51. app/models/schemas/product.py +31 -0
  52. app/models/schemas/response.py +17 -0
  53. app/models/schemas/token.py +17 -0
  54. app/models/schemas/totp.py +16 -0
  55. app/models/schemas/user.py +37 -0
  56. app/repositories/__init__.py +1 -0
  57. app/repositories/base/__init__.py +1 -0
  58. app/repositories/base/base_repository.py +51 -0
  59. app/repositories/email_verification_repository.py +37 -0
  60. app/repositories/password_reset_repository.py +33 -0
  61. app/repositories/product_repository.py +40 -0
  62. app/repositories/refresh_token_repository.py +40 -0
  63. app/repositories/totp_secret_repository.py +23 -0
  64. app/repositories/user_repository.py +44 -0
  65. app/services/__init__.py +1 -0
  66. app/services/auth_service.py +32 -0
  67. app/services/base/__init__.py +1 -0
  68. app/services/base/base_service.py +21 -0
  69. app/services/email_service.py +32 -0
  70. app/services/product_service.py +24 -0
  71. app/services/social_auth_service.py +47 -0
  72. app/services/totp_service.py +13 -0
  73. app/services/user_service.py +27 -0
  74. app/tests/__init__.py +1 -0
  75. app/tests/conftest.py +36 -0
  76. app/tests/integration/test_auth_api.py +115 -0
  77. app/tests/integration/test_product_api.py +58 -0
  78. app/tests/integration/test_user_api.py +68 -0
  79. app/tests/unit/test_auth_service.py +48 -0
  80. app/tests/unit/test_product_service.py +33 -0
  81. app/tests/unit/test_user_service.py +32 -0
  82. app/utils/__init__.py +1 -0
  83. app/utils/date_utils.py +8 -0
  84. app/utils/file_utils.py +8 -0
  85. app/utils/logger.py +11 -0
  86. app/utils/validators.py +13 -0
  87. hinbert_fastapi-0.1.0.dist-info/METADATA +0 -0
  88. hinbert_fastapi-0.1.0.dist-info/RECORD +96 -0
  89. hinbert_fastapi-0.1.0.dist-info/WHEEL +5 -0
  90. hinbert_fastapi-0.1.0.dist-info/entry_points.txt +5 -0
  91. hinbert_fastapi-0.1.0.dist-info/licenses/LICENSE +21 -0
  92. hinbert_fastapi-0.1.0.dist-info/top_level.txt +2 -0
  93. scripts/__init__.py +1 -0
  94. scripts/create_admin.py +41 -0
  95. scripts/run_migrations.py +13 -0
  96. scripts/seed_data.py +44 -0
@@ -0,0 +1,44 @@
1
+ """Encrypted-at-rest TOTP secret ownership boundary."""
2
+
3
+ from typing import TYPE_CHECKING
4
+ from uuid import UUID
5
+
6
+ from cryptography.fernet import Fernet, InvalidToken
7
+ from sqlalchemy import ForeignKey, String
8
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
9
+
10
+ from app.core.config.settings import get_settings
11
+ from app.db.base import Base
12
+
13
+ if TYPE_CHECKING:
14
+ from app.models.domain.user import User
15
+
16
+
17
+ class TotpSecret(Base):
18
+ """One TOTP secret per user; production deployments should encrypt its value."""
19
+
20
+ __tablename__ = "totp_secrets"
21
+ user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), primary_key=True)
22
+ encrypted_secret: Mapped[str] = mapped_column(String(512))
23
+ user: Mapped["User"] = relationship(back_populates="totp_secret")
24
+
25
+ @property
26
+ def secret(self) -> str:
27
+ """Decrypt the secret only at the point where TOTP verification needs it."""
28
+ try:
29
+ return Fernet(_fernet_key()).decrypt(self.encrypted_secret.encode()).decode()
30
+ except InvalidToken as exc:
31
+ raise ValueError("Stored TOTP secret cannot be decrypted") from exc
32
+
33
+ @secret.setter
34
+ def secret(self, value: str) -> None:
35
+ """Encrypt a plaintext secret before SQLAlchemy persists it."""
36
+ self.encrypted_secret = Fernet(_fernet_key()).encrypt(value.encode()).decode()
37
+
38
+
39
+ def _fernet_key() -> bytes:
40
+ """Return a Fernet key derived from the configured secret key."""
41
+ import base64
42
+ import hashlib
43
+
44
+ return base64.urlsafe_b64encode(hashlib.sha256(get_settings().jwt_secret_key.get_secret_value().encode()).digest())
@@ -0,0 +1,34 @@
1
+ """User table storing identity, authorization, and password state."""
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from sqlalchemy import Boolean, String
6
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
7
+
8
+ from app.models.domain.base import TimestampedModel
9
+
10
+ if TYPE_CHECKING:
11
+ from app.models.domain.email_verification import EmailVerification
12
+ from app.models.domain.password_reset import PasswordReset
13
+ from app.models.domain.refresh_token import RefreshToken
14
+ from app.models.domain.totp_secret import TotpSecret
15
+
16
+
17
+ class User(TimestampedModel):
18
+ """Application user; email is unique and password hashes are never exposed."""
19
+
20
+ __tablename__ = "users"
21
+ email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
22
+ full_name: Mapped[str] = mapped_column(String(200))
23
+ password_hash: Mapped[str] = mapped_column(String(255))
24
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True)
25
+ is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
26
+ is_verified: Mapped[bool] = mapped_column(Boolean, default=False)
27
+ refresh_tokens: Mapped[list["RefreshToken"]] = relationship(back_populates="user", cascade="all, delete-orphan")
28
+ email_verifications: Mapped[list["EmailVerification"]] = relationship(
29
+ back_populates="user", cascade="all, delete-orphan"
30
+ )
31
+ password_resets: Mapped[list["PasswordReset"]] = relationship(back_populates="user", cascade="all, delete-orphan")
32
+ totp_secret: Mapped["TotpSecret | None"] = relationship(
33
+ back_populates="user", uselist=False, cascade="all, delete-orphan"
34
+ )
@@ -0,0 +1 @@
1
+ """Pydantic request and response schemas."""
@@ -0,0 +1,30 @@
1
+ """Authentication request schemas."""
2
+
3
+ from pydantic import BaseModel, EmailStr, Field
4
+
5
+
6
+ class SignupRequest(BaseModel):
7
+ """Signup data with a deliberately strong minimum password length."""
8
+
9
+ email: EmailStr
10
+ full_name: str = Field(min_length=1, max_length=200)
11
+ password: str = Field(min_length=12, max_length=72)
12
+
13
+
14
+ class LoginRequest(BaseModel):
15
+ """Credential login payload."""
16
+
17
+ email: EmailStr
18
+ password: str
19
+
20
+
21
+ class EmailTokenRequest(BaseModel):
22
+ """Single-use email verification token payload."""
23
+
24
+ token: str = Field(min_length=20)
25
+
26
+
27
+ class SocialAuthRequest(BaseModel):
28
+ """OAuth callback data returned by a supported provider."""
29
+
30
+ code: str = Field(min_length=1)
@@ -0,0 +1,16 @@
1
+ """Password recovery payloads."""
2
+
3
+ from pydantic import BaseModel, EmailStr, Field
4
+
5
+
6
+ class ForgotPassword(BaseModel):
7
+ """Email address used to request a reset link."""
8
+
9
+ email: EmailStr
10
+
11
+
12
+ class ResetPassword(BaseModel):
13
+ """Single-use reset token and replacement password."""
14
+
15
+ token: str
16
+ password: str = Field(min_length=12, max_length=72)
@@ -0,0 +1,31 @@
1
+ """Product CRUD schemas."""
2
+
3
+ from decimal import Decimal
4
+ from uuid import UUID
5
+
6
+ from pydantic import BaseModel, ConfigDict, Field
7
+
8
+
9
+ class ProductCreate(BaseModel):
10
+ """Validated product creation payload."""
11
+
12
+ name: str = Field(min_length=1, max_length=200)
13
+ description: str = ""
14
+ price: Decimal = Field(ge=0, decimal_places=2)
15
+ category: str = Field(default="general", max_length=100)
16
+
17
+
18
+ class ProductOut(ProductCreate):
19
+ """Product response including database identity."""
20
+
21
+ model_config = ConfigDict(from_attributes=True)
22
+ id: UUID
23
+
24
+
25
+ class ProductPage(BaseModel):
26
+ """Paginated product collection with stable client-facing metadata."""
27
+
28
+ items: list[ProductOut]
29
+ total_count: int
30
+ page: int
31
+ limit: int
@@ -0,0 +1,17 @@
1
+ """Standard API envelope for predictable clients and observability."""
2
+
3
+ from typing import Generic, TypeVar
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ DataT = TypeVar("DataT")
8
+
9
+
10
+ class APIResponse(BaseModel, Generic[DataT]):
11
+ """Envelope containing success state, data, errors, and HTTP status."""
12
+
13
+ success: bool = True
14
+ message: str = "OK"
15
+ data: DataT | None = None
16
+ errors: list[str] = Field(default_factory=list)
17
+ status_code: int = 200
@@ -0,0 +1,17 @@
1
+ """Token exchange schemas."""
2
+
3
+ from pydantic import BaseModel
4
+
5
+
6
+ class TokenResponse(BaseModel):
7
+ """Bearer access token plus opaque refresh token."""
8
+
9
+ access_token: str
10
+ refresh_token: str
11
+ token_type: str = "bearer"
12
+
13
+
14
+ class RefreshRequest(BaseModel):
15
+ """Raw refresh token accepted only over TLS."""
16
+
17
+ refresh_token: str
@@ -0,0 +1,16 @@
1
+ """Two-factor setup and verification payloads."""
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ class TotpVerify(BaseModel):
7
+ """Six-digit current TOTP code."""
8
+
9
+ code: str = Field(pattern=r"^\d{6}$")
10
+
11
+
12
+ class TotpSetupResponse(BaseModel):
13
+ """TOTP secret and provisioning URI for authenticator applications."""
14
+
15
+ secret: str
16
+ provisioning_uri: str
@@ -0,0 +1,37 @@
1
+ """User input/output schemas that exclude password hashes."""
2
+
3
+ from uuid import UUID
4
+
5
+ from pydantic import BaseModel, ConfigDict, EmailStr, Field
6
+
7
+
8
+ class UserCreate(BaseModel):
9
+ """Validated signup payload."""
10
+
11
+ email: EmailStr
12
+ full_name: str = Field(min_length=1, max_length=200)
13
+ password: str = Field(min_length=12, max_length=128)
14
+
15
+
16
+ class UserUpdate(BaseModel):
17
+ """Partial profile update payload."""
18
+
19
+ full_name: str | None = Field(default=None, min_length=1, max_length=200)
20
+
21
+
22
+ class AdminUserUpdate(UserUpdate):
23
+ """Administrator-only profile and role update payload."""
24
+
25
+ is_active: bool | None = None
26
+ is_admin: bool | None = None
27
+
28
+
29
+ class UserOut(BaseModel):
30
+ """Safe public user representation."""
31
+
32
+ model_config = ConfigDict(from_attributes=True)
33
+ id: UUID
34
+ email: EmailStr
35
+ full_name: str
36
+ is_active: bool
37
+ is_verified: bool
@@ -0,0 +1 @@
1
+ """Repository layer encapsulates all database queries."""
@@ -0,0 +1 @@
1
+ """Generic data-access abstractions."""
@@ -0,0 +1,51 @@
1
+ """Generic async CRUD repository using SQLAlchemy expressions."""
2
+
3
+ from typing import Generic, TypeVar
4
+ from uuid import UUID
5
+
6
+ from sqlalchemy import select
7
+ from sqlalchemy.ext.asyncio import AsyncSession
8
+
9
+ from app.db.base import Base
10
+
11
+ ModelT = TypeVar("ModelT", bound=Base)
12
+
13
+
14
+ class BaseRepository(Generic[ModelT]):
15
+ """Provide safe parameterized lookup and paginated listing primitives."""
16
+
17
+ def __init__(self, session: AsyncSession, model: type[ModelT]):
18
+ self.session, self.model = session, model
19
+
20
+ async def get(self, item_id: UUID) -> ModelT | None:
21
+ """Fetch by primary key using SQLAlchemy's bound parameters."""
22
+ return await self.session.get(self.model, item_id)
23
+
24
+ async def get_by_id(self, item_id: UUID) -> ModelT | None:
25
+ """Alias with an explicit name for service and endpoint callers."""
26
+ return await self.get(item_id)
27
+
28
+ async def create(self, instance: ModelT) -> ModelT:
29
+ """Persist, commit, and refresh a new model instance."""
30
+ self.session.add(instance)
31
+ await self.session.commit()
32
+ await self.session.refresh(instance)
33
+ return instance
34
+
35
+ async def update(self, instance: ModelT, values: dict[str, object]) -> ModelT:
36
+ """Apply a whitelist prepared by the caller and persist the model."""
37
+ for field, value in values.items():
38
+ setattr(instance, field, value)
39
+ await self.session.commit()
40
+ await self.session.refresh(instance)
41
+ return instance
42
+
43
+ async def delete(self, instance: ModelT) -> None:
44
+ """Delete an instance and commit the transaction."""
45
+ await self.session.delete(instance)
46
+ await self.session.commit()
47
+
48
+ async def list(self, offset: int = 0, limit: int = 50) -> list[ModelT]:
49
+ """Return a bounded page, preventing unbounded database reads."""
50
+ result = await self.session.scalars(select(self.model).offset(offset).limit(min(limit, 100)))
51
+ return list(result)
@@ -0,0 +1,37 @@
1
+ """Email verification token query boundary."""
2
+
3
+ from datetime import UTC, datetime
4
+
5
+ from sqlalchemy import select
6
+ from sqlalchemy.ext.asyncio import AsyncSession
7
+
8
+ from app.models.domain.email_verification import EmailVerification
9
+
10
+
11
+ async def save(session: AsyncSession, token: EmailVerification) -> EmailVerification:
12
+ """Persist a pre-hashed verification token."""
13
+ session.add(token)
14
+ await session.commit()
15
+ return token
16
+
17
+
18
+ async def create(session: AsyncSession, token: EmailVerification) -> EmailVerification:
19
+ """Persist a verification token using the repository naming convention."""
20
+ return await save(session, token)
21
+
22
+
23
+ async def get_by_token(session: AsyncSession, token_hash: str) -> EmailVerification | None:
24
+ """Find an unused and unexpired verification token by digest."""
25
+ return await session.scalar(
26
+ select(EmailVerification).where(
27
+ EmailVerification.token_hash == token_hash,
28
+ EmailVerification.used_at.is_(None),
29
+ EmailVerification.expires_at > datetime.now(UTC),
30
+ )
31
+ )
32
+
33
+
34
+ async def mark_used(session: AsyncSession, token: EmailVerification) -> None:
35
+ """Mark a verification token as consumed."""
36
+ token.used_at = datetime.now(UTC)
37
+ await session.commit()
@@ -0,0 +1,33 @@
1
+ """Password-reset token persistence and single-use lookup operations."""
2
+
3
+ from datetime import UTC, datetime
4
+
5
+ from sqlalchemy import select
6
+ from sqlalchemy.ext.asyncio import AsyncSession
7
+
8
+ from app.models.domain.password_reset import PasswordReset
9
+
10
+
11
+ async def create(session: AsyncSession, token: PasswordReset) -> PasswordReset:
12
+ """Persist a hashed, expiring reset token."""
13
+ session.add(token)
14
+ await session.commit()
15
+ await session.refresh(token)
16
+ return token
17
+
18
+
19
+ async def get_by_token(session: AsyncSession, token_hash: str) -> PasswordReset | None:
20
+ """Find an unused and unexpired reset token by digest."""
21
+ return await session.scalar(
22
+ select(PasswordReset).where(
23
+ PasswordReset.token_hash == token_hash,
24
+ PasswordReset.used_at.is_(None),
25
+ PasswordReset.expires_at > datetime.now(UTC),
26
+ )
27
+ )
28
+
29
+
30
+ async def mark_used(session: AsyncSession, token: PasswordReset) -> None:
31
+ """Mark a reset token unusable after a successful password change."""
32
+ token.used_at = datetime.now(UTC)
33
+ await session.commit()
@@ -0,0 +1,40 @@
1
+ """Product-specific query repository."""
2
+
3
+ from decimal import Decimal
4
+
5
+ from sqlalchemy import select
6
+
7
+ from app.models.domain.product import Product
8
+ from app.repositories.base.base_repository import BaseRepository
9
+
10
+
11
+ class ProductRepository(BaseRepository[Product]):
12
+ """Repository configured for product queries."""
13
+
14
+ def __init__(self, session):
15
+ super().__init__(session, Product)
16
+
17
+ async def get_all(
18
+ self,
19
+ offset: int = 0,
20
+ limit: int = 50,
21
+ category: str | None = None,
22
+ min_price: Decimal | None = None,
23
+ max_price: Decimal | None = None,
24
+ sort_by: str = "created_at",
25
+ ) -> list[Product]:
26
+ """Return a bounded, filtered, allowlisted-sorted product page."""
27
+ columns = {"name": Product.name, "price": Product.price, "created_at": Product.created_at}
28
+ query = select(Product)
29
+ if category:
30
+ query = query.where(Product.category == category)
31
+ if min_price is not None:
32
+ query = query.where(Product.price >= min_price)
33
+ if max_price is not None:
34
+ query = query.where(Product.price <= max_price)
35
+ query = query.order_by(columns.get(sort_by, Product.created_at)).offset(offset).limit(min(limit, 100))
36
+ return list(await self.session.scalars(query))
37
+
38
+ async def get_by_id(self, product_id):
39
+ """Return a product by UUID."""
40
+ return await self.get(product_id)
@@ -0,0 +1,40 @@
1
+ """Refresh-token persistence and revocation queries."""
2
+
3
+ from datetime import UTC, datetime
4
+
5
+ from sqlalchemy import delete, select
6
+ from sqlalchemy.ext.asyncio import AsyncSession
7
+
8
+ from app.models.domain.refresh_token import RefreshToken
9
+
10
+
11
+ async def find_active(session: AsyncSession, token_hash: str) -> RefreshToken | None:
12
+ """Find a non-revoked, non-expired hashed token."""
13
+ return await session.scalar(
14
+ select(RefreshToken).where(
15
+ RefreshToken.token_hash == token_hash,
16
+ RefreshToken.revoked_at.is_(None),
17
+ RefreshToken.expires_at > datetime.now(UTC),
18
+ )
19
+ )
20
+
21
+
22
+ async def create(session: AsyncSession, token: RefreshToken) -> RefreshToken:
23
+ """Persist a hashed refresh token and its expiry."""
24
+ session.add(token)
25
+ await session.commit()
26
+ await session.refresh(token)
27
+ return token
28
+
29
+
30
+ async def revoke(session: AsyncSession, token: RefreshToken) -> None:
31
+ """Revoke a refresh token without deleting its audit record."""
32
+ token.revoked_at = datetime.now(UTC)
33
+ await session.commit()
34
+
35
+
36
+ async def delete_expired(session: AsyncSession) -> int:
37
+ """Delete expired refresh tokens and return the affected row count."""
38
+ result = await session.execute(delete(RefreshToken).where(RefreshToken.expires_at <= datetime.now(UTC)))
39
+ await session.commit()
40
+ return result.rowcount or 0
@@ -0,0 +1,23 @@
1
+ """Encrypted TOTP-secret persistence operations."""
2
+
3
+ from sqlalchemy.ext.asyncio import AsyncSession
4
+
5
+ from app.models.domain.totp_secret import TotpSecret
6
+
7
+
8
+ async def get(session: AsyncSession, user_id) -> TotpSecret | None:
9
+ """Load the encrypted secret belonging to a user."""
10
+ return await session.get(TotpSecret, user_id)
11
+
12
+
13
+ async def create_or_replace(session: AsyncSession, secret: TotpSecret) -> TotpSecret:
14
+ """Insert or replace a user's encrypted TOTP secret."""
15
+ existing = await session.get(TotpSecret, secret.user_id)
16
+ if existing is not None:
17
+ existing.secret = secret.secret
18
+ secret = existing
19
+ else:
20
+ session.add(secret)
21
+ await session.commit()
22
+ await session.refresh(secret)
23
+ return secret
@@ -0,0 +1,44 @@
1
+ """User-specific query repository."""
2
+
3
+ from sqlalchemy import select
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+
6
+ from app.models.domain.user import User
7
+
8
+
9
+ async def get_by_email(session: AsyncSession, email: str) -> User | None:
10
+ """Find a user by normalized email using a bound SQL expression."""
11
+ return await session.scalar(select(User).where(User.email == email.lower()))
12
+
13
+
14
+ async def get_by_id(session: AsyncSession, user_id) -> User | None:
15
+ """Return a user by UUID."""
16
+ return await session.get(User, user_id)
17
+
18
+
19
+ async def get_all(session: AsyncSession, offset: int = 0, limit: int = 50) -> list[User]:
20
+ """Return a bounded user page."""
21
+ return list(await session.scalars(select(User).offset(offset).limit(min(limit, 100))))
22
+
23
+
24
+ async def create(session: AsyncSession, user: User) -> User:
25
+ """Persist a user and refresh generated fields."""
26
+ session.add(user)
27
+ await session.commit()
28
+ await session.refresh(user)
29
+ return user
30
+
31
+
32
+ async def update(session: AsyncSession, user: User, values: dict[str, object]) -> User:
33
+ """Update approved user fields and commit."""
34
+ for field, value in values.items():
35
+ setattr(user, field, value)
36
+ await session.commit()
37
+ await session.refresh(user)
38
+ return user
39
+
40
+
41
+ async def delete(session: AsyncSession, user: User) -> None:
42
+ """Delete a user and dependent owned records."""
43
+ await session.delete(user)
44
+ await session.commit()
@@ -0,0 +1 @@
1
+ """Business services coordinate repositories and enforce domain rules."""
@@ -0,0 +1,32 @@
1
+ """Authentication orchestration and secure refresh-token rotation."""
2
+
3
+ import hashlib
4
+ import secrets
5
+ from datetime import UTC, datetime, timedelta
6
+ from uuid import UUID
7
+
8
+ from app.core.config.settings import get_settings
9
+ from app.core.security.jwt import create_token
10
+ from app.core.security.password import hash_password
11
+
12
+
13
+ def hash_refresh_token(token: str) -> str:
14
+ """Return the SHA-256 digest persisted for a raw refresh bearer token."""
15
+ return hashlib.sha256(token.encode("utf-8")).hexdigest()
16
+
17
+
18
+ def issue_tokens(user_id: UUID) -> tuple[str, str, str, datetime]:
19
+ """Issue short-lived access and opaque refresh credentials.
20
+
21
+ The returned digest is the only refresh value suitable for persistence.
22
+ """
23
+ settings = get_settings()
24
+ raw_refresh = secrets.token_urlsafe(48)
25
+ expiry = datetime.now(UTC) + timedelta(days=settings.refresh_token_days)
26
+ access = create_token(user_id, "access", timedelta(minutes=settings.access_token_minutes))
27
+ return access, raw_refresh, hash_refresh_token(raw_refresh), expiry
28
+
29
+
30
+ def issue_password_hash(password: str) -> str:
31
+ """Hash a password for reset and social-account provisioning workflows."""
32
+ return hash_password(password)
@@ -0,0 +1 @@
1
+ """Reusable service abstractions."""
@@ -0,0 +1,21 @@
1
+ """Generic service helpers that keep endpoint code thin."""
2
+
3
+ from typing import Generic, TypeVar
4
+ from uuid import UUID
5
+
6
+ from sqlalchemy.ext.asyncio import AsyncSession
7
+
8
+ from app.db.base import Base
9
+
10
+ ModelT = TypeVar("ModelT", bound=Base)
11
+
12
+
13
+ class BaseService(Generic[ModelT]):
14
+ """Provide common async lookup behavior for a model service."""
15
+
16
+ def __init__(self, model: type[ModelT], session: AsyncSession):
17
+ self.model, self.session = model, session
18
+
19
+ async def get(self, item_id: UUID) -> ModelT | None:
20
+ """Fetch one row by primary key, returning None when absent."""
21
+ return await self.session.get(self.model, item_id)
@@ -0,0 +1,32 @@
1
+ """SMTP email boundary with no credential or token logging."""
2
+
3
+ import smtplib
4
+ from email.message import EmailMessage
5
+
6
+ from app.core.config.settings import get_settings
7
+ from app.models.domain.user import User
8
+
9
+
10
+ def send_email(recipient: str, subject: str, body: str) -> None:
11
+ """Send a plain-text email through configured SMTP; replace with a provider SDK as needed."""
12
+ settings = get_settings()
13
+ if settings.environment == "development" and not settings.smtp_username:
14
+ return
15
+ message = EmailMessage()
16
+ message["From"], message["To"], message["Subject"] = settings.smtp_from, recipient, subject
17
+ message.set_content(body)
18
+ with smtplib.SMTP(settings.smtp_host, settings.smtp_port) as smtp:
19
+ smtp.starttls()
20
+ if settings.smtp_username:
21
+ smtp.login(settings.smtp_username, settings.smtp_password.get_secret_value())
22
+ smtp.send_message(message)
23
+
24
+
25
+ def send_verification_email(user: User, token: str) -> None:
26
+ """Send a verification link containing the one-time raw token."""
27
+ send_email(str(user.email), "Verify your email", f"Use this verification token: {token}")
28
+
29
+
30
+ def send_reset_email(user: User, token: str) -> None:
31
+ """Send a password-reset link containing the one-time raw token."""
32
+ send_email(str(user.email), "Reset your password", f"Use this password reset token: {token}")
@@ -0,0 +1,24 @@
1
+ """Product creation and query rules."""
2
+
3
+ from sqlalchemy.ext.asyncio import AsyncSession
4
+
5
+ from app.models.domain.product import Product
6
+ from app.models.schemas.product import ProductCreate
7
+
8
+
9
+ async def create_product(session: AsyncSession, payload: ProductCreate) -> Product:
10
+ """Create and return a product after transaction commit."""
11
+ product = Product(**payload.model_dump())
12
+ session.add(product)
13
+ await session.commit()
14
+ await session.refresh(product)
15
+ return product
16
+
17
+
18
+ async def update_product(session: AsyncSession, product: Product, payload: ProductCreate) -> Product:
19
+ """Replace editable product fields and persist the result."""
20
+ for field, value in payload.model_dump().items():
21
+ setattr(product, field, value)
22
+ await session.commit()
23
+ await session.refresh(product)
24
+ return product