Skip to content

Commit 455923d

Browse files
Merge pull request #27 from codewithme-py/feat/personal-accounts-and-integrations
feat: implement buyer and seller service layers, add product moderation comments, and refactor security authentication schemes
2 parents 7ec4175 + c324dac commit 455923d

24 files changed

Lines changed: 589 additions & 33 deletions

app/core/admin/admin.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,20 @@ def order_link_formatter(model: Any, name: Any) -> Any:
7575
return 'N/A'
7676
return Markup(f'<a href="/admin/order/details/{order_id}">{order_id}</a>')
7777

78+
@staticmethod
79+
def docs_link_formatter(model: Any, name: Any) -> Any:
80+
docs = getattr(model, name)
81+
if not docs:
82+
return 'No docs'
83+
links = []
84+
if isinstance(docs, dict):
85+
for doc_type, s3_key in docs.items():
86+
links.append(
87+
f'<a href="/api/v1/media/view?key={s3_key}" '
88+
f'target="_blank">{doc_type}</a>'
89+
)
90+
return Markup(', '.join(links))
91+
7892

7993
class VerificationRequestAdmin(ModelView, model=VerificationRequest):
8094
column_list = [
@@ -93,6 +107,7 @@ class VerificationRequestAdmin(ModelView, model=VerificationRequest):
93107
'user_id': AdminPanelFormatter.user_link_formatter,
94108
'target_role': AdminPanelFormatter.status_formatter,
95109
'status': AdminPanelFormatter.status_formatter,
110+
'docs_url': AdminPanelFormatter.docs_link_formatter,
96111
}
97112
column_formatters_detail = column_formatters
98113
name = 'Verification Request'
@@ -156,6 +171,7 @@ class ProductAdmin(ModelView, model=Product):
156171
Product.status,
157172
Product.owner_id,
158173
Product.moderator_id,
174+
Product.moderation_comment,
159175
]
160176
column_labels = {'qty_available': 'Quantity Available'}
161177
column_default_sort = [('created_at', True)]
@@ -165,11 +181,21 @@ class ProductAdmin(ModelView, model=Product):
165181
'moderator_id': AdminPanelFormatter.user_link_formatter,
166182
}
167183
column_formatters_detail = column_formatters
168-
column_searchable_list = [Product.id, Product.name, Product.description]
184+
column_searchable_list = [
185+
Product.id,
186+
Product.name,
187+
Product.description,
188+
Product.moderation_comment,
189+
]
169190
can_delete = False
170191
name = 'Product'
171192
name_plural = 'Products'
172193
icon = 'fa-solid fa-box'
194+
form_columns = [
195+
Product.status,
196+
Product.moderator_id,
197+
Product.moderation_comment,
198+
]
173199

174200

175201
class OrderAdmin(ModelView, model=Order):

app/core/auth_schemes.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from fastapi.security import APIKeyHeader, OAuth2PasswordBearer
2+
3+
oauth2_scheme = OAuth2PasswordBearer(tokenUrl='/api/v1/auth/token')
4+
header_scheme = APIKeyHeader(name='X-API-Key', auto_error=False)

app/core/hashing.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import asyncio
2+
3+
from passlib.context import CryptContext
4+
5+
pwd_context = CryptContext(schemes=['bcrypt'], deprecated='auto')
6+
7+
8+
def verify_password_sync(plain_password: str, hashed_password: str) -> bool:
9+
return bool(pwd_context.verify(plain_password, hashed_password))
10+
11+
12+
async def verify_password(plain_password: str, hashed_password: str) -> bool:
13+
return await asyncio.to_thread(
14+
verify_password_sync, plain_password, hashed_password
15+
)
16+
17+
18+
def get_password_hash_sync(password: str) -> str:
19+
return str(pwd_context.hash(password))
20+
21+
22+
async def get_password_hash(password: str) -> str:
23+
return await asyncio.to_thread(get_password_hash_sync, password)

app/core/security.py

Lines changed: 1 addition & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,16 @@
1-
import asyncio
21
from datetime import UTC, datetime, timedelta
32
from typing import Annotated, Any
43

54
from fastapi import Depends
6-
from fastapi.security import APIKeyHeader
75
from jose import jwt
8-
from passlib.context import CryptContext
96

7+
from app.core.auth_schemes import header_scheme
108
from app.core.config import settings
119
from app.core.database import SessionDep
1210
from app.core.exceptions import CredentialsError, PermissionDeniedError
1311
from app.services.user.models import User, UserRole
1412
from app.shared.deps import get_current_user
1513

16-
pwd_context = CryptContext(schemes=['bcrypt'], deprecated='auto')
17-
header_scheme = APIKeyHeader(name='X-API-Key', auto_error=False)
18-
1914

2015
async def get_b2b_partner_by_api_key(
2116
api_key: Annotated[str | None, Depends(header_scheme)], session: SessionDep
@@ -35,24 +30,6 @@ async def get_b2b_partner_by_api_key(
3530
return user
3631

3732

38-
def verify_password_sync(plain_password: str, hashed_password: str) -> bool:
39-
return bool(pwd_context.verify(plain_password, hashed_password))
40-
41-
42-
async def verify_password(plain_password: str, hashed_password: str) -> bool:
43-
return await asyncio.to_thread(
44-
verify_password_sync, plain_password, hashed_password
45-
)
46-
47-
48-
def get_password_hash_sync(password: str) -> str:
49-
return str(pwd_context.hash(password))
50-
51-
52-
async def get_password_hash(password: str) -> str:
53-
return await asyncio.to_thread(get_password_hash_sync, password)
54-
55-
5633
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
5734
to_encode = data.copy()
5835
if expires_delta:

app/main.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,12 @@
1919
from app.core.lua_scripts import RATE_LIMIT_LUA_SCRIPT
2020
from app.core.s3 import init_s3_bucket
2121
from app.core.setup import setup_exception_handlers
22+
from app.services.buyer_user.routes import router_v1 as buyer_user_router_v1
23+
from app.services.external.routes import router_v1 as external_router_v1
2224
from app.services.inventory.routes import router_v1 as inventory_router_v1
2325
from app.services.media.routes import router_v1 as media_router_v1
2426
from app.services.orders.routes import router_v1 as order_router_v1
27+
from app.services.seller_user.routes import router_v1 as seller_user_router_v1
2528
from app.services.user.routes import router_v1 as user_router_v1
2629

2730
setup_logging()
@@ -88,6 +91,9 @@ async def add_request_context(
8891
app.include_router(order_router_v1, prefix='/api/v1', tags=['Orders'])
8992
app.include_router(inventory_router_v1, prefix='/api/v1', tags=['Inventory'])
9093
app.include_router(media_router_v1, prefix='/api/v1', tags=['Media'])
94+
app.include_router(seller_user_router_v1, prefix='/api/v1', tags=['Seller Dashboard'])
95+
app.include_router(buyer_user_router_v1, prefix='/api/v1', tags=['Buyer Dashboard'])
96+
app.include_router(external_router_v1, prefix='/api/v1', tags=['Partner API'])
9197

9298

9399
@app.get('/health')

app/services/buyer_user/__init__.py

Whitespace-only changes.

app/services/buyer_user/routes.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from fastapi import APIRouter, Depends, Query
2+
from sqlalchemy.ext.asyncio import AsyncSession
3+
4+
from app.core.database import get_session
5+
from app.core.security import RoleChecker
6+
from app.services.buyer_user.schemas import (
7+
BuyerOrderRead,
8+
BuyerStats,
9+
)
10+
from app.services.buyer_user.service import (
11+
get_my_orders,
12+
get_my_stats,
13+
)
14+
from app.services.orders.models import OrderStatus
15+
from app.services.user.models import User, UserRole
16+
17+
router_v1 = APIRouter(prefix='/buyer_user', tags=['Buyer Dashboard'])
18+
19+
BUYER_DEPENDENCY = Depends(
20+
RoleChecker(allowed_roles=[UserRole.USER, UserRole.USER_B2B])
21+
)
22+
23+
24+
@router_v1.get('/stats', response_model=BuyerStats)
25+
async def fetch_my_stats(
26+
session: AsyncSession = Depends(get_session),
27+
current_user: User = BUYER_DEPENDENCY,
28+
) -> BuyerStats:
29+
return await get_my_stats(session, current_user.id)
30+
31+
32+
@router_v1.get('/orders', response_model=list[BuyerOrderRead])
33+
async def fetch_my_orders(
34+
status: OrderStatus | None = Query(None),
35+
session: AsyncSession = Depends(get_session),
36+
current_user: User = BUYER_DEPENDENCY,
37+
) -> list[BuyerOrderRead]:
38+
orders = await get_my_orders(session, current_user.id, status)
39+
return [BuyerOrderRead.model_validate(o) for o in orders]

app/services/buyer_user/schemas.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from datetime import datetime
2+
from decimal import Decimal
3+
from uuid import UUID
4+
5+
from pydantic import BaseModel, ConfigDict
6+
7+
8+
class BuyerStats(BaseModel):
9+
total_orders: int
10+
pending_orders: int
11+
paid_orders: int
12+
shipped_orders: int
13+
14+
15+
class BuyerOrderItemRead(BaseModel):
16+
model_config = ConfigDict(from_attributes=True)
17+
id: UUID
18+
product_id: UUID
19+
product_name: str
20+
quantity: int
21+
price: Decimal
22+
23+
24+
class BuyerOrderRead(BaseModel):
25+
model_config = ConfigDict(from_attributes=True)
26+
id: UUID
27+
status: str
28+
total_amount: Decimal
29+
shipping_address: str | None = None
30+
created_at: datetime
31+
items: list[BuyerOrderItemRead]

app/services/buyer_user/service.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from collections.abc import Sequence
2+
from uuid import UUID
3+
4+
from sqlalchemy import func, select
5+
from sqlalchemy.ext.asyncio import AsyncSession
6+
from sqlalchemy.orm import selectinload
7+
8+
from app.services.buyer_user.schemas import BuyerStats
9+
from app.services.orders.models import Order, OrderStatus
10+
11+
12+
async def get_my_orders(
13+
session: AsyncSession,
14+
user_id: UUID,
15+
status: OrderStatus | None = None,
16+
) -> Sequence[Order]:
17+
stmt = (
18+
select(Order)
19+
.where(Order.user_id == user_id)
20+
.options(selectinload(Order.items))
21+
.order_by(Order.created_at.desc())
22+
)
23+
if status:
24+
stmt = stmt.where(Order.status == status)
25+
result = await session.execute(stmt)
26+
return result.scalars().all()
27+
28+
29+
async def get_my_stats(
30+
session: AsyncSession,
31+
user_id: UUID,
32+
) -> BuyerStats:
33+
stmt = (
34+
select(Order.status, func.count(Order.id))
35+
.where(Order.user_id == user_id)
36+
.group_by(Order.status)
37+
)
38+
result = await session.execute(stmt)
39+
counts: dict[OrderStatus, int] = {row[0]: row[1] for row in result.all()}
40+
41+
return BuyerStats(
42+
total_orders=sum(counts.values()),
43+
pending_orders=counts.get(OrderStatus.PENDING, 0),
44+
paid_orders=counts.get(OrderStatus.PAID, 0),
45+
shipped_orders=counts.get(OrderStatus.SHIPPED, 0),
46+
)

app/services/external/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)