mirror of
http://88.130.71.182:3000/BlitTech/badoHair_be.git
synced 2026-06-12 23:23:22 +00:00
Initial Commit
This commit is contained in:
0
app/routers/__init__.py
Normal file
0
app/routers/__init__.py
Normal file
0
app/routers/admin/__init__.py
Normal file
0
app/routers/admin/__init__.py
Normal file
179
app/routers/admin/bookings.py
Normal file
179
app/routers/admin/bookings.py
Normal file
@@ -0,0 +1,179 @@
|
||||
from datetime import date
|
||||
from typing import Annotated
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
import asyncpg
|
||||
|
||||
from app.core.pagination import pagination_params
|
||||
from app.core.responses import ok, paginated
|
||||
from app.dependencies import get_db, require_admin
|
||||
from app.models.bookings import (
|
||||
UpdateBookingStatus, SlotCreate, WeeklyScheduleCreate,
|
||||
GenerateSlotsRequest, BlockedDateCreate, UpdateSlotRequest,
|
||||
)
|
||||
from app.services import booking_service, slot_service
|
||||
|
||||
router = APIRouter(tags=["Admin — Bookings"])
|
||||
|
||||
|
||||
# ── Bookings ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/bookings")
|
||||
async def list_bookings(
|
||||
pagination: Annotated[tuple, Depends(pagination_params)],
|
||||
status: str | None = Query(None),
|
||||
from_date: date | None = Query(None),
|
||||
to_date: date | None = Query(None),
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
_: dict = Depends(require_admin),
|
||||
):
|
||||
page, per_page, offset = pagination
|
||||
bookings, total = await booking_service.list_bookings(
|
||||
db, page, per_page, offset,
|
||||
status=status, from_date=from_date, to_date=to_date,
|
||||
)
|
||||
return paginated(bookings, total, page, per_page)
|
||||
|
||||
|
||||
@router.get("/bookings/{booking_id}")
|
||||
async def get_booking(
|
||||
booking_id: str,
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
_: dict = Depends(require_admin),
|
||||
):
|
||||
booking = await booking_service.get_booking(db, booking_id)
|
||||
return ok(booking)
|
||||
|
||||
|
||||
@router.patch("/bookings/{booking_id}")
|
||||
async def update_booking(
|
||||
booking_id: str,
|
||||
body: UpdateBookingStatus,
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
admin: dict = Depends(require_admin),
|
||||
):
|
||||
booking = await booking_service.admin_update_booking(
|
||||
db, booking_id, body.status, body.admin_notes, str(admin["id"])
|
||||
)
|
||||
return ok(booking)
|
||||
|
||||
|
||||
@router.delete("/bookings/{booking_id}", status_code=204)
|
||||
async def delete_booking(
|
||||
booking_id: str,
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
_: dict = Depends(require_admin),
|
||||
):
|
||||
await booking_service.admin_delete_booking(db, booking_id)
|
||||
|
||||
|
||||
# ── Slots ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/slots")
|
||||
async def list_slots(
|
||||
from_date: date = Query(...),
|
||||
to_date: date = Query(...),
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
_: dict = Depends(require_admin),
|
||||
):
|
||||
slots = await slot_service.list_slots(db, from_date, to_date, available_only=False)
|
||||
return ok(slots)
|
||||
|
||||
|
||||
@router.post("/slots", status_code=201)
|
||||
async def create_slot(
|
||||
body: SlotCreate,
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
_: dict = Depends(require_admin),
|
||||
):
|
||||
slot = await slot_service.create_slot(db, body)
|
||||
return ok(slot)
|
||||
|
||||
|
||||
@router.patch("/slots/{slot_id}")
|
||||
async def update_slot(
|
||||
slot_id: str,
|
||||
body: UpdateSlotRequest,
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
_: dict = Depends(require_admin),
|
||||
):
|
||||
slot = await slot_service.update_slot(db, slot_id, body.is_blocked, body.block_reason)
|
||||
return ok(slot)
|
||||
|
||||
|
||||
@router.delete("/slots/{slot_id}", status_code=204)
|
||||
async def delete_slot(
|
||||
slot_id: str,
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
_: dict = Depends(require_admin),
|
||||
):
|
||||
await slot_service.delete_slot(db, slot_id)
|
||||
|
||||
|
||||
@router.post("/slots/generate")
|
||||
async def generate_slots(
|
||||
body: GenerateSlotsRequest,
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
_: dict = Depends(require_admin),
|
||||
):
|
||||
created = await slot_service.generate_slots(db, body.from_date, body.to_date)
|
||||
return ok({"created": created})
|
||||
|
||||
|
||||
# ── Weekly schedule ───────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/schedule")
|
||||
async def get_schedule(
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
_: dict = Depends(require_admin),
|
||||
):
|
||||
schedule = await slot_service.get_schedule(db)
|
||||
return ok(schedule)
|
||||
|
||||
|
||||
@router.post("/schedule", status_code=201)
|
||||
async def create_schedule(
|
||||
body: WeeklyScheduleCreate,
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
_: dict = Depends(require_admin),
|
||||
):
|
||||
entry = await slot_service.create_schedule_entry(db, body)
|
||||
return ok(entry)
|
||||
|
||||
|
||||
@router.delete("/schedule/{schedule_id}", status_code=204)
|
||||
async def delete_schedule(
|
||||
schedule_id: str,
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
_: dict = Depends(require_admin),
|
||||
):
|
||||
await slot_service.delete_schedule_entry(db, schedule_id)
|
||||
|
||||
|
||||
# ── Blocked dates ─────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/blocked-dates")
|
||||
async def get_blocked_dates(
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
_: dict = Depends(require_admin),
|
||||
):
|
||||
dates = await slot_service.get_blocked_dates(db)
|
||||
return ok(dates)
|
||||
|
||||
|
||||
@router.post("/blocked-dates", status_code=201)
|
||||
async def add_blocked_date(
|
||||
body: BlockedDateCreate,
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
_: dict = Depends(require_admin),
|
||||
):
|
||||
entry = await slot_service.add_blocked_date(db, body.date, body.reason)
|
||||
return ok(entry)
|
||||
|
||||
|
||||
@router.delete("/blocked-dates/{blocked_date_id}", status_code=204)
|
||||
async def remove_blocked_date(
|
||||
blocked_date_id: str,
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
_: dict = Depends(require_admin),
|
||||
):
|
||||
await slot_service.remove_blocked_date(db, blocked_date_id)
|
||||
147
app/routers/admin/customers.py
Normal file
147
app/routers/admin/customers.py
Normal file
@@ -0,0 +1,147 @@
|
||||
import csv
|
||||
import io
|
||||
from typing import Annotated
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
import asyncpg
|
||||
|
||||
from app.core.pagination import pagination_params
|
||||
from app.core.responses import ok, paginated
|
||||
from app.dependencies import get_db, require_admin
|
||||
from app.exceptions import NotFoundError
|
||||
from app.models.admin import UpdateCustomer
|
||||
|
||||
router = APIRouter(prefix="/customers", tags=["Admin — Customers"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_customers(
|
||||
pagination: Annotated[tuple, Depends(pagination_params)],
|
||||
search: str | None = Query(None),
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
_: dict = Depends(require_admin),
|
||||
):
|
||||
page, per_page, offset = pagination
|
||||
conditions = ["role = 'client'"]
|
||||
params: list = []
|
||||
|
||||
if search:
|
||||
params.append(f"%{search}%")
|
||||
conditions.append(f"(email ILIKE ${len(params)} OR full_name ILIKE ${len(params)})")
|
||||
|
||||
where = f"WHERE {' AND '.join(conditions)}"
|
||||
total = await db.fetchval(f"SELECT COUNT(*) FROM profiles {where}", *params)
|
||||
|
||||
params.extend([per_page, offset])
|
||||
rows = await db.fetch(
|
||||
f"""
|
||||
SELECT
|
||||
p.id, p.email, p.full_name, p.phone, p.is_blocked, p.created_at,
|
||||
COUNT(DISTINCT o.id) AS orders_count,
|
||||
COUNT(DISTINCT b.id) AS bookings_count,
|
||||
COALESCE(SUM(o.total_amount) FILTER (WHERE o.status IN ('paid','shipped','delivered')), 0) AS total_spent
|
||||
FROM profiles p
|
||||
LEFT JOIN orders o ON o.user_id = p.id
|
||||
LEFT JOIN bookings b ON b.user_id = p.id
|
||||
{where}
|
||||
GROUP BY p.id
|
||||
ORDER BY p.created_at DESC
|
||||
LIMIT ${len(params) - 1} OFFSET ${len(params)}
|
||||
""",
|
||||
*params,
|
||||
)
|
||||
return paginated([dict(r) for r in rows], total, page, per_page)
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
async def export_customers(
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
_: dict = Depends(require_admin),
|
||||
):
|
||||
rows = await db.fetch(
|
||||
"SELECT id, email, full_name, phone, is_blocked, created_at FROM profiles WHERE role = 'client' ORDER BY created_at DESC"
|
||||
)
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(["ID", "Email", "Name", "Phone", "Blocked", "Joined"])
|
||||
for r in rows:
|
||||
writer.writerow([r["id"], r["email"], r["full_name"], r["phone"], r["is_blocked"], r["created_at"]])
|
||||
|
||||
output.seek(0)
|
||||
return StreamingResponse(
|
||||
iter([output.getvalue()]),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=customers.csv"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{customer_id}")
|
||||
async def get_customer(
|
||||
customer_id: str,
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
_: dict = Depends(require_admin),
|
||||
):
|
||||
row = await db.fetchrow(
|
||||
"""
|
||||
SELECT
|
||||
p.id, p.email, p.full_name, p.phone, p.is_blocked, p.created_at,
|
||||
COUNT(DISTINCT o.id) AS orders_count,
|
||||
COUNT(DISTINCT b.id) AS bookings_count,
|
||||
COALESCE(SUM(o.total_amount) FILTER (WHERE o.status IN ('paid','shipped','delivered')), 0) AS total_spent
|
||||
FROM profiles p
|
||||
LEFT JOIN orders o ON o.user_id = p.id
|
||||
LEFT JOIN bookings b ON b.user_id = p.id
|
||||
WHERE p.id = $1
|
||||
GROUP BY p.id
|
||||
""",
|
||||
customer_id,
|
||||
)
|
||||
if not row:
|
||||
raise NotFoundError("customer")
|
||||
|
||||
orders = await db.fetch(
|
||||
"SELECT id, status, total_amount, created_at FROM orders WHERE user_id = $1 ORDER BY created_at DESC LIMIT 10",
|
||||
customer_id,
|
||||
)
|
||||
bookings = await db.fetch(
|
||||
"""
|
||||
SELECT b.id, b.status, b.amount_paid, ts.date, ts.start_time
|
||||
FROM bookings b JOIN time_slots ts ON ts.id = b.slot_id
|
||||
WHERE b.user_id = $1
|
||||
ORDER BY ts.date DESC
|
||||
LIMIT 10
|
||||
""",
|
||||
customer_id,
|
||||
)
|
||||
|
||||
result = dict(row)
|
||||
result["recent_orders"] = [dict(r) for r in orders]
|
||||
result["recent_bookings"] = [dict(r) for r in bookings]
|
||||
return ok(result)
|
||||
|
||||
|
||||
@router.patch("/{customer_id}")
|
||||
async def update_customer(
|
||||
customer_id: str,
|
||||
body: UpdateCustomer,
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
admin: dict = Depends(require_admin),
|
||||
):
|
||||
updates = {k: v for k, v in body.model_dump().items() if v is not None}
|
||||
if not updates:
|
||||
raise NotFoundError("customer")
|
||||
|
||||
set_clauses = [f"{k} = ${i + 2}" for i, k in enumerate(updates)]
|
||||
row = await db.fetchrow(
|
||||
f"UPDATE profiles SET {', '.join(set_clauses)}, updated_at = now() WHERE id = $1 AND role = 'client' RETURNING *",
|
||||
customer_id, *updates.values(),
|
||||
)
|
||||
if not row:
|
||||
raise NotFoundError("customer")
|
||||
|
||||
action = "customer.blocked" if updates.get("is_blocked") else "customer.updated"
|
||||
await db.execute(
|
||||
"INSERT INTO activity_log (actor_id, action, entity_type, entity_id) VALUES ($1, $2, 'customer', $3)",
|
||||
str(admin["id"]), action, customer_id,
|
||||
)
|
||||
return ok(dict(row))
|
||||
114
app/routers/admin/dashboard.py
Normal file
114
app/routers/admin/dashboard.py
Normal file
@@ -0,0 +1,114 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
import asyncpg
|
||||
|
||||
from app.core.responses import ok
|
||||
from app.dependencies import get_db, require_admin
|
||||
|
||||
router = APIRouter(prefix="/stats", tags=["Admin — Dashboard"])
|
||||
|
||||
|
||||
@router.get("/overview")
|
||||
async def overview(
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
_: dict = Depends(require_admin),
|
||||
):
|
||||
# Revenue
|
||||
revenue_today = float(await db.fetchval(
|
||||
"SELECT COALESCE(SUM(total_amount),0) FROM orders WHERE status IN ('paid','processing','shipped','delivered') AND DATE(created_at)=CURRENT_DATE"
|
||||
) or 0)
|
||||
revenue_week = float(await db.fetchval(
|
||||
"SELECT COALESCE(SUM(total_amount),0) FROM orders WHERE status IN ('paid','processing','shipped','delivered') AND created_at>=date_trunc('week',now())"
|
||||
) or 0)
|
||||
revenue_month = float(await db.fetchval(
|
||||
"SELECT COALESCE(SUM(total_amount),0) FROM orders WHERE status IN ('paid','processing','shipped','delivered') AND created_at>=date_trunc('month',now())"
|
||||
) or 0)
|
||||
|
||||
# Booking counts — what the frontend dashboard shows
|
||||
orders_pending = int(await db.fetchval("SELECT COUNT(*) FROM orders WHERE status='pending'") or 0)
|
||||
bookings_pending = int(await db.fetchval("SELECT COUNT(*) FROM bookings WHERE status='pending'") or 0)
|
||||
bookings_confirmed = int(await db.fetchval(
|
||||
"SELECT COUNT(*) FROM bookings b JOIN time_slots ts ON ts.id=b.slot_id WHERE b.status='confirmed' AND ts.date>=CURRENT_DATE"
|
||||
) or 0)
|
||||
|
||||
# Products
|
||||
products_count = int(await db.fetchval("SELECT COUNT(*) FROM products WHERE is_hidden=false") or 0)
|
||||
catalog_value = float(await db.fetchval(
|
||||
"SELECT COALESCE(SUM(price),0) FROM products WHERE is_hidden=false"
|
||||
) or 0)
|
||||
low_stock_count = int(await db.fetchval(
|
||||
"SELECT COUNT(*) FROM products WHERE stock_quantity<=5 AND is_hidden=false"
|
||||
) or 0)
|
||||
|
||||
new_customers_month = int(await db.fetchval(
|
||||
"SELECT COUNT(*) FROM profiles WHERE role='client' AND created_at>=date_trunc('month',now())"
|
||||
) or 0)
|
||||
|
||||
return ok({
|
||||
# Revenue stats
|
||||
"revenue_today": revenue_today,
|
||||
"revenue_week": revenue_week,
|
||||
"revenue_month": revenue_month,
|
||||
# Booking stats (matches frontend dashboard cards)
|
||||
"orders_pending": orders_pending,
|
||||
"bookings_pending": bookings_pending,
|
||||
"bookings_confirmed": bookings_confirmed,
|
||||
"bookings_upcoming": bookings_confirmed,
|
||||
# Product stats (matches frontend dashboard cards)
|
||||
"products_count": products_count,
|
||||
"catalog_value": catalog_value,
|
||||
"low_stock_count": low_stock_count,
|
||||
# Customer stats
|
||||
"new_customers_month": new_customers_month,
|
||||
})
|
||||
|
||||
|
||||
@router.get("/revenue")
|
||||
async def revenue(
|
||||
period: str = Query("month", pattern="^(today|week|month|year)$"),
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
_: dict = Depends(require_admin),
|
||||
):
|
||||
trunc = {"today": "day", "week": "week", "month": "month", "year": "year"}[period]
|
||||
rows = await db.fetch(
|
||||
f"""
|
||||
SELECT date_trunc('{trunc}', created_at) AS period,
|
||||
COALESCE(SUM(total_amount), 0) AS revenue,
|
||||
COUNT(*) AS orders_count
|
||||
FROM orders
|
||||
WHERE status IN ('paid','processing','shipped','delivered')
|
||||
AND created_at >= date_trunc('{trunc}', now()) - interval '12 {trunc}s'
|
||||
GROUP BY 1 ORDER BY 1 DESC
|
||||
""",
|
||||
)
|
||||
booking_rows = await db.fetch(
|
||||
f"""
|
||||
SELECT date_trunc('{trunc}', b.created_at) AS period,
|
||||
COALESCE(SUM(b.amount_paid), 0) AS revenue,
|
||||
COUNT(*) AS bookings_count
|
||||
FROM bookings b
|
||||
WHERE b.status IN ('confirmed','completed')
|
||||
AND b.created_at >= date_trunc('{trunc}', now()) - interval '12 {trunc}s'
|
||||
GROUP BY 1 ORDER BY 1 DESC
|
||||
""",
|
||||
)
|
||||
return ok({"orders": [dict(r) for r in rows], "bookings": [dict(r) for r in booking_rows]})
|
||||
|
||||
|
||||
@router.get("/activity", tags=["Admin — Dashboard"])
|
||||
async def activity(
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
_: dict = Depends(require_admin),
|
||||
):
|
||||
rows = await db.fetch(
|
||||
"""
|
||||
SELECT al.id, p.full_name AS actor_name, al.action,
|
||||
al.entity_type, al.entity_id, al.metadata, al.created_at
|
||||
FROM activity_log al
|
||||
LEFT JOIN profiles p ON p.id = al.actor_id
|
||||
ORDER BY al.created_at DESC
|
||||
LIMIT $1
|
||||
""",
|
||||
limit,
|
||||
)
|
||||
return ok([dict(r) for r in rows])
|
||||
89
app/routers/admin/orders.py
Normal file
89
app/routers/admin/orders.py
Normal file
@@ -0,0 +1,89 @@
|
||||
import csv
|
||||
import io
|
||||
from datetime import date
|
||||
from typing import Annotated
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
import asyncpg
|
||||
|
||||
from app.core.pagination import pagination_params
|
||||
from app.core.responses import ok, paginated
|
||||
from app.dependencies import get_db, require_admin
|
||||
from app.models.orders import UpdateOrderStatus, RefundRequest
|
||||
from app.services import order_service
|
||||
|
||||
router = APIRouter(prefix="/orders", tags=["Admin — Orders"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_orders(
|
||||
pagination: Annotated[tuple, Depends(pagination_params)],
|
||||
status: str | None = Query(None),
|
||||
from_date: date | None = Query(None),
|
||||
to_date: date | None = Query(None),
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
_: dict = Depends(require_admin),
|
||||
):
|
||||
page, per_page, offset = pagination
|
||||
orders, total = await order_service.list_orders(db, page, per_page, offset, status=status)
|
||||
return paginated(orders, total, page, per_page)
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
async def export_orders(
|
||||
status: str | None = Query(None),
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
_: dict = Depends(require_admin),
|
||||
):
|
||||
rows = await db.fetch(
|
||||
"""
|
||||
SELECT o.id, p.email, p.full_name, o.status, o.total_amount, o.created_at
|
||||
FROM orders o
|
||||
JOIN profiles p ON p.id = o.user_id
|
||||
ORDER BY o.created_at DESC
|
||||
"""
|
||||
)
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(["ID", "Email", "Name", "Status", "Total", "Date"])
|
||||
for r in rows:
|
||||
writer.writerow([r["id"], r["email"], r["full_name"], r["status"], r["total_amount"], r["created_at"]])
|
||||
|
||||
output.seek(0)
|
||||
return StreamingResponse(
|
||||
iter([output.getvalue()]),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=orders.csv"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{order_id}")
|
||||
async def get_order(
|
||||
order_id: str,
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
_: dict = Depends(require_admin),
|
||||
):
|
||||
order = await order_service.get_order(db, order_id)
|
||||
return ok(order)
|
||||
|
||||
|
||||
@router.patch("/{order_id}/status")
|
||||
async def update_status(
|
||||
order_id: str,
|
||||
body: UpdateOrderStatus,
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
admin: dict = Depends(require_admin),
|
||||
):
|
||||
order = await order_service.update_order_status(db, order_id, body.status, str(admin["id"]))
|
||||
return ok(order)
|
||||
|
||||
|
||||
@router.post("/{order_id}/refund")
|
||||
async def refund_order(
|
||||
order_id: str,
|
||||
body: RefundRequest,
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
admin: dict = Depends(require_admin),
|
||||
):
|
||||
order = await order_service.refund_order(db, order_id, str(admin["id"]), body.amount, body.reason)
|
||||
return ok(order)
|
||||
116
app/routers/admin/products.py
Normal file
116
app/routers/admin/products.py
Normal file
@@ -0,0 +1,116 @@
|
||||
from typing import Annotated
|
||||
from fastapi import APIRouter, Depends, Query, UploadFile, File
|
||||
import asyncpg
|
||||
|
||||
from app.core.pagination import pagination_params
|
||||
from app.core.responses import ok, paginated
|
||||
from app.dependencies import get_db, require_admin
|
||||
from app.exceptions import ValidationError
|
||||
from app.models.products import ProductCreate, ProductUpdate, BulkStockUpdateRequest
|
||||
from app.services import product_service, storage_service
|
||||
|
||||
router = APIRouter(prefix="/products", tags=["Admin — Products"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_products(
|
||||
pagination: Annotated[tuple, Depends(pagination_params)],
|
||||
search: str | None = Query(None),
|
||||
category: str | None = Query(None),
|
||||
bestseller: bool = Query(False),
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
_: dict = Depends(require_admin),
|
||||
):
|
||||
page, per_page, offset = pagination
|
||||
products, total = await product_service.list_products(
|
||||
db, page, per_page, offset,
|
||||
include_hidden=True,
|
||||
category=category,
|
||||
bestseller=bestseller,
|
||||
search=search,
|
||||
)
|
||||
return paginated(products, total, page, per_page)
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_product(
|
||||
body: ProductCreate,
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
admin: dict = Depends(require_admin),
|
||||
):
|
||||
product = await product_service.create_product(db, body)
|
||||
await _log(db, admin, "product.created", str(product["id"]))
|
||||
return ok(product)
|
||||
|
||||
|
||||
@router.put("/{product_id}")
|
||||
async def update_product(
|
||||
product_id: str,
|
||||
body: ProductUpdate,
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
admin: dict = Depends(require_admin),
|
||||
):
|
||||
product = await product_service.update_product(db, product_id, body)
|
||||
await _log(db, admin, "product.updated", product_id)
|
||||
return ok(product)
|
||||
|
||||
|
||||
@router.delete("/{product_id}", status_code=204)
|
||||
async def delete_product(
|
||||
product_id: str,
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
admin: dict = Depends(require_admin),
|
||||
):
|
||||
await product_service.delete_product(db, product_id)
|
||||
await _log(db, admin, "product.deleted", product_id)
|
||||
|
||||
|
||||
@router.post("/{product_id}/images", status_code=201)
|
||||
async def upload_image(
|
||||
product_id: str,
|
||||
file: UploadFile = File(...),
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
admin: dict = Depends(require_admin),
|
||||
):
|
||||
if not file.content_type or not file.content_type.startswith("image/"):
|
||||
raise ValidationError("Only image files are allowed")
|
||||
|
||||
content = await file.read()
|
||||
if len(content) > 10 * 1024 * 1024:
|
||||
raise ValidationError("Image must be under 10MB")
|
||||
|
||||
image_data = await storage_service.upload_product_image(product_id, content, file.content_type)
|
||||
product = await product_service.add_image(db, product_id, image_data["url"])
|
||||
return ok(product)
|
||||
|
||||
|
||||
@router.delete("/{product_id}/images")
|
||||
async def delete_image(
|
||||
product_id: str,
|
||||
url: str = Query(..., description="Full image URL to remove"),
|
||||
storage_path: str = Query(..., description="Storage path for deletion from bucket"),
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
admin: dict = Depends(require_admin),
|
||||
):
|
||||
await storage_service.delete_product_image(storage_path)
|
||||
product = await product_service.remove_image(db, product_id, url)
|
||||
return ok(product)
|
||||
|
||||
|
||||
@router.post("/bulk-stock")
|
||||
async def bulk_stock(
|
||||
body: BulkStockUpdateRequest,
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
admin: dict = Depends(require_admin),
|
||||
):
|
||||
updates = [u.model_dump() for u in body.updates]
|
||||
await product_service.bulk_stock_update(db, updates)
|
||||
await _log(db, admin, "product.bulk_stock_updated", None)
|
||||
return ok({"updated": len(updates)})
|
||||
|
||||
|
||||
async def _log(db, admin, action, entity_id, metadata=None):
|
||||
await db.execute(
|
||||
"INSERT INTO activity_log (actor_id, action, entity_type, entity_id, metadata) VALUES ($1, $2, 'product', $3, $4)",
|
||||
str(admin["id"]), action, entity_id, metadata,
|
||||
)
|
||||
88
app/routers/admin/services.py
Normal file
88
app/routers/admin/services.py
Normal file
@@ -0,0 +1,88 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
import asyncpg
|
||||
|
||||
from app.core.responses import ok
|
||||
from app.dependencies import get_db, require_admin
|
||||
from app.exceptions import NotFoundError
|
||||
|
||||
router = APIRouter(prefix="/services", tags=["Admin — Services"])
|
||||
|
||||
|
||||
class ServiceCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
description: str | None = None
|
||||
duration_minutes: int = Field(default=60, ge=5, le=480)
|
||||
price: float = Field(default=0.0, ge=0)
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class ServiceUpdate(BaseModel):
|
||||
name: str | None = Field(None, min_length=1, max_length=120)
|
||||
description: str | None = None
|
||||
duration_minutes: int | None = Field(None, ge=5, le=480)
|
||||
price: float | None = Field(None, ge=0)
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_services(
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
_: dict = Depends(require_admin),
|
||||
):
|
||||
rows = await db.fetch(
|
||||
"SELECT * FROM services ORDER BY price ASC, name ASC"
|
||||
)
|
||||
return ok([dict(r) for r in rows])
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_service(
|
||||
body: ServiceCreate,
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
_: dict = Depends(require_admin),
|
||||
):
|
||||
row = await db.fetchrow(
|
||||
"""
|
||||
INSERT INTO services (name, description, duration_minutes, price, is_active)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *
|
||||
""",
|
||||
body.name, body.description, body.duration_minutes, body.price, body.is_active,
|
||||
)
|
||||
return ok(dict(row))
|
||||
|
||||
|
||||
@router.put("/{service_id}")
|
||||
async def update_service(
|
||||
service_id: str,
|
||||
body: ServiceUpdate,
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
_: dict = Depends(require_admin),
|
||||
):
|
||||
updates = {k: v for k, v in body.model_dump().items() if v is not None}
|
||||
if not updates:
|
||||
row = await db.fetchrow("SELECT * FROM services WHERE id = $1", service_id)
|
||||
if not row:
|
||||
raise NotFoundError("service")
|
||||
return ok(dict(row))
|
||||
|
||||
set_clauses = [f"{k} = ${i + 2}" for i, k in enumerate(updates)]
|
||||
row = await db.fetchrow(
|
||||
f"UPDATE services SET {', '.join(set_clauses)}, updated_at = now() WHERE id = $1 RETURNING *",
|
||||
service_id, *updates.values(),
|
||||
)
|
||||
if not row:
|
||||
raise NotFoundError("service")
|
||||
return ok(dict(row))
|
||||
|
||||
|
||||
@router.delete("/{service_id}", status_code=204)
|
||||
async def delete_service(
|
||||
service_id: str,
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
_: dict = Depends(require_admin),
|
||||
):
|
||||
result = await db.execute("DELETE FROM services WHERE id = $1", service_id)
|
||||
if result == "DELETE 0":
|
||||
raise NotFoundError("service")
|
||||
99
app/routers/admin/settings.py
Normal file
99
app/routers/admin/settings.py
Normal file
@@ -0,0 +1,99 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
import asyncpg
|
||||
|
||||
from app.core.responses import ok
|
||||
from app.dependencies import get_db, require_admin
|
||||
from app.exceptions import NotFoundError
|
||||
from app.models.admin import StoreSettingUpdate, AdminUserCreate, EmailTemplateUpdate
|
||||
from app.services import auth_service
|
||||
|
||||
router = APIRouter(tags=["Admin — Settings"])
|
||||
|
||||
|
||||
# ── Store settings ────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/settings")
|
||||
async def get_settings(
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
_: dict = Depends(require_admin),
|
||||
):
|
||||
rows = await db.fetch("SELECT key, value, updated_at FROM store_settings ORDER BY key")
|
||||
return ok([dict(r) for r in rows])
|
||||
|
||||
|
||||
@router.put("/settings/{key}")
|
||||
async def update_setting(
|
||||
key: str,
|
||||
body: StoreSettingUpdate,
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
_: dict = Depends(require_admin),
|
||||
):
|
||||
import json
|
||||
row = await db.fetchrow(
|
||||
"""
|
||||
INSERT INTO store_settings (key, value)
|
||||
VALUES ($1, $2::jsonb)
|
||||
ON CONFLICT (key) DO UPDATE SET value = $2::jsonb, updated_at = now()
|
||||
RETURNING *
|
||||
""",
|
||||
key, json.dumps(body.value),
|
||||
)
|
||||
return ok(dict(row))
|
||||
|
||||
|
||||
# ── Email templates ───────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/email-templates")
|
||||
async def get_templates(
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
_: dict = Depends(require_admin),
|
||||
):
|
||||
rows = await db.fetch("SELECT * FROM email_templates ORDER BY name")
|
||||
return ok([dict(r) for r in rows])
|
||||
|
||||
|
||||
@router.put("/email-templates/{name}")
|
||||
async def update_template(
|
||||
name: str,
|
||||
body: EmailTemplateUpdate,
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
_: dict = Depends(require_admin),
|
||||
):
|
||||
row = await db.fetchrow(
|
||||
"""
|
||||
INSERT INTO email_templates (name, subject, body_html, body_text)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (name) DO UPDATE SET subject = $2, body_html = $3, body_text = $4, updated_at = now()
|
||||
RETURNING *
|
||||
""",
|
||||
name, body.subject, body.body_html, body.body_text,
|
||||
)
|
||||
return ok(dict(row))
|
||||
|
||||
|
||||
# ── Admin users ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/users")
|
||||
async def list_admin_users(
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
_: dict = Depends(require_admin),
|
||||
):
|
||||
rows = await db.fetch(
|
||||
"SELECT id, email, full_name, created_at FROM profiles WHERE role = 'admin' ORDER BY created_at"
|
||||
)
|
||||
return ok([dict(r) for r in rows])
|
||||
|
||||
|
||||
@router.post("/users", status_code=201)
|
||||
async def create_admin_user(
|
||||
body: AdminUserCreate,
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
_: dict = Depends(require_admin),
|
||||
):
|
||||
user = await auth_service.create_admin_user(body.email, body.password, body.full_name, db)
|
||||
return ok({
|
||||
"id": str(user["id"]),
|
||||
"email": user["email"],
|
||||
"full_name": user["full_name"],
|
||||
"role": user["role"],
|
||||
})
|
||||
60
app/routers/auth.py
Normal file
60
app/routers/auth.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
import asyncpg
|
||||
|
||||
from app.core.responses import ok
|
||||
from app.dependencies import get_db, get_current_user
|
||||
from app.models.auth import RegisterRequest, LoginRequest, RefreshRequest, ForgotPasswordRequest, UpdateProfileRequest
|
||||
from app.services import auth_service
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["Auth"])
|
||||
|
||||
|
||||
@router.post("/register", status_code=201)
|
||||
async def register(body: RegisterRequest, db: asyncpg.Connection = Depends(get_db)):
|
||||
data = await auth_service.register(body, db)
|
||||
return ok(data)
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(body: LoginRequest):
|
||||
data = await auth_service.login(body)
|
||||
return ok(data)
|
||||
|
||||
|
||||
@router.post("/refresh")
|
||||
async def refresh(body: RefreshRequest):
|
||||
data = await auth_service.refresh(body)
|
||||
return ok(data)
|
||||
|
||||
|
||||
@router.post("/forgot-password")
|
||||
async def forgot_password(body: ForgotPasswordRequest):
|
||||
await auth_service.forgot_password(body.email)
|
||||
return ok({"message": "If that email exists, a reset link has been sent."})
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def me(user: dict = Depends(get_current_user)):
|
||||
return ok({
|
||||
"id": str(user["id"]),
|
||||
"email": user["email"],
|
||||
"full_name": user["full_name"],
|
||||
"phone": user["phone"],
|
||||
"role": user["role"],
|
||||
})
|
||||
|
||||
|
||||
@router.patch("/me")
|
||||
async def update_me(
|
||||
body: UpdateProfileRequest,
|
||||
user: dict = Depends(get_current_user),
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
):
|
||||
updated = await auth_service.update_profile(str(user["id"]), db, body.full_name, body.phone)
|
||||
return ok({
|
||||
"id": str(updated["id"]),
|
||||
"email": updated["email"],
|
||||
"full_name": updated["full_name"],
|
||||
"phone": updated["phone"],
|
||||
"role": updated["role"],
|
||||
})
|
||||
65
app/routers/bookings.py
Normal file
65
app/routers/bookings.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from datetime import date
|
||||
from typing import Annotated
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
import asyncpg
|
||||
|
||||
from app.core.pagination import pagination_params
|
||||
from app.core.responses import ok, paginated
|
||||
from app.dependencies import get_db, get_current_user, get_current_user_optional
|
||||
from app.models.bookings import BookingCreate
|
||||
from app.services import booking_service, slot_service
|
||||
|
||||
router = APIRouter(prefix="/bookings", tags=["Bookings"])
|
||||
|
||||
|
||||
@router.get("/slots")
|
||||
async def get_available_slots(
|
||||
from_date: date = Query(...),
|
||||
to_date: date = Query(...),
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
):
|
||||
slots = await slot_service.list_slots(db, from_date, to_date, available_only=True)
|
||||
return ok(slots)
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_booking(
|
||||
body: BookingCreate,
|
||||
user: dict | None = Depends(get_current_user_optional),
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
):
|
||||
result = await booking_service.create_booking(db, body, user=user)
|
||||
return ok(result)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_my_bookings(
|
||||
pagination: Annotated[tuple, Depends(pagination_params)],
|
||||
status: str | None = Query(None),
|
||||
user: dict = Depends(get_current_user),
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
):
|
||||
page, per_page, offset = pagination
|
||||
bookings, total = await booking_service.list_bookings(
|
||||
db, page, per_page, offset, user_id=str(user["id"]), status=status
|
||||
)
|
||||
return paginated(bookings, total, page, per_page)
|
||||
|
||||
|
||||
@router.get("/{booking_id}")
|
||||
async def get_booking(
|
||||
booking_id: str,
|
||||
user: dict = Depends(get_current_user),
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
):
|
||||
booking = await booking_service.get_booking(db, booking_id, user_id=str(user["id"]))
|
||||
return ok(booking)
|
||||
|
||||
|
||||
@router.delete("/{booking_id}", status_code=204)
|
||||
async def cancel_booking(
|
||||
booking_id: str,
|
||||
user: dict = Depends(get_current_user),
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
):
|
||||
await booking_service.cancel_booking(db, booking_id, str(user["id"]))
|
||||
23
app/routers/contact.py
Normal file
23
app/routers/contact.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from fastapi import APIRouter, Depends
|
||||
import asyncpg
|
||||
|
||||
from app.core.responses import ok
|
||||
from app.dependencies import get_db
|
||||
|
||||
router = APIRouter(prefix="/contact", tags=["Contact"])
|
||||
|
||||
|
||||
class ContactRequest(BaseModel):
|
||||
name: str
|
||||
email: EmailStr
|
||||
message: str
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def submit_contact(body: ContactRequest, db: asyncpg.Connection = Depends(get_db)):
|
||||
await db.execute(
|
||||
"INSERT INTO contact_messages (name, email, message) VALUES ($1, $2, $3)",
|
||||
body.name, body.email, body.message,
|
||||
)
|
||||
return ok({"message": "Your message has been received. We will get back to you shortly."})
|
||||
43
app/routers/orders.py
Normal file
43
app/routers/orders.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from typing import Annotated
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
import asyncpg
|
||||
|
||||
from app.core.pagination import pagination_params
|
||||
from app.core.responses import ok, paginated
|
||||
from app.dependencies import get_db, get_current_user
|
||||
from app.models.orders import OrderCreate
|
||||
from app.services import order_service
|
||||
|
||||
router = APIRouter(prefix="/orders", tags=["Orders"])
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_order(
|
||||
body: OrderCreate,
|
||||
user: dict = Depends(get_current_user),
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
):
|
||||
result = await order_service.create_order(db, str(user["id"]), body)
|
||||
return ok(result)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_my_orders(
|
||||
pagination: Annotated[tuple, Depends(pagination_params)],
|
||||
status: str | None = Query(None),
|
||||
user: dict = Depends(get_current_user),
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
):
|
||||
page, per_page, offset = pagination
|
||||
orders, total = await order_service.list_orders(db, page, per_page, offset, user_id=str(user["id"]), status=status)
|
||||
return paginated(orders, total, page, per_page)
|
||||
|
||||
|
||||
@router.get("/{order_id}")
|
||||
async def get_order(
|
||||
order_id: str,
|
||||
user: dict = Depends(get_current_user),
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
):
|
||||
order = await order_service.get_order(db, order_id, user_id=str(user["id"]))
|
||||
return ok(order)
|
||||
47
app/routers/payments.py
Normal file
47
app/routers/payments.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from fastapi import APIRouter, Request, Header
|
||||
import asyncpg
|
||||
from fastapi import Depends
|
||||
|
||||
from app.core.responses import ok
|
||||
from app.dependencies import get_db
|
||||
from app.exceptions import PaymentError
|
||||
from app.services import stripe_service, order_service, booking_service
|
||||
|
||||
router = APIRouter(prefix="/payments", tags=["Payments"])
|
||||
|
||||
|
||||
@router.post("/webhook")
|
||||
async def stripe_webhook(
|
||||
request: Request,
|
||||
stripe_signature: str = Header(None, alias="stripe-signature"),
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
):
|
||||
payload = await request.body()
|
||||
|
||||
if not stripe_signature:
|
||||
raise PaymentError("Missing signature")
|
||||
|
||||
event = stripe_service.verify_webhook(payload, stripe_signature)
|
||||
|
||||
intent = event.get("data", {}).get("object", {})
|
||||
metadata = intent.get("metadata", {})
|
||||
entity_type = metadata.get("entity_type")
|
||||
entity_id = metadata.get("entity_id")
|
||||
payment_intent_id = intent.get("id")
|
||||
|
||||
if not entity_type or not entity_id or entity_id == "pending":
|
||||
return ok({"received": True})
|
||||
|
||||
if event["type"] == "payment_intent.succeeded":
|
||||
if entity_type == "order":
|
||||
await order_service.handle_payment_succeeded(db, payment_intent_id, entity_id)
|
||||
elif entity_type == "booking":
|
||||
await booking_service.handle_payment_succeeded(db, payment_intent_id, entity_id)
|
||||
|
||||
elif event["type"] == "payment_intent.payment_failed":
|
||||
if entity_type == "order":
|
||||
await order_service.handle_payment_failed(db, payment_intent_id, entity_id)
|
||||
elif entity_type == "booking":
|
||||
await booking_service.handle_payment_failed(db, payment_intent_id, entity_id)
|
||||
|
||||
return ok({"received": True})
|
||||
39
app/routers/products.py
Normal file
39
app/routers/products.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from typing import Annotated
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
import asyncpg
|
||||
|
||||
from app.core.pagination import pagination_params
|
||||
from app.core.responses import ok, paginated
|
||||
from app.dependencies import get_db
|
||||
from app.services import product_service
|
||||
|
||||
router = APIRouter(prefix="/products", tags=["Products"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_products(
|
||||
pagination: Annotated[tuple, Depends(pagination_params)],
|
||||
search: str | None = Query(None),
|
||||
category: str | None = Query(None),
|
||||
bestseller: bool = Query(False),
|
||||
is_new: bool = Query(False),
|
||||
exclude: str | None = Query(None, description="Exclude a product ID (e.g. for similar products)"),
|
||||
db: asyncpg.Connection = Depends(get_db),
|
||||
):
|
||||
page, per_page, offset = pagination
|
||||
products, total = await product_service.list_products(
|
||||
db, page, per_page, offset,
|
||||
include_hidden=False,
|
||||
category=category,
|
||||
bestseller=bestseller,
|
||||
is_new=is_new,
|
||||
search=search,
|
||||
exclude_id=exclude,
|
||||
)
|
||||
return paginated(products, total, page, per_page)
|
||||
|
||||
|
||||
@router.get("/{product_id}")
|
||||
async def get_product(product_id: str, db: asyncpg.Connection = Depends(get_db)):
|
||||
product = await product_service.get_product(db, product_id)
|
||||
return ok(product)
|
||||
15
app/routers/services.py
Normal file
15
app/routers/services.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
import asyncpg
|
||||
|
||||
from app.core.responses import ok
|
||||
from app.dependencies import get_db
|
||||
|
||||
router = APIRouter(prefix="/services", tags=["Services"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_services(db: asyncpg.Connection = Depends(get_db)):
|
||||
rows = await db.fetch(
|
||||
"SELECT id, name, description, duration_minutes, price FROM services WHERE is_active = true ORDER BY price ASC"
|
||||
)
|
||||
return ok([dict(r) for r in rows])
|
||||
Reference in New Issue
Block a user