Initial Commit

This commit is contained in:
belviskhoremk
2026-05-12 00:34:21 +00:00
commit d2dc43b16f
57 changed files with 6056 additions and 0 deletions

0
app/services/__init__.py Normal file
View File

View File

@@ -0,0 +1,127 @@
import asyncpg
from supabase import create_client, Client
from app.config import get_settings
from app.exceptions import AppError, UnauthorizedError
from app.models.auth import RegisterRequest, LoginRequest, RefreshRequest
def _client() -> Client:
s = get_settings()
return create_client(s.SUPABASE_URL, s.SUPABASE_ANON_KEY)
def _admin_client() -> Client:
s = get_settings()
return create_client(s.SUPABASE_URL, s.SUPABASE_SERVICE_ROLE_KEY)
async def register(req: RegisterRequest, db: asyncpg.Connection) -> dict:
try:
result = _client().auth.sign_up({"email": req.email, "password": req.password})
except Exception as e:
raise AppError("REGISTRATION_FAILED", str(e), 400)
if not result.user:
raise AppError("REGISTRATION_FAILED", "Could not create account", 400)
user_id = str(result.user.id)
await db.execute(
"""
INSERT INTO profiles (id, email, full_name, phone, role)
VALUES ($1, $2, $3, $4, 'client')
ON CONFLICT (id) DO NOTHING
""",
user_id, req.email, req.resolved_name(), req.phone,
)
if result.session:
return {
"access_token": result.session.access_token,
"refresh_token": result.session.refresh_token,
"token_type": "bearer",
"expires_in": result.session.expires_in,
}
return {"message": "Account created successfully."}
async def login(req: LoginRequest) -> dict:
try:
result = _client().auth.sign_in_with_password({"email": req.email, "password": req.password})
except Exception:
raise UnauthorizedError("Invalid email or password")
if not result.session:
raise UnauthorizedError("Invalid email or password")
return {
"access_token": result.session.access_token,
"refresh_token": result.session.refresh_token,
"token_type": "bearer",
"expires_in": result.session.expires_in,
}
async def refresh(req: RefreshRequest) -> dict:
try:
result = _client().auth.refresh_session(req.refresh_token)
except Exception:
raise UnauthorizedError("Invalid or expired refresh token")
return {
"access_token": result.session.access_token,
"refresh_token": result.session.refresh_token,
"token_type": "bearer",
"expires_in": result.session.expires_in,
}
async def forgot_password(email: str):
try:
_client().auth.reset_password_email(email)
except Exception:
pass # Always return success to avoid email enumeration
async def update_profile(user_id: str, db: asyncpg.Connection, full_name: str | None, phone: str | None) -> dict:
updates = {}
if full_name is not None:
updates["full_name"] = full_name
if phone is not None:
updates["phone"] = phone
if not updates:
row = await db.fetchrow("SELECT * FROM profiles WHERE id = $1", user_id)
return dict(row)
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 RETURNING *",
user_id, *updates.values(),
)
return dict(row)
async def create_admin_user(email: str, password: str, full_name: str, db: asyncpg.Connection) -> dict:
try:
result = _admin_client().auth.admin.create_user({
"email": email,
"password": password,
"email_confirm": True,
})
except Exception as e:
raise AppError("USER_CREATE_FAILED", str(e), 400)
user_id = str(result.user.id)
await db.execute(
"""
INSERT INTO profiles (id, email, full_name, role)
VALUES ($1, $2, $3, 'admin')
ON CONFLICT (id) DO UPDATE SET role = 'admin'
""",
user_id, email, full_name,
)
row = await db.fetchrow("SELECT * FROM profiles WHERE id = $1", user_id)
return dict(row)

View File

@@ -0,0 +1,272 @@
from uuid import UUID
import asyncpg
from app.exceptions import NotFoundError, SlotUnavailableError, AppError, ValidationError
from app.models.bookings import BookingCreate
async def create_booking(
db: asyncpg.Connection,
data: BookingCreate,
user: dict | None = None,
) -> dict:
# Resolve identity
if user:
user_id = str(user["id"])
guest_name = guest_email = guest_phone = None
else:
if not data.guest_email or not data.guest_name:
raise ValidationError("guest_name and guest_email are required for unauthenticated bookings")
user_id = None
guest_name = data.guest_name
guest_email = data.guest_email
guest_phone = data.guest_phone
async with db.transaction():
slot = await db.fetchrow(
"""
SELECT ts.* FROM time_slots ts
WHERE ts.id = $1
AND ts.is_blocked = false
AND ts.date >= CURRENT_DATE
AND NOT EXISTS (SELECT 1 FROM blocked_dates bd WHERE bd.date = ts.date)
AND NOT EXISTS (
SELECT 1 FROM bookings b
WHERE b.slot_id = ts.id AND b.status IN ('pending', 'confirmed')
)
""",
str(data.slot_id),
)
if not slot:
raise SlotUnavailableError()
booking = await db.fetchrow(
"""
INSERT INTO bookings
(user_id, slot_id, service_note, status,
guest_name, guest_email, guest_phone)
VALUES ($1, $2, $3, 'pending', $4, $5, $6)
RETURNING *
""",
user_id, str(data.slot_id), data.service_note,
guest_name, guest_email, guest_phone,
)
return {"booking_id": str(booking["id"])}
async def get_booking(db: asyncpg.Connection, booking_id: str, user_id: str | None = None) -> dict:
query = """
SELECT b.*,
ts.date AS slot_date,
ts.start_time AS slot_start,
ts.end_time AS slot_end,
p.full_name AS profile_name,
p.email AS profile_email,
p.phone AS profile_phone,
s.name AS service_name
FROM bookings b
JOIN time_slots ts ON ts.id = b.slot_id
LEFT JOIN profiles p ON p.id = b.user_id
LEFT JOIN services s ON s.id = b.service_id
WHERE b.id = $1
"""
params: list = [booking_id]
if user_id:
query += " AND b.user_id = $2"
params.append(user_id)
row = await db.fetchrow(query, *params)
if not row:
raise NotFoundError("booking")
return _shape(row)
async def list_bookings(
db: asyncpg.Connection,
page: int,
per_page: int,
offset: int,
user_id: str | None = None,
status: str | None = None,
from_date=None,
to_date=None,
) -> tuple[list[dict], int]:
conditions: list[str] = []
params: list = []
if user_id:
params.append(user_id)
conditions.append(f"b.user_id = ${len(params)}")
if status:
params.append(status)
conditions.append(f"b.status = ${len(params)}")
if from_date:
params.append(from_date)
conditions.append(f"ts.date >= ${len(params)}")
if to_date:
params.append(to_date)
conditions.append(f"ts.date <= ${len(params)}")
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
total = await db.fetchval(
f"SELECT COUNT(*) FROM bookings b JOIN time_slots ts ON ts.id = b.slot_id {where}",
*params,
)
params.extend([per_page, offset])
rows = await db.fetch(
f"""
SELECT b.*,
ts.date AS slot_date,
ts.start_time AS slot_start,
ts.end_time AS slot_end,
p.full_name AS profile_name,
p.email AS profile_email,
p.phone AS profile_phone,
s.name AS service_name
FROM bookings b
JOIN time_slots ts ON ts.id = b.slot_id
LEFT JOIN profiles p ON p.id = b.user_id
LEFT JOIN services s ON s.id = b.service_id
{where}
ORDER BY ts.date DESC, ts.start_time DESC
LIMIT ${len(params) - 1} OFFSET ${len(params)}
""",
*params,
)
return [_shape(r) for r in rows], total
async def cancel_booking(db: asyncpg.Connection, booking_id: str, user_id: str):
booking = await db.fetchrow(
"SELECT * FROM bookings WHERE id = $1 AND user_id = $2",
booking_id, user_id,
)
if not booking:
raise NotFoundError("booking")
if booking["status"] not in ("pending", "confirmed"):
raise AppError("CANNOT_CANCEL", "Only pending or confirmed bookings can be cancelled", 400)
await db.execute(
"UPDATE bookings SET status = 'cancelled', updated_at = now() WHERE id = $1",
booking_id,
)
if booking["stripe_payment_intent_id"] and booking["status"] == "confirmed":
await stripe_service.create_refund(booking["stripe_payment_intent_id"])
async def admin_update_booking(
db: asyncpg.Connection,
booking_id: str,
status: str,
admin_notes: str | None,
actor_id: str,
) -> dict:
row = await db.fetchrow(
"""
UPDATE bookings SET status = $2, admin_notes = COALESCE($3, admin_notes), updated_at = now()
WHERE id = $1 RETURNING *
""",
booking_id, status, admin_notes,
)
if not row:
raise NotFoundError("booking")
booking = dict(row)
email = booking.get("guest_email")
if not email and booking.get("user_id"):
profile = await db.fetchrow("SELECT email FROM profiles WHERE id = $1", str(booking["user_id"]))
if profile:
email = profile["email"]
if email:
slot = await db.fetchrow("SELECT date, start_time FROM time_slots WHERE id = $1", str(booking["slot_id"]))
if slot:
from app.services import email_service
if status == "confirmed":
await email_service.send_booking_confirmed(email, str(slot["date"]), _fmt_time(slot["start_time"]))
elif status == "cancelled":
await email_service.send_booking_cancelled(email, str(slot["date"]), _fmt_time(slot["start_time"]))
await _log(db, actor_id, f"booking.{status}", "booking", booking_id)
return await get_booking(db, booking_id)
async def admin_delete_booking(db: asyncpg.Connection, booking_id: str):
result = await db.execute("DELETE FROM bookings WHERE id = $1", booking_id)
if result == "DELETE 0":
raise NotFoundError("booking")
async def handle_payment_succeeded(db: asyncpg.Connection, payment_intent_id: str, entity_id: str):
booking = await db.fetchrow(
"SELECT * FROM bookings WHERE id = $1 AND stripe_payment_intent_id = $2",
entity_id, payment_intent_id,
)
if not booking:
return
await db.execute(
"UPDATE bookings SET status = 'confirmed', updated_at = now() WHERE id = $1",
entity_id,
)
email = booking["guest_email"]
if not email and booking["user_id"]:
profile = await db.fetchrow("SELECT email FROM profiles WHERE id = $1", str(booking["user_id"]))
if profile:
email = profile["email"]
slot = await db.fetchrow("SELECT date, start_time FROM time_slots WHERE id = $1", str(booking["slot_id"]))
if email and slot:
from app.services.email_service import send_booking_confirmed
await send_booking_confirmed(email, str(slot["date"]), _fmt_time(slot["start_time"]))
async def handle_payment_failed(db: asyncpg.Connection, payment_intent_id: str, entity_id: str):
await db.execute(
"UPDATE bookings SET status = 'cancelled', updated_at = now() WHERE id = $1 AND status = 'pending'",
entity_id,
)
async def _get_booking_price(db: asyncpg.Connection) -> float:
val = await db.fetchval("SELECT value FROM store_settings WHERE key = 'default_booking_price'")
if val is not None:
try:
return float(val)
except (TypeError, ValueError):
pass
return 0.0
def _shape(r) -> dict:
d = dict(r)
d["slot_start"] = _fmt_time(d.get("slot_start"))
d["slot_end"] = _fmt_time(d.get("slot_end"))
# Resolve client identity: profile takes priority over guest fields
d["client_name"] = d.pop("profile_name", None) or d.get("guest_name")
d["client_email"] = d.pop("profile_email", None) or d.get("guest_email")
d["client_phone"] = d.pop("profile_phone", None) or d.get("guest_phone")
# Clean up internal fields
for f in ("guest_name", "guest_email", "guest_phone", "service_name"):
d.pop(f, None)
return d
def _fmt_time(t) -> str:
if t is None:
return ""
s = str(t)
# "HH:MM:SS" → "HH:MM"
return s[:5] if len(s) >= 5 else s
async def _log(db, actor_id, action, entity_type, entity_id, metadata=None):
await db.execute(
"INSERT INTO activity_log (actor_id, action, entity_type, entity_id, metadata) VALUES ($1, $2, $3, $4, $5)",
actor_id, action, entity_type, entity_id, metadata,
)

View File

@@ -0,0 +1,71 @@
import aiosmtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from app.config import get_settings
async def send_email(to: str, subject: str, html_body: str, text_body: str | None = None):
settings = get_settings()
if not settings.SMTP_HOST:
return # Email non configuré, on ignore silencieusement
message = MIMEMultipart("alternative")
message["Subject"] = subject
message["From"] = settings.EMAIL_FROM
message["To"] = to
if text_body:
message.attach(MIMEText(text_body, "plain"))
message.attach(MIMEText(html_body, "html"))
try:
async with aiosmtplib.SMTP(
hostname=settings.SMTP_HOST,
port=settings.SMTP_PORT,
start_tls=True,
) as smtp:
await smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
await smtp.send_message(message)
except Exception:
pass # Ne jamais laisser un échec d'email planter la requête
async def send_booking_confirmed(to: str, booking_date: str, booking_time: str):
settings = get_settings()
html = f"""
<h2>Votre rendez-vous est confirmé !</h2>
<p>Bonjour,</p>
<p>Votre rendez-vous chez <strong>{settings.BUSINESS_NAME}</strong> est confirmé.</p>
<p><strong>Date :</strong> {booking_date}<br>
<strong>Heure :</strong> {booking_time}</p>
<p>Nous vous contacterons pour finaliser le paiement. À très bientôt !</p>
<p>— L'équipe {settings.BUSINESS_NAME}</p>
"""
await send_email(to, f"Rendez-vous confirmé {settings.BUSINESS_NAME}", html)
async def send_booking_cancelled(to: str, booking_date: str, booking_time: str):
settings = get_settings()
html = f"""
<h2>Annulation de votre rendez-vous</h2>
<p>Bonjour,</p>
<p>Votre rendez-vous du {booking_date} à {booking_time} a été annulé.</p>
<p>Pour toute question, n'hésitez pas à nous contacter.</p>
<p>— L'équipe {settings.BUSINESS_NAME}</p>
"""
await send_email(to, f"Rendez-vous annulé {settings.BUSINESS_NAME}", html)
async def send_order_confirmed(to: str, order_id: str, total: float):
settings = get_settings()
html = f"""
<h2>Commande enregistrée !</h2>
<p>Bonjour,</p>
<p>Merci pour votre commande chez <strong>{settings.BUSINESS_NAME}</strong>.</p>
<p><strong>Numéro de commande :</strong> {order_id}<br>
<strong>Total :</strong> {total:.2f} €</p>
<p>Nous vous contacterons pour confirmer les modalités de livraison et de paiement.</p>
<p>— L'équipe {settings.BUSINESS_NAME}</p>
"""
await send_email(to, f"Commande confirmée {settings.BUSINESS_NAME}", html)

View File

@@ -0,0 +1,194 @@
from uuid import UUID
import asyncpg
from app.exceptions import NotFoundError, OutOfStockError
from app.models.orders import OrderCreate
async def create_order(db: asyncpg.Connection, user_id: str, data: OrderCreate) -> dict:
async with db.transaction():
total = 0.0
line_items = []
for item in data.items:
product = await db.fetchrow(
"SELECT id, name, price, stock_quantity FROM products WHERE id = $1 AND is_hidden = false",
str(item.product_id),
)
if not product:
raise NotFoundError("product")
if product["stock_quantity"] < item.quantity:
raise OutOfStockError(product["name"])
line_items.append({
"product_id": str(item.product_id),
"product_name": product["name"],
"quantity": item.quantity,
"unit_price": float(product["price"]),
})
total += float(product["price"]) * item.quantity
await db.execute(
"UPDATE products SET stock_quantity = stock_quantity - $2 WHERE id = $1",
str(item.product_id), item.quantity,
)
total = round(total, 2)
order = await db.fetchrow(
"""
INSERT INTO orders (user_id, status, total_amount, shipping_address, notes)
VALUES ($1, 'pending', $2, $3, $4)
RETURNING *
""",
user_id, total,
data.shipping_address, data.notes,
)
order_id = str(order["id"])
for li in line_items:
await db.execute(
"""
INSERT INTO order_items (order_id, product_id, quantity, unit_price)
VALUES ($1, $2, $3, $4)
""",
order_id, li["product_id"], li["quantity"], li["unit_price"],
)
return {"order_id": order_id, "amount": total}
async def get_order(db: asyncpg.Connection, order_id: UUID, user_id: str | None = None) -> dict:
query = "SELECT o.* FROM orders o WHERE o.id = $1"
params: list = [str(order_id)]
if user_id:
query += " AND o.user_id = $2"
params.append(user_id)
row = await db.fetchrow(query, *params)
if not row:
raise NotFoundError("order")
items = await db.fetch(
"""
SELECT oi.id, oi.product_id, p.name as product_name, oi.quantity, oi.unit_price
FROM order_items oi
JOIN products p ON p.id = oi.product_id
WHERE oi.order_id = $1
""",
str(order_id),
)
result = dict(row)
result["items"] = [dict(i) for i in items]
return result
async def list_orders(
db: asyncpg.Connection,
page: int,
per_page: int,
offset: int,
user_id: str | None = None,
status: str | None = None,
) -> tuple[list[dict], int]:
conditions: list[str] = []
params: list = []
if user_id:
params.append(user_id)
conditions.append(f"o.user_id = ${len(params)}")
if status:
params.append(status)
conditions.append(f"o.status = ${len(params)}")
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
total = await db.fetchval(f"SELECT COUNT(*) FROM orders o {where}", *params)
params.extend([per_page, offset])
rows = await db.fetch(
f"""
SELECT o.id, o.user_id, o.status, o.total_amount,
o.shipping_address, o.notes, o.created_at, o.updated_at,
p.full_name AS client_name, p.email AS client_email, p.phone AS client_phone
FROM orders o
LEFT JOIN profiles p ON p.id = o.user_id
{where}
ORDER BY o.created_at DESC
LIMIT ${len(params) - 1} OFFSET ${len(params)}
""",
*params,
)
return [dict(r) for r in rows], total
async def update_order_status(db: asyncpg.Connection, order_id: UUID, status: str, actor_id: str) -> dict:
row = await db.fetchrow(
"UPDATE orders SET status = $2, updated_at = now() WHERE id = $1 RETURNING *",
str(order_id), status,
)
if not row:
raise NotFoundError("order")
await _log(db, actor_id, "order.status_updated", "order", str(order_id), {"status": status})
return dict(row)
async def refund_order(db: asyncpg.Connection, order_id: UUID, actor_id: str, amount: float | None, reason: str | None) -> dict:
order = await db.fetchrow("SELECT * FROM orders WHERE id = $1", str(order_id))
if not order:
raise NotFoundError("order")
if not order["stripe_payment_intent_id"]:
from app.exceptions import AppError
raise AppError("NO_PAYMENT", "No payment found for this order", 400)
refund = await stripe_service.create_refund(
order["stripe_payment_intent_id"],
amount=amount,
reason=reason,
)
new_status = "refunded"
row = await db.fetchrow(
"UPDATE orders SET status = $2, stripe_refund_id = $3, updated_at = now() WHERE id = $1 RETURNING *",
str(order_id), new_status, refund.id,
)
await _log(db, actor_id, "order.refunded", "order", str(order_id), {"refund_id": refund.id})
return dict(row)
async def handle_payment_succeeded(db: asyncpg.Connection, payment_intent_id: str, entity_id: str):
row = await db.fetchrow(
"UPDATE orders SET status = 'paid', updated_at = now() WHERE id = $1 AND stripe_payment_intent_id = $2 RETURNING *",
entity_id, payment_intent_id,
)
if row:
user = await db.fetchrow("SELECT email FROM profiles WHERE id = $1", str(row["user_id"]))
if user:
from app.services.email_service import send_order_confirmed
await send_order_confirmed(user["email"], entity_id, float(row["total_amount"]))
async def handle_payment_failed(db: asyncpg.Connection, payment_intent_id: str, entity_id: str):
order = await db.fetchrow("SELECT * FROM orders WHERE id = $1", entity_id)
if not order or order["status"] != "pending":
return
await db.execute(
"UPDATE orders SET status = 'cancelled', updated_at = now() WHERE id = $1",
entity_id,
)
# Restore stock
items = await db.fetch("SELECT product_id, quantity FROM order_items WHERE order_id = $1", entity_id)
for item in items:
await db.execute(
"UPDATE products SET stock_quantity = stock_quantity + $2 WHERE id = $1",
str(item["product_id"]), item["quantity"],
)
async def _log(db, actor_id, action, entity_type, entity_id, metadata=None):
await db.execute(
"INSERT INTO activity_log (actor_id, action, entity_type, entity_id, metadata) VALUES ($1, $2, $3, $4, $5)",
actor_id, action, entity_type, entity_id, metadata,
)

View File

@@ -0,0 +1,159 @@
import json
from uuid import UUID
import asyncpg
from app.exceptions import NotFoundError
from app.models.products import ProductCreate, ProductUpdate
async def list_products(
db: asyncpg.Connection,
page: int,
per_page: int,
offset: int,
include_hidden: bool = False,
category: str | None = None,
bestseller: bool = False,
is_new: bool = False,
search: str | None = None,
exclude_id: str | None = None,
) -> tuple[list[dict], int]:
conditions: list[str] = []
params: list = []
if not include_hidden:
conditions.append("is_hidden = false")
if category:
params.append(category)
conditions.append(f"category = ${len(params)}")
if bestseller:
conditions.append("is_bestseller = true")
if is_new:
conditions.append("is_new = true")
if search:
params.append(f"%{search}%")
conditions.append(f"(name ILIKE ${len(params)} OR description ILIKE ${len(params)})")
if exclude_id:
params.append(exclude_id)
conditions.append(f"id != ${len(params)}")
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
total = await db.fetchval(f"SELECT COUNT(*) FROM products {where}", *params)
params.extend([per_page, offset])
rows = await db.fetch(
f"""
SELECT id, name, description, price, original_price, category,
images, colors, lengths, features, stock_quantity,
is_featured, is_hidden, is_new, is_bestseller,
rating, review_count, created_at, updated_at
FROM products {where}
ORDER BY is_bestseller DESC, is_new DESC, created_at DESC
LIMIT ${len(params) - 1} OFFSET ${len(params)}
""",
*params,
)
return [_row(r) for r in rows], total
async def get_product(db: asyncpg.Connection, product_id: str) -> dict:
row = await db.fetchrow("SELECT * FROM products WHERE id = $1", product_id)
if not row:
raise NotFoundError("product")
return _row(row)
async def create_product(db: asyncpg.Connection, data: ProductCreate) -> dict:
row = await db.fetchrow(
"""
INSERT INTO products (
name, description, price, original_price, category,
colors, lengths, features, stock_quantity,
is_featured, is_hidden, is_new, is_bestseller, images
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,'[]'::jsonb)
RETURNING *
""",
data.name, data.description, data.price, data.original_price, data.category,
data.colors, data.lengths, data.features,
data.stock_quantity, data.is_featured, data.is_hidden, data.is_new, data.is_bestseller,
)
return _row(row)
async def update_product(db: asyncpg.Connection, product_id: str, data: ProductUpdate) -> dict:
await get_product(db, product_id)
updates = {k: v for k, v in data.model_dump().items() if v is not None}
if not updates:
return await get_product(db, product_id)
json_fields = {"colors", "lengths", "features"}
set_parts = []
values = []
for i, (k, v) in enumerate(updates.items()):
set_parts.append(f"{k} = ${i + 2}")
values.append(v)
row = await db.fetchrow(
f"UPDATE products SET {', '.join(set_parts)}, updated_at = now() WHERE id = $1 RETURNING *",
product_id, *values,
)
return _row(row)
async def delete_product(db: asyncpg.Connection, product_id: str):
result = await db.execute("DELETE FROM products WHERE id = $1", product_id)
if result == "DELETE 0":
raise NotFoundError("product")
async def add_image(db: asyncpg.Connection, product_id: str, url: str) -> dict:
await get_product(db, product_id)
row = await db.fetchrow(
"""
UPDATE products
SET images = images || $2, updated_at = now()
WHERE id = $1
RETURNING *
""",
product_id, [url],
)
return _row(row)
async def remove_image(db: asyncpg.Connection, product_id: str, url: str) -> dict:
product = await get_product(db, product_id)
images = [img for img in product["_raw_images"] if img != url]
row = await db.fetchrow(
"UPDATE products SET images = $2, updated_at = now() WHERE id = $1 RETURNING *",
product_id, images,
)
return _row(row)
async def bulk_stock_update(db: asyncpg.Connection, updates: list[dict]):
async with db.transaction():
for u in updates:
await db.execute(
"UPDATE products SET stock_quantity = $2, updated_at = now() WHERE id = $1",
str(u["id"]), u["stock_quantity"],
)
def _row(r) -> dict:
d = dict(r)
# Decode JSONB lists
for field in ("images", "colors", "lengths", "features"):
val = d.get(field, [])
if isinstance(val, str):
val = json.loads(val)
d[field] = val if isinstance(val, list) else []
# images stored as plain URL strings; keep a raw copy for internal use
d["_raw_images"] = d["images"]
# Derive convenience `image` field (first URL for product cards)
d["image"] = d["images"][0] if d["images"] else ""
return d

View File

@@ -0,0 +1,175 @@
from datetime import date, timedelta, datetime, time
import asyncpg
import pytz
from app.config import get_settings
from app.exceptions import NotFoundError, AppError
from app.models.bookings import WeeklyScheduleCreate, SlotCreate
def _fmt_time(t) -> str:
if t is None:
return ""
s = str(t)
return s[:5]
async def get_schedule(db: asyncpg.Connection) -> list[dict]:
rows = await db.fetch("SELECT * FROM weekly_schedule WHERE is_active = true ORDER BY day_of_week, start_time")
return [dict(r) for r in rows]
async def create_schedule_entry(db: asyncpg.Connection, data: WeeklyScheduleCreate) -> dict:
row = await db.fetchrow(
"""
INSERT INTO weekly_schedule (day_of_week, start_time, end_time, slot_duration_minutes)
VALUES ($1, $2, $3, $4)
RETURNING *
""",
data.day_of_week, data.start_time, data.end_time, data.slot_duration_minutes,
)
return dict(row)
async def delete_schedule_entry(db: asyncpg.Connection, schedule_id: str):
result = await db.execute("DELETE FROM weekly_schedule WHERE id = $1", schedule_id)
if result == "DELETE 0":
raise NotFoundError("schedule entry")
async def generate_slots(db: asyncpg.Connection, from_date: date, to_date: date) -> int:
if (to_date - from_date).days > 90:
raise AppError("RANGE_TOO_LARGE", "Date range cannot exceed 90 days", 400)
schedule = await db.fetch("SELECT * FROM weekly_schedule WHERE is_active = true")
if not schedule:
raise AppError("NO_SCHEDULE", "No active weekly schedule configured", 400)
blocked = await db.fetch(
"SELECT date FROM blocked_dates WHERE date BETWEEN $1 AND $2",
from_date, to_date,
)
blocked_set = {r["date"] for r in blocked}
tz = pytz.timezone(get_settings().BUSINESS_TIMEZONE)
created = 0
current = from_date
async with db.transaction():
while current <= to_date:
if current in blocked_set:
current += timedelta(days=1)
continue
day_schedules = [s for s in schedule if s["day_of_week"] == current.weekday()]
for sched in day_schedules:
cursor = datetime.combine(current, sched["start_time"])
end_limit = datetime.combine(current, sched["end_time"])
duration = timedelta(minutes=sched["slot_duration_minutes"])
while cursor + duration <= end_limit:
slot_end = cursor + duration
exists = await db.fetchval(
"SELECT 1 FROM time_slots WHERE date = $1 AND start_time = $2",
current, cursor.time(),
)
if not exists:
await db.execute(
"INSERT INTO time_slots (date, start_time, end_time) VALUES ($1, $2, $3)",
current, cursor.time(), slot_end.time(),
)
created += 1
cursor += duration
current += timedelta(days=1)
return created
async def create_slot(db: asyncpg.Connection, data: SlotCreate) -> dict:
if data.start_time >= data.end_time:
raise AppError("INVALID_SLOT", "start_time must be before end_time", 400)
row = await db.fetchrow(
"INSERT INTO time_slots (date, start_time, end_time) VALUES ($1, $2, $3) RETURNING *",
data.date, data.start_time, data.end_time,
)
return dict(row)
async def delete_slot(db: asyncpg.Connection, slot_id: str):
booked = await db.fetchval(
"SELECT 1 FROM bookings WHERE slot_id = $1 AND status IN ('pending', 'confirmed')",
slot_id,
)
if booked:
raise AppError("SLOT_BOOKED", "Cannot delete a slot with active bookings", 409)
result = await db.execute("DELETE FROM time_slots WHERE id = $1", slot_id)
if result == "DELETE 0":
raise NotFoundError("slot")
async def update_slot(db: asyncpg.Connection, slot_id: str, is_blocked: bool, block_reason: str | None) -> dict:
row = await db.fetchrow(
"UPDATE time_slots SET is_blocked = $2, block_reason = $3 WHERE id = $1 RETURNING *",
slot_id, is_blocked, block_reason,
)
if not row:
raise NotFoundError("slot")
return dict(row)
async def list_slots(
db: asyncpg.Connection,
from_date: date,
to_date: date,
available_only: bool = False,
) -> list[dict]:
query = """
SELECT
ts.id, ts.date, ts.start_time, ts.end_time,
ts.is_blocked, ts.block_reason,
EXISTS(
SELECT 1 FROM bookings b
WHERE b.slot_id = ts.id AND b.status IN ('pending', 'confirmed')
) AS is_booked
FROM time_slots ts
WHERE ts.date BETWEEN $1 AND $2
"""
params: list = [from_date, to_date]
if available_only:
query += " AND ts.is_blocked = false"
query += """
AND NOT EXISTS(
SELECT 1 FROM blocked_dates bd WHERE bd.date = ts.date
)
AND NOT EXISTS(
SELECT 1 FROM bookings b WHERE b.slot_id = ts.id AND b.status IN ('pending', 'confirmed')
)
AND ts.date >= CURRENT_DATE
"""
query += " ORDER BY ts.date, ts.start_time"
rows = await db.fetch(query, *params)
return [dict(r) for r in rows]
async def get_blocked_dates(db: asyncpg.Connection) -> list[dict]:
rows = await db.fetch("SELECT * FROM blocked_dates ORDER BY date")
return [dict(r) for r in rows]
async def add_blocked_date(db: asyncpg.Connection, date: date, reason: str | None) -> dict:
row = await db.fetchrow(
"INSERT INTO blocked_dates (date, reason) VALUES ($1, $2) ON CONFLICT (date) DO UPDATE SET reason = $2 RETURNING *",
date, reason,
)
return dict(row)
async def remove_blocked_date(db: asyncpg.Connection, blocked_date_id: str):
result = await db.execute("DELETE FROM blocked_dates WHERE id = $1", blocked_date_id)
if result == "DELETE 0":
raise NotFoundError("blocked date")

View File

@@ -0,0 +1,36 @@
import io
import uuid
from supabase import create_client
from app.config import get_settings
BUCKET = "product-images"
def _client():
s = get_settings()
return create_client(s.SUPABASE_URL, s.SUPABASE_SERVICE_ROLE_KEY)
async def upload_product_image(product_id: str, file_bytes: bytes, content_type: str) -> dict:
ext = "jpg" if "jpeg" in content_type else content_type.split("/")[-1]
path = f"{product_id}/{uuid.uuid4()}.{ext}"
client = _client()
result = client.storage.from_(BUCKET).upload(
path,
file_bytes,
{"content-type": content_type, "upsert": False},
)
# supabase-py v2 raises StorageException on failure, but guard against
# unexpected response shapes that silently skip the upload
if hasattr(result, "error") and result.error:
raise RuntimeError(f"Storage upload failed: {result.error}")
url = client.storage.from_(BUCKET).get_public_url(path)
return {"url": url, "storage_path": path}
async def delete_product_image(storage_path: str):
_client().storage.from_(BUCKET).remove([storage_path])

View File

@@ -0,0 +1,61 @@
import asyncio
import stripe
from app.config import get_settings
from app.exceptions import PaymentError
_stripe_initialized = False
def _init_stripe():
global _stripe_initialized
if not _stripe_initialized:
stripe.api_key = get_settings().STRIPE_SECRET_KEY
_stripe_initialized = True
async def create_payment_intent(
amount: float,
metadata: dict,
description: str = "",
) -> stripe.PaymentIntent:
_init_stripe()
settings = get_settings()
try:
intent = await asyncio.to_thread(
stripe.PaymentIntent.create,
amount=int(round(amount * 100)),
currency=settings.STRIPE_CURRENCY,
metadata=metadata,
description=description,
automatic_payment_methods={"enabled": True},
)
return intent
except stripe.StripeError as e:
raise PaymentError(str(e))
async def create_refund(
payment_intent_id: str,
amount: float | None = None,
reason: str | None = None,
) -> stripe.Refund:
_init_stripe()
try:
kwargs: dict = {"payment_intent": payment_intent_id}
if amount is not None:
kwargs["amount"] = int(round(amount * 100))
if reason:
kwargs["reason"] = reason
return await asyncio.to_thread(stripe.Refund.create, **kwargs)
except stripe.StripeError as e:
raise PaymentError(str(e))
def verify_webhook(payload: bytes, sig_header: str) -> stripe.Event:
_init_stripe()
settings = get_settings()
try:
return stripe.Webhook.construct_event(payload, sig_header, settings.STRIPE_WEBHOOK_SECRET)
except stripe.SignatureVerificationError:
raise PaymentError("Invalid webhook signature")