Skip to content

Commit 7ec4175

Browse files
Merge pull request #26 from codewithme-py/feat/sqla-admin-tools-and-analytics
feat: Implement user verification requests with dedicated database schema and admin panel management.
2 parents 5a1b3a3 + 3c463a9 commit 7ec4175

15 files changed

Lines changed: 601 additions & 10 deletions

app/core/admin/admin.py

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
from typing import Any
2+
3+
from markupsafe import Markup
4+
from sqladmin import ModelView
5+
from sqlalchemy import select
6+
from starlette.requests import Request
7+
8+
from app.core.audit_log.models import AuditLog
9+
from app.core.database import async_session_factory
10+
from app.services.inventory.models import Product, Reservation
11+
from app.services.orders.models import Order
12+
from app.services.user.models import (
13+
APIKeyB2BPartner,
14+
User,
15+
UserRole,
16+
VerificationRequest,
17+
VerificationStatus,
18+
)
19+
20+
21+
class AdminAccessMixin(ModelView):
22+
def is_accessible(self, request: Request) -> bool:
23+
user = getattr(request.state, 'user', None)
24+
return user is not None and user.role == UserRole.ADMIN
25+
26+
def is_visible(self, request: Request) -> bool:
27+
user = getattr(request.state, 'user', None)
28+
return user is not None and user.role == UserRole.ADMIN
29+
30+
31+
class AdminPanelFormatter:
32+
@staticmethod
33+
def status_formatter(model: Any, name: Any) -> Any:
34+
status_colors = {
35+
'ACTIVE': 'success',
36+
'PENDING': 'warning',
37+
'REJECTED': 'danger',
38+
'PENDING_MODERATION': 'info',
39+
'PAID': 'success',
40+
'SHIPPED': 'info',
41+
'COMPLETED': 'success',
42+
'CANCELLED': 'danger',
43+
'FAILED': 'danger',
44+
'EXPIRED': 'secondary',
45+
'DRAFT': 'secondary',
46+
'MODERATION_IN_PROGRESS': 'info',
47+
'APPROVED': 'success',
48+
'SELLER_B2B': 'primary',
49+
'USER_B2B': 'secondary',
50+
}
51+
if name in ('status', 'target_role', 'role'):
52+
value = getattr(model, name)
53+
color = status_colors.get(value, 'secondary')
54+
return Markup(f'<span class="badge bg-{color}">{value}</span>')
55+
return getattr(model, name)
56+
57+
@staticmethod
58+
def user_link_formatter(model: Any, name: Any) -> Any:
59+
user_id = getattr(model, name)
60+
if not user_id:
61+
return 'N/A'
62+
return Markup(f'<a href="/admin/user/details/{user_id}">{user_id}</a>')
63+
64+
@staticmethod
65+
def product_link_formatter(model: Any, name: Any) -> Any:
66+
product_id = getattr(model, name)
67+
if not product_id:
68+
return 'N/A'
69+
return Markup(f'<a href="/admin/product/details/{product_id}">{product_id}</a>')
70+
71+
@staticmethod
72+
def order_link_formatter(model: Any, name: Any) -> Any:
73+
order_id = getattr(model, name)
74+
if not order_id:
75+
return 'N/A'
76+
return Markup(f'<a href="/admin/order/details/{order_id}">{order_id}</a>')
77+
78+
79+
class VerificationRequestAdmin(ModelView, model=VerificationRequest):
80+
column_list = [
81+
VerificationRequest.id,
82+
VerificationRequest.user_id,
83+
VerificationRequest.target_role,
84+
VerificationRequest.status,
85+
VerificationRequest.docs_url,
86+
VerificationRequest.admin_feedback,
87+
VerificationRequest.created_at,
88+
VerificationRequest.updated_at,
89+
]
90+
column_searchable_list = column_list
91+
column_default_sort = [('created_at', True)]
92+
column_formatters = {
93+
'user_id': AdminPanelFormatter.user_link_formatter,
94+
'target_role': AdminPanelFormatter.status_formatter,
95+
'status': AdminPanelFormatter.status_formatter,
96+
}
97+
column_formatters_detail = column_formatters
98+
name = 'Verification Request'
99+
name_plural = 'Verification Requests'
100+
icon = 'fa-solid fa-user-check'
101+
can_delete = False
102+
can_create = False
103+
can_edit = True
104+
form_columns = [VerificationRequest.status, VerificationRequest.admin_feedback]
105+
106+
async def on_model_change(
107+
self, data: dict, model: Any, is_created: bool, request: Request
108+
) -> None:
109+
if not is_created and data.get('status') == VerificationStatus.APPROVED:
110+
async with async_session_factory() as session:
111+
result = await session.execute(
112+
select(User).where(User.id == model.user_id)
113+
)
114+
user = result.scalar_one()
115+
user.role = model.target_role
116+
user.is_verified = True
117+
await session.commit()
118+
119+
120+
class UserAdmin(ModelView, model=User):
121+
column_list = [User.id, User.email, User.role, User.is_active]
122+
column_searchable_list = [User.id, User.email]
123+
can_delete = False
124+
name = 'User'
125+
name_plural = 'Users'
126+
icon = 'fa-solid fa-user'
127+
column_default_sort = [('created_at', True)]
128+
129+
def is_accessible(self, request: Request) -> bool:
130+
user = getattr(request.state, 'user', None)
131+
if not user:
132+
return False
133+
134+
if user.role == UserRole.MODERATOR:
135+
endpoint = request.scope.get('endpoint')
136+
endpoint_name = getattr(endpoint, '__name__', '')
137+
route = request.scope.get('route')
138+
route_name = getattr(route, 'name', '')
139+
path = request.url.path
140+
forbidden = {'create', 'edit', 'delete'}
141+
if (
142+
endpoint_name in forbidden
143+
or route_name in forbidden
144+
or any(f'/{x}/' in path for x in forbidden)
145+
):
146+
return False
147+
return user.role in (UserRole.ADMIN, UserRole.MODERATOR)
148+
149+
150+
class ProductAdmin(ModelView, model=Product):
151+
column_list = [
152+
Product.id,
153+
Product.name,
154+
Product.price,
155+
Product.qty_available,
156+
Product.status,
157+
Product.owner_id,
158+
Product.moderator_id,
159+
]
160+
column_labels = {'qty_available': 'Quantity Available'}
161+
column_default_sort = [('created_at', True)]
162+
column_formatters = {
163+
'status': AdminPanelFormatter.status_formatter,
164+
'owner_id': AdminPanelFormatter.user_link_formatter,
165+
'moderator_id': AdminPanelFormatter.user_link_formatter,
166+
}
167+
column_formatters_detail = column_formatters
168+
column_searchable_list = [Product.id, Product.name, Product.description]
169+
can_delete = False
170+
name = 'Product'
171+
name_plural = 'Products'
172+
icon = 'fa-solid fa-box'
173+
174+
175+
class OrderAdmin(ModelView, model=Order):
176+
column_list = [
177+
Order.id,
178+
Order.user_id,
179+
Order.status,
180+
Order.total_amount,
181+
Order.created_at,
182+
]
183+
column_searchable_list = [Order.id, Order.user_id]
184+
can_delete = False
185+
name = 'Order'
186+
name_plural = 'Orders'
187+
icon = 'fa-solid fa-receipt'
188+
column_formatters = {
189+
'status': AdminPanelFormatter.status_formatter,
190+
'user_id': AdminPanelFormatter.user_link_formatter,
191+
}
192+
column_formatters_detail = column_formatters
193+
column_default_sort = [('created_at', True)]
194+
195+
196+
class ReservationAdmin(ModelView, model=Reservation):
197+
column_list = [
198+
Reservation.id,
199+
Reservation.product_id,
200+
Reservation.user_id,
201+
Reservation.qty_reserved,
202+
Reservation.status,
203+
Reservation.created_at,
204+
Reservation.expires_at,
205+
]
206+
column_searchable_list = [Reservation.id, Reservation.user_id]
207+
can_delete = False
208+
name = 'Reservation'
209+
name_plural = 'Reservations'
210+
icon = 'fa-solid fa-cart-arrow-down'
211+
column_formatters = {
212+
'user_id': AdminPanelFormatter.user_link_formatter,
213+
'product_id': AdminPanelFormatter.product_link_formatter,
214+
'status': AdminPanelFormatter.status_formatter,
215+
'order_id': AdminPanelFormatter.order_link_formatter,
216+
}
217+
column_formatters_detail = column_formatters
218+
column_default_sort = [('created_at', True)]
219+
column_labels = {'qty_reserved': 'Quantity reserved'}
220+
221+
222+
class AuditLogAdmin(AdminAccessMixin, model=AuditLog):
223+
column_list = [
224+
AuditLog.id,
225+
AuditLog.target_type,
226+
AuditLog.target_id,
227+
AuditLog.actor_id,
228+
AuditLog.action,
229+
]
230+
column_searchable_list = [AuditLog.target_type, AuditLog.target_id, AuditLog.action]
231+
can_create = False
232+
can_edit = False
233+
can_delete = False
234+
name = 'Audit Log'
235+
name_plural = 'Audit Logs'
236+
icon = 'fa-solid fa-clipboard-list'
237+
column_default_sort = [('created_at', True)]
238+
column_formatters = {
239+
'actor_id': AdminPanelFormatter.user_link_formatter,
240+
'target_id': AdminPanelFormatter.order_link_formatter,
241+
}
242+
column_formatters_detail = column_formatters
243+
244+
245+
class APIKeyB2BPartnerAdmin(AdminAccessMixin, model=APIKeyB2BPartner):
246+
column_list = [
247+
APIKeyB2BPartner.id,
248+
APIKeyB2BPartner.user_id,
249+
APIKeyB2BPartner.name,
250+
APIKeyB2BPartner.key_prefix,
251+
APIKeyB2BPartner.is_active,
252+
]
253+
column_searchable_list = [APIKeyB2BPartner.name, APIKeyB2BPartner.key_prefix]
254+
can_delete = False
255+
name = 'API Key'
256+
name_plural = 'API Keys'
257+
icon = 'fa-solid fa-key'
258+
column_default_sort = [('created_at', True)]
259+
column_formatters = {
260+
'user_id': AdminPanelFormatter.user_link_formatter,
261+
}
262+
column_formatters_detail = column_formatters
263+
264+
265+
def register_admin_views(admin: Any) -> None:
266+
admin.add_view(UserAdmin)
267+
admin.add_view(ProductAdmin)
268+
admin.add_view(OrderAdmin)
269+
admin.add_view(ReservationAdmin)
270+
admin.add_view(AuditLogAdmin)
271+
admin.add_view(APIKeyB2BPartnerAdmin)
272+
admin.add_view(VerificationRequestAdmin)

app/core/admin/admin_auth.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
from sqladmin.authentication import AuthenticationBackend
2+
from sqlalchemy import select
3+
from starlette.requests import Request
4+
5+
from app.core.config import settings
6+
from app.core.database import async_session_factory
7+
from app.core.exceptions import PermissionDeniedError
8+
from app.core.security import check_permission
9+
from app.services.user.models import User, UserRole
10+
from app.services.user.service import UserService
11+
12+
13+
class AdminAuth(AuthenticationBackend):
14+
async def login(self, request: Request) -> bool:
15+
user_data = await request.form()
16+
email = user_data.get('username')
17+
password = user_data.get('password')
18+
19+
if not isinstance(email, str) or not isinstance(password, str):
20+
return False
21+
22+
async with async_session_factory() as session:
23+
user = await UserService.authenticate_user(session, email, password)
24+
if user is None:
25+
return False
26+
27+
try:
28+
await check_permission(user, [UserRole.ADMIN, UserRole.MODERATOR])
29+
request.session['token'] = str(user.id)
30+
return True
31+
except PermissionDeniedError:
32+
return False
33+
34+
async def logout(self, request: Request) -> bool:
35+
request.session.clear()
36+
return True
37+
38+
async def authenticate(self, request: Request) -> bool:
39+
token = request.session.get('token')
40+
if not token:
41+
return False
42+
43+
async with async_session_factory() as session:
44+
result = await session.execute(select(User).where(User.id == token))
45+
user = result.scalar_one_or_none()
46+
if not user or user.role not in (UserRole.ADMIN, UserRole.MODERATOR):
47+
return False
48+
request.state.user = user
49+
return True
50+
51+
52+
authentication_backend = AdminAuth(secret_key=settings.secret_key)

app/core/exception_handlers.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
NotFoundError,
99
PermissionDeniedError,
1010
UserAlreadyExists,
11+
VerificationRequestAlreadyExists,
1112
)
1213

1314
EXISTING_USER_MESSAGE = 'User already exists'
@@ -66,3 +67,12 @@ async def permission_denied_handler(
6667
status_code=status.HTTP_403_FORBIDDEN,
6768
content={'detail': str(exc) or 'Permission denied'},
6869
)
70+
71+
72+
async def verification_request_already_exists_handler(
73+
request: Request, exc: VerificationRequestAlreadyExists
74+
) -> JSONResponse:
75+
return JSONResponse(
76+
status_code=status.HTTP_400_BAD_REQUEST,
77+
content={'detail': str(exc) or 'Verification request already exists'},
78+
)

app/core/exceptions.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,10 @@ class PermissionDeniedError(AppError):
4444

4545
def __init__(self, message: str = 'Permission denied'):
4646
super().__init__(message=message)
47+
48+
49+
class VerificationRequestAlreadyExists(AppError):
50+
"""Verification request already exists."""
51+
52+
def __init__(self, message: str = 'Verification request already exists'):
53+
super().__init__(message=message)

app/core/setup.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
not_found_error_handler,
88
permission_denied_handler,
99
user_already_exists_handler,
10+
verification_request_already_exists_handler,
1011
)
1112
from .exceptions import (
1213
ConflictError,
@@ -15,6 +16,7 @@
1516
NotFoundError,
1617
PermissionDeniedError,
1718
UserAlreadyExists,
19+
VerificationRequestAlreadyExists,
1820
)
1921

2022

@@ -31,3 +33,7 @@ def setup_exception_handlers(app: FastAPI) -> None:
3133
PermissionDeniedError,
3234
permission_denied_handler, # type: ignore[arg-type]
3335
)
36+
app.add_exception_handler(
37+
VerificationRequestAlreadyExists,
38+
verification_request_already_exists_handler, # type: ignore[arg-type]
39+
)

0 commit comments

Comments
 (0)