Initial commit for iFixKart Backend
30
.env.production
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
# Production Server Database Credentials & URLs
|
||||||
|
DATABASE_URL=mysql+pymysql://Adithiyan:Adithiyan$2025Mysql@127.0.0.1:3306/ifixkartecommerceadmin
|
||||||
|
CORE_DATABASE_URL=mysql+pymysql://Adithiyan:Adithiyan$2025Mysql@127.0.0.1:3306/ifixkartecommerceadmin
|
||||||
|
CRM_DATABASE_URL=mysql+pymysql://Adithiyan:Adithiyan$2025Mysql@127.0.0.1:3306/ifixkartcrm
|
||||||
|
COMMERCE_DATABASE_URL=mysql+pymysql://Adithiyan:Adithiyan$2025Mysql@127.0.0.1:3306/ifixkartecommerce
|
||||||
|
|
||||||
|
# Server JWT & Auth
|
||||||
|
JWT_PRIVATE_KEY_PATH=jwt_private.pem
|
||||||
|
JWT_PUBLIC_KEY_PATH=jwt_public.pem
|
||||||
|
ALGORITHM=RS512
|
||||||
|
ACCESS_TOKEN_EXPIRE_MIN=1440
|
||||||
|
REFRESH_TOKEN_EXPIRE_DAYS=120
|
||||||
|
PROJECT_NAME=iFixKart
|
||||||
|
MAX_FAILED_LOGIN=5
|
||||||
|
LOCKOUT_MINUTES=15
|
||||||
|
PUBLIC_API_KEY=ifixkart_public_key_2026
|
||||||
|
SECRET_KEY=9a3b6c4d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b
|
||||||
|
|
||||||
|
# Server Environment & Domain Endpoints
|
||||||
|
ENVIRONMENT=production
|
||||||
|
RUNTIME_ENV=production
|
||||||
|
GOOGLE_CLIENT_ID=395995422555-3gf9b434jqf471qbhumdme72noudqptg.apps.googleusercontent.com
|
||||||
|
GOOGLE_CLIENT_SECRET=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
|
||||||
|
GOOGLE_REDIRECT_URI=https://ifixkartbe.trionixsolution.com/api/auth/google/callback
|
||||||
|
|
||||||
|
# Payment Gateway
|
||||||
|
RAZORPAY_KEY_ID=rzp_test_TTUzPFYF0hRV89
|
||||||
|
RAZORPAY_KEY_SECRET=74tmlSkS4qK7zQaH1zclQfeH
|
||||||
|
RAZORPAY_WEBHOOK_SECRET=6TjjXgErPG3@ZpM
|
||||||
|
RAZORPAY_ENABLED=true
|
||||||
10
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.env
|
||||||
|
local_ifixkart.db
|
||||||
|
*.zip
|
||||||
|
*.log
|
||||||
|
.pytest_cache/
|
||||||
0
app/__init__.py
Normal file
0
app/api/__init__.py
Normal file
BIN
app/api/uploads/storefront/01KZ8A4G881X90RKWANGBHSWZM.webp
Normal file
|
After Width: | Height: | Size: 998 KiB |
BIN
app/api/uploads/storefront/01KZ8A4G881X90RKWANGBHSWZM_large.webp
Normal file
|
After Width: | Height: | Size: 340 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 32 KiB |
BIN
app/api/uploads/storefront/01KZ8A4RE8MF9MPXGCA79SFWBB.webp
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
app/api/uploads/storefront/01KZ8A4RE8MF9MPXGCA79SFWBB_large.webp
Normal file
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 11 KiB |
BIN
app/api/uploads/storefront/01KZ8B6TTWKMJEWMVAG13SR8R3.webp
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
app/api/uploads/storefront/01KZ8B6TTWKMJEWMVAG13SR8R3_large.webp
Normal file
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
0
app/api/v1/__init__.py
Normal file
132
app/api/v1/routers/AdminCustomerRouter.py
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
"""
|
||||||
|
@router AdminCustomerRouter (Backend/app/api/v1/routers/AdminCustomerRouter.py)
|
||||||
|
@purpose CRM Admin endpoint controllers to search, list, and inspect storefront e-commerce customers, addresses, and order history.
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
from typing import List, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.core.database.db_session import get_db
|
||||||
|
from app.core.permissions.RoleChecker import get_current_user
|
||||||
|
from app.models.UserModel import User
|
||||||
|
from app.models.EcomCustomerModel import EcomCustomer, CustomerAddress
|
||||||
|
from app.models.OrderModel import Order
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/admin/customers", tags=["Admin CRM Storefront Customers"])
|
||||||
|
|
||||||
|
# --- Schemas ---
|
||||||
|
|
||||||
|
class CustomerAddressSchema(BaseModel):
|
||||||
|
address_id: str
|
||||||
|
address_type: str
|
||||||
|
full_name: str
|
||||||
|
phone: str
|
||||||
|
street_address: str
|
||||||
|
city: str
|
||||||
|
state: str
|
||||||
|
pincode: str
|
||||||
|
is_default: bool
|
||||||
|
|
||||||
|
class CustomerOrderSummary(BaseModel):
|
||||||
|
order_id: str
|
||||||
|
order_no: str
|
||||||
|
final_amount: float
|
||||||
|
status: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class AdminCustomerListResponse(BaseModel):
|
||||||
|
customer_id: str
|
||||||
|
email: str
|
||||||
|
first_name: str
|
||||||
|
last_name: str
|
||||||
|
phone: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class AdminCustomerDetailResponse(BaseModel):
|
||||||
|
customer_id: str
|
||||||
|
email: str
|
||||||
|
first_name: str
|
||||||
|
last_name: str
|
||||||
|
phone: Optional[str] = None
|
||||||
|
profile_picture: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
addresses: List[CustomerAddressSchema]
|
||||||
|
orders: List[CustomerOrderSummary]
|
||||||
|
|
||||||
|
# --- Endpoints ---
|
||||||
|
|
||||||
|
@router.get("", response_model=List[AdminCustomerListResponse])
|
||||||
|
def list_all_customers(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
List all registered storefront customers.
|
||||||
|
"""
|
||||||
|
customers = db.query(EcomCustomer).order_by(EcomCustomer.created_at.desc()).all()
|
||||||
|
return [
|
||||||
|
AdminCustomerListResponse(
|
||||||
|
customer_id=c.customer_id,
|
||||||
|
email=c.email,
|
||||||
|
first_name=c.first_name,
|
||||||
|
last_name=c.last_name,
|
||||||
|
phone=c.phone,
|
||||||
|
created_at=c.created_at
|
||||||
|
) for c in customers
|
||||||
|
]
|
||||||
|
|
||||||
|
@router.get("/{customer_id}", response_model=AdminCustomerDetailResponse)
|
||||||
|
def get_customer_details(
|
||||||
|
customer_id: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Retrieve deep profile details, saved address books, and complete sales purchase history of a storefront customer.
|
||||||
|
"""
|
||||||
|
customer = db.query(EcomCustomer).filter(EcomCustomer.customer_id == customer_id).first()
|
||||||
|
if not customer:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Storefront customer not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch addresses
|
||||||
|
addresses = db.query(CustomerAddress).filter(CustomerAddress.customer_id == customer_id).all()
|
||||||
|
|
||||||
|
# Fetch orders
|
||||||
|
orders = db.query(Order).filter(Order.customer_id == customer_id).order_by(Order.created_at.desc()).all()
|
||||||
|
|
||||||
|
return AdminCustomerDetailResponse(
|
||||||
|
customer_id=customer.customer_id,
|
||||||
|
email=customer.email,
|
||||||
|
first_name=customer.first_name,
|
||||||
|
last_name=customer.last_name,
|
||||||
|
phone=customer.phone,
|
||||||
|
profile_picture=customer.profile_picture,
|
||||||
|
created_at=customer.created_at,
|
||||||
|
addresses=[
|
||||||
|
CustomerAddressSchema(
|
||||||
|
address_id=a.address_id,
|
||||||
|
address_type=a.address_type,
|
||||||
|
full_name=a.full_name,
|
||||||
|
phone=a.phone,
|
||||||
|
street_address=a.street_address,
|
||||||
|
city=a.city,
|
||||||
|
state=a.state,
|
||||||
|
pincode=a.pincode,
|
||||||
|
is_default=a.is_default
|
||||||
|
) for a in addresses
|
||||||
|
],
|
||||||
|
orders=[
|
||||||
|
CustomerOrderSummary(
|
||||||
|
order_id=o.order_id,
|
||||||
|
order_no=o.order_no,
|
||||||
|
final_amount=float(o.final_amount),
|
||||||
|
status=o.status,
|
||||||
|
created_at=o.created_at
|
||||||
|
) for o in orders
|
||||||
|
]
|
||||||
|
)
|
||||||
299
app/api/v1/routers/AdminInvoiceRouter.py
Normal file
|
|
@ -0,0 +1,299 @@
|
||||||
|
"""
|
||||||
|
@router AdminInvoiceRouter (Backend/app/api/v1/routers/AdminInvoiceRouter.py)
|
||||||
|
@purpose CRM Admin endpoint controllers to query, generate, and print standard PDF and 80mm thermal roll GST invoices on-the-fly.
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, Response
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import List, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
import ulid
|
||||||
|
import io
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
from app.core.database.db_session import get_db
|
||||||
|
from app.core.permissions.RoleChecker import get_current_user
|
||||||
|
from app.models.UserModel import User
|
||||||
|
from app.models.InvoiceModel import Invoice
|
||||||
|
from app.models.OrderModel import Order, OrderItem
|
||||||
|
from app.models.EcomCustomerModel import EcomCustomer
|
||||||
|
from app.core.invoice_generator import generate_invoice_pdf, generate_thermal_invoice_pdf
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/admin/invoices", tags=["Admin CRM Customer Invoices"])
|
||||||
|
|
||||||
|
# --- Schemas ---
|
||||||
|
|
||||||
|
class AdminInvoiceResponse(BaseModel):
|
||||||
|
invoice_id: str
|
||||||
|
invoice_no: str
|
||||||
|
order_id: Optional[str] = None
|
||||||
|
customer_id: str
|
||||||
|
customer_email: Optional[str] = None
|
||||||
|
subtotal: float
|
||||||
|
discount_amount: float
|
||||||
|
cgst: float
|
||||||
|
sgst: float
|
||||||
|
igst: float
|
||||||
|
total_amount: float
|
||||||
|
status: str
|
||||||
|
pdf_url: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
# --- Endpoints ---
|
||||||
|
|
||||||
|
@router.get("", response_model=List[AdminInvoiceResponse])
|
||||||
|
def list_all_invoices(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
List all generated GST invoices in the system.
|
||||||
|
"""
|
||||||
|
invoices = db.query(Invoice).order_by(Invoice.created_at.desc()).all()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for inv in invoices:
|
||||||
|
cust_email = None
|
||||||
|
customer = db.query(EcomCustomer).filter(EcomCustomer.customer_id == inv.customer_id).first()
|
||||||
|
if customer:
|
||||||
|
cust_email = customer.email
|
||||||
|
|
||||||
|
pdf_url = f"/api/v1/admin/invoices/{inv.invoice_id}/download"
|
||||||
|
|
||||||
|
result.append(
|
||||||
|
AdminInvoiceResponse(
|
||||||
|
invoice_id=inv.invoice_id,
|
||||||
|
invoice_no=inv.invoice_no,
|
||||||
|
order_id=inv.order_id,
|
||||||
|
customer_id=inv.customer_id,
|
||||||
|
customer_email=cust_email,
|
||||||
|
subtotal=float(inv.subtotal),
|
||||||
|
discount_amount=float(inv.discount_amount),
|
||||||
|
cgst=float(inv.cgst),
|
||||||
|
sgst=float(inv.sgst),
|
||||||
|
igst=float(inv.igst),
|
||||||
|
total_amount=float(inv.total_amount),
|
||||||
|
status=inv.status,
|
||||||
|
pdf_url=pdf_url,
|
||||||
|
created_at=inv.created_at
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
@router.post("/generate/{order_id}", response_model=AdminInvoiceResponse)
|
||||||
|
def generate_invoice_for_order(
|
||||||
|
order_id: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Generate a new GST invoice for a confirmed order.
|
||||||
|
"""
|
||||||
|
# Check if invoice already exists
|
||||||
|
existing = db.query(Invoice).filter(Invoice.order_id == order_id).first()
|
||||||
|
if existing:
|
||||||
|
cust_email = None
|
||||||
|
customer = db.query(EcomCustomer).filter(EcomCustomer.customer_id == existing.customer_id).first()
|
||||||
|
if customer:
|
||||||
|
cust_email = customer.email
|
||||||
|
return AdminInvoiceResponse(
|
||||||
|
invoice_id=existing.invoice_id,
|
||||||
|
invoice_no=existing.invoice_no,
|
||||||
|
order_id=existing.order_id,
|
||||||
|
customer_id=existing.customer_id,
|
||||||
|
customer_email=cust_email,
|
||||||
|
subtotal=float(existing.subtotal),
|
||||||
|
discount_amount=float(existing.discount_amount),
|
||||||
|
cgst=float(existing.cgst),
|
||||||
|
sgst=float(existing.sgst),
|
||||||
|
igst=float(existing.igst),
|
||||||
|
total_amount=float(existing.total_amount),
|
||||||
|
status=existing.status,
|
||||||
|
pdf_url=f"/api/v1/admin/invoices/{existing.invoice_id}/download",
|
||||||
|
created_at=existing.created_at
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch order details
|
||||||
|
order = db.query(Order).filter(Order.order_id == order_id).first()
|
||||||
|
if not order:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Order not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# GST calculation matching order details
|
||||||
|
subtotal = float(order.total_amount)
|
||||||
|
discount = float(order.discount_amount)
|
||||||
|
tax_amount = float(order.tax_amount)
|
||||||
|
cgst = round(tax_amount / 2, 2)
|
||||||
|
sgst = round(tax_amount / 2, 2)
|
||||||
|
igst = 0.0
|
||||||
|
total = float(order.final_amount)
|
||||||
|
|
||||||
|
# Allocate a 16-char GST invoice number
|
||||||
|
# Format: C1P2-26-SEQ
|
||||||
|
today = datetime.now()
|
||||||
|
fy = today.strftime("%y") # financial year seq, e.g. 26
|
||||||
|
last_inv = db.query(Invoice).order_by(Invoice.created_at.desc()).first()
|
||||||
|
if not last_inv:
|
||||||
|
seq = 1
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
seq = int(last_inv.invoice_no.split("-")[-1]) + 1
|
||||||
|
except Exception:
|
||||||
|
seq = 1
|
||||||
|
invoice_no = f"C1P1-{fy}-{seq:06d}"
|
||||||
|
|
||||||
|
invoice = Invoice(
|
||||||
|
invoice_id=str(ulid.ULID()),
|
||||||
|
invoice_no=invoice_no,
|
||||||
|
order_id=order.order_id,
|
||||||
|
customer_id=order.customer_id,
|
||||||
|
subtotal=subtotal,
|
||||||
|
discount_amount=discount,
|
||||||
|
cgst=cgst,
|
||||||
|
sgst=sgst,
|
||||||
|
igst=igst,
|
||||||
|
total_amount=total,
|
||||||
|
pdf_path=f"uploads/invoices/{invoice_no}.pdf" # virtual pointer path
|
||||||
|
)
|
||||||
|
db.add(invoice)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(invoice)
|
||||||
|
|
||||||
|
cust_email = None
|
||||||
|
if order.customer:
|
||||||
|
cust_email = order.customer.email
|
||||||
|
|
||||||
|
return AdminInvoiceResponse(
|
||||||
|
invoice_id=invoice.invoice_id,
|
||||||
|
invoice_no=invoice.invoice_no,
|
||||||
|
order_id=invoice.order_id,
|
||||||
|
customer_id=invoice.customer_id,
|
||||||
|
customer_email=cust_email,
|
||||||
|
subtotal=float(invoice.subtotal),
|
||||||
|
discount_amount=float(invoice.discount_amount),
|
||||||
|
cgst=float(invoice.cgst),
|
||||||
|
sgst=float(invoice.sgst),
|
||||||
|
igst=float(invoice.igst),
|
||||||
|
total_amount=float(invoice.total_amount),
|
||||||
|
status=invoice.status,
|
||||||
|
pdf_url=f"/api/v1/admin/invoices/{invoice.invoice_id}/download",
|
||||||
|
created_at=invoice.created_at
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/{invoice_id}/download")
|
||||||
|
def download_invoice_pdf(
|
||||||
|
invoice_id: str,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Generate and serve standard Letter-sized PDF GST Invoice on-the-fly.
|
||||||
|
"""
|
||||||
|
invoice = db.query(Invoice).filter(Invoice.invoice_id == invoice_id).first()
|
||||||
|
if not invoice:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Invoice not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
order = db.query(Order).filter(Order.order_id == invoice.order_id).first()
|
||||||
|
if not order:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Sales order record missing, cannot generate PDF invoice"
|
||||||
|
)
|
||||||
|
|
||||||
|
customer = db.query(EcomCustomer).filter(EcomCustomer.customer_id == invoice.customer_id).first()
|
||||||
|
order_items = db.query(OrderItem).filter(OrderItem.order_id == invoice.order_id).all()
|
||||||
|
|
||||||
|
try:
|
||||||
|
pdf_bytes = generate_invoice_pdf(order, customer, order_items, db=db)
|
||||||
|
return Response(
|
||||||
|
content=pdf_bytes,
|
||||||
|
media_type="application/pdf",
|
||||||
|
headers={"Content-Disposition": f"inline; filename={invoice.invoice_no}.pdf"}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to compile PDF on-the-fly: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/{invoice_id}/thermal-download")
|
||||||
|
def download_thermal_invoice_pdf(
|
||||||
|
invoice_id: str,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Generate and serve compact 80mm thermal receipt PDF on-the-fly.
|
||||||
|
"""
|
||||||
|
invoice = db.query(Invoice).filter(Invoice.invoice_id == invoice_id).first()
|
||||||
|
if not invoice:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Invoice not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
order = db.query(Order).filter(Order.order_id == invoice.order_id).first()
|
||||||
|
if not order:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Sales order record missing, cannot generate PDF invoice"
|
||||||
|
)
|
||||||
|
|
||||||
|
customer = db.query(EcomCustomer).filter(EcomCustomer.customer_id == invoice.customer_id).first()
|
||||||
|
order_items = db.query(OrderItem).filter(OrderItem.order_id == invoice.order_id).all()
|
||||||
|
|
||||||
|
try:
|
||||||
|
pdf_bytes = generate_thermal_invoice_pdf(order, customer, order_items)
|
||||||
|
return Response(
|
||||||
|
content=pdf_bytes,
|
||||||
|
media_type="application/pdf",
|
||||||
|
headers={"Content-Disposition": f"inline; filename=thermal_{invoice.invoice_no}.pdf"}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to compile thermal receipt PDF: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/export/excel")
|
||||||
|
def export_invoices_excel(
|
||||||
|
start_date: Optional[str] = None,
|
||||||
|
end_date: Optional[str] = None,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
invoices = db.query(Invoice).order_by(Invoice.created_at.desc()).all()
|
||||||
|
csv_data = "Invoice No,Order ID,Customer ID,Subtotal,CGST,SGST,Total Amount,Created At\n"
|
||||||
|
for inv in invoices:
|
||||||
|
csv_data += f"{inv.invoice_no},{inv.order_id},{inv.customer_id},{inv.subtotal},{inv.cgst},{inv.sgst},{inv.total_amount},{inv.created_at}\n"
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=csv_data,
|
||||||
|
media_type="text/csv",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=invoices_export.csv"}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/export/zip")
|
||||||
|
def export_invoices_zip(
|
||||||
|
start_date: Optional[str] = None,
|
||||||
|
end_date: Optional[str] = None,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
invoices = db.query(Invoice).order_by(Invoice.created_at.desc()).all()
|
||||||
|
|
||||||
|
zip_buffer = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
|
||||||
|
summary = "Invoice No,Order ID,Customer ID,Total Amount\n"
|
||||||
|
for inv in invoices:
|
||||||
|
summary += f"{inv.invoice_no},{inv.order_id},{inv.customer_id},{inv.total_amount}\n"
|
||||||
|
zip_file.writestr("summary.csv", summary)
|
||||||
|
zip_file.writestr("read_me.txt", "Sassynest CRM invoice bulk dump package.")
|
||||||
|
|
||||||
|
zip_buffer.seek(0)
|
||||||
|
return Response(
|
||||||
|
content=zip_buffer.getvalue(),
|
||||||
|
media_type="application/zip",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=invoices_export.zip"}
|
||||||
|
)
|
||||||
400
app/api/v1/routers/AdminOrderRouter.py
Normal file
|
|
@ -0,0 +1,400 @@
|
||||||
|
"""
|
||||||
|
@router AdminOrderRouter (Backend/app/api/v1/routers/AdminOrderRouter.py)
|
||||||
|
@purpose CRM Admin endpoint controllers to search, view, and modify storefront customer orders and trigger status transitions.
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import List, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
import ulid
|
||||||
|
import json
|
||||||
|
|
||||||
|
from app.core.database.db_session import get_db
|
||||||
|
from app.core.permissions.RoleChecker import get_current_user
|
||||||
|
from app.models.UserModel import User
|
||||||
|
from app.models.OrderModel import Order, OrderItem, OrderStatusHistory
|
||||||
|
from app.models.EcomCustomerModel import EcomCustomer
|
||||||
|
from app.services.InventoryService import record_ledger_entry
|
||||||
|
from app.models.InventoryLedgerModel import InventoryLedger
|
||||||
|
from app.models.InvoiceModel import Invoice
|
||||||
|
from app.models.ProductModel import ProductVariant
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/admin/orders", tags=["Admin CRM Customer Orders"])
|
||||||
|
|
||||||
|
# --- Schemas ---
|
||||||
|
|
||||||
|
class OrderItemDetail(BaseModel):
|
||||||
|
product_id: str
|
||||||
|
variant_id: str
|
||||||
|
product_name: str
|
||||||
|
sku: str
|
||||||
|
unit_price: float
|
||||||
|
quantity: int
|
||||||
|
total_price: float
|
||||||
|
|
||||||
|
class StatusHistorySchema(BaseModel):
|
||||||
|
previous_status: Optional[str]
|
||||||
|
new_status: str
|
||||||
|
changed_by: str
|
||||||
|
reason: Optional[str]
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class AdminOrderDetailResponse(BaseModel):
|
||||||
|
order_id: str
|
||||||
|
order_no: str
|
||||||
|
customer_id: Optional[str]
|
||||||
|
customer_email: Optional[str] = None
|
||||||
|
customer_name: Optional[str] = None
|
||||||
|
total_amount: float
|
||||||
|
discount_amount: float
|
||||||
|
tax_amount: float
|
||||||
|
shipping_cost: float
|
||||||
|
final_amount: float
|
||||||
|
status: str
|
||||||
|
payment_status: str
|
||||||
|
fulfillment_status: str
|
||||||
|
shipping_address_json: Optional[str] = None
|
||||||
|
billing_address_json: Optional[str] = None
|
||||||
|
tracking_number: Optional[str] = None
|
||||||
|
courier_name: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
items: List[OrderItemDetail]
|
||||||
|
history: List[StatusHistorySchema]
|
||||||
|
|
||||||
|
class AdminOrderListResponse(BaseModel):
|
||||||
|
order_id: str
|
||||||
|
order_no: str
|
||||||
|
customer_email: Optional[str] = None
|
||||||
|
customer_name: Optional[str] = None
|
||||||
|
customer_phone: Optional[str] = None
|
||||||
|
final_amount: float
|
||||||
|
status: str
|
||||||
|
payment_status: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class StatusUpdatePayload(BaseModel):
|
||||||
|
status: str
|
||||||
|
tracking_number: Optional[str] = None
|
||||||
|
courier_name: Optional[str] = None
|
||||||
|
reason: Optional[str] = None
|
||||||
|
|
||||||
|
# --- Endpoints ---
|
||||||
|
|
||||||
|
@router.get("", response_model=List[AdminOrderListResponse])
|
||||||
|
def list_all_orders(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
List all customer orders in the system.
|
||||||
|
"""
|
||||||
|
orders = db.query(Order).order_by(Order.created_at.desc()).all()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for o in orders:
|
||||||
|
cust_email = None
|
||||||
|
cust_name = None
|
||||||
|
cust_phone = None
|
||||||
|
if o.customer:
|
||||||
|
cust_email = o.customer.email
|
||||||
|
cust_name = f"{o.customer.first_name} {o.customer.last_name}".strip()
|
||||||
|
cust_phone = o.customer.phone
|
||||||
|
elif o.shipping_address_json or o.billing_address_json:
|
||||||
|
try:
|
||||||
|
addr = json.loads(o.shipping_address_json or o.billing_address_json or "{}")
|
||||||
|
cust_name = addr.get("full_name") or addr.get("name")
|
||||||
|
cust_phone = addr.get("phone")
|
||||||
|
cust_email = addr.get("email")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
result.append(
|
||||||
|
AdminOrderListResponse(
|
||||||
|
order_id=o.order_id,
|
||||||
|
order_no=o.order_no,
|
||||||
|
customer_email=cust_email or "N/A",
|
||||||
|
customer_name=cust_name or "Walk-in Guest",
|
||||||
|
customer_phone=cust_phone or "N/A",
|
||||||
|
final_amount=float(o.final_amount),
|
||||||
|
status=o.status,
|
||||||
|
payment_status=o.payment_status,
|
||||||
|
created_at=o.created_at
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
@router.get("/{order_id}", response_model=AdminOrderDetailResponse)
|
||||||
|
def get_order_details(
|
||||||
|
order_id: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Retrieve full details for any order including items, customer metadata, and status logs.
|
||||||
|
"""
|
||||||
|
order = db.query(Order).filter(Order.order_id == order_id).first()
|
||||||
|
if not order:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Order not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
cust_email = None
|
||||||
|
cust_name = None
|
||||||
|
if order.customer:
|
||||||
|
cust_email = order.customer.email
|
||||||
|
cust_name = f"{order.customer.first_name} {order.customer.last_name}"
|
||||||
|
|
||||||
|
return AdminOrderDetailResponse(
|
||||||
|
order_id=order.order_id,
|
||||||
|
order_no=order.order_no,
|
||||||
|
customer_id=order.customer_id,
|
||||||
|
customer_email=cust_email,
|
||||||
|
customer_name=cust_name,
|
||||||
|
total_amount=float(order.total_amount),
|
||||||
|
discount_amount=float(order.discount_amount),
|
||||||
|
tax_amount=float(order.tax_amount),
|
||||||
|
shipping_cost=float(order.shipping_cost),
|
||||||
|
final_amount=float(order.final_amount),
|
||||||
|
status=order.status,
|
||||||
|
payment_status=order.payment_status,
|
||||||
|
fulfillment_status=order.fulfillment_status,
|
||||||
|
shipping_address_json=order.shipping_address_json,
|
||||||
|
billing_address_json=order.billing_address_json,
|
||||||
|
tracking_number=order.tracking_number,
|
||||||
|
courier_name=order.courier_name,
|
||||||
|
created_at=order.created_at,
|
||||||
|
items=[
|
||||||
|
OrderItemDetail(
|
||||||
|
product_id=item.product_id,
|
||||||
|
variant_id=item.variant_id,
|
||||||
|
product_name=item.product_name,
|
||||||
|
sku=item.sku,
|
||||||
|
unit_price=float(item.unit_price),
|
||||||
|
quantity=item.quantity,
|
||||||
|
total_price=float(item.total_price)
|
||||||
|
) for item in order.items
|
||||||
|
],
|
||||||
|
history=[
|
||||||
|
StatusHistorySchema(
|
||||||
|
previous_status=h.previous_status,
|
||||||
|
new_status=h.new_status,
|
||||||
|
changed_by=h.changed_by,
|
||||||
|
reason=h.reason,
|
||||||
|
created_at=h.created_at
|
||||||
|
) for h in order.history
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.put("/{order_id}/status")
|
||||||
|
def update_order_status(
|
||||||
|
order_id: str,
|
||||||
|
payload: StatusUpdatePayload,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Transition an order status, record timeline logs, write ledger confirmations/releases, and update tracking coordinates.
|
||||||
|
"""
|
||||||
|
order = db.query(Order).filter(Order.order_id == order_id).first()
|
||||||
|
if not order:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Order not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
previous_status = order.status
|
||||||
|
new_status = payload.status
|
||||||
|
|
||||||
|
# Transition tracking info if provided
|
||||||
|
if payload.tracking_number:
|
||||||
|
order.tracking_number = payload.tracking_number
|
||||||
|
if payload.courier_name:
|
||||||
|
order.courier_name = payload.courier_name
|
||||||
|
|
||||||
|
# Set fulfillment status flags
|
||||||
|
if new_status in ["SHIPPED", "DELIVERED"]:
|
||||||
|
order.fulfillment_status = "FULFILLED"
|
||||||
|
elif new_status == "CANCELLED":
|
||||||
|
order.fulfillment_status = "CANCELLED"
|
||||||
|
|
||||||
|
# --- STOCK CONFIRMATION / RELEASE LOGIC ---
|
||||||
|
|
||||||
|
# 1. Transition: ORDER_CREATED/PAYMENT_PENDING -> ORDER_CONFIRMED/PROCESSING/SHIPPED/DELIVERED
|
||||||
|
if new_status in ["ORDER_CONFIRMED", "PROCESSING", "SHIPPED", "DELIVERED"] and previous_status in ["ORDER_CREATED", "PAYMENT_PENDING"]:
|
||||||
|
# Record the physical sale transition in the ledger by subtracting quantity
|
||||||
|
for item in order.items:
|
||||||
|
record_ledger_entry(
|
||||||
|
variant_id=item.variant_id,
|
||||||
|
event_type="ONLINE_SALE_FROM_RESERVATION",
|
||||||
|
qty=-item.quantity, # Deduct stock upon confirmation
|
||||||
|
reference_id=order.order_id,
|
||||||
|
db=db,
|
||||||
|
notes=f"Physical stock sale confirmed from reservation for order {order.order_no}",
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
order.payment_status = "PAYMENT_CAPTURED"
|
||||||
|
|
||||||
|
# 2. Transition: Active -> CANCELLED
|
||||||
|
elif new_status == "CANCELLED" and previous_status != "CANCELLED":
|
||||||
|
# Check if the order was already confirmed
|
||||||
|
was_confirmed = db.query(InventoryLedger).filter(
|
||||||
|
InventoryLedger.reference_id == order.order_id,
|
||||||
|
InventoryLedger.event_type == "ONLINE_SALE_FROM_RESERVATION"
|
||||||
|
).first() is not None
|
||||||
|
|
||||||
|
# Check return eligibility: eligible if never delivered, or delivered within last 6 days
|
||||||
|
can_restore = True
|
||||||
|
if previous_status == "DELIVERED":
|
||||||
|
del_history = db.query(OrderStatusHistory).filter(
|
||||||
|
OrderStatusHistory.order_id == order.order_id,
|
||||||
|
OrderStatusHistory.new_status == "DELIVERED"
|
||||||
|
).order_by(OrderStatusHistory.created_at.desc()).first()
|
||||||
|
del_time = del_history.created_at if del_history else order.updated_at
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
now = datetime.now(del_time.tzinfo) if del_time.tzinfo else datetime.now()
|
||||||
|
delta = now - del_time
|
||||||
|
if delta.days >= 6:
|
||||||
|
can_restore = False
|
||||||
|
|
||||||
|
for item in order.items:
|
||||||
|
if was_confirmed:
|
||||||
|
if can_restore:
|
||||||
|
# Record RETURN (+qty) to restore physical and available stock if within 6 days
|
||||||
|
record_ledger_entry(
|
||||||
|
variant_id=item.variant_id,
|
||||||
|
event_type="RETURN",
|
||||||
|
qty=item.quantity,
|
||||||
|
reference_id=order.order_id,
|
||||||
|
db=db,
|
||||||
|
notes=f"Restored stock from cancelled confirmed order {order.order_no} within 6-day window",
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Beyond 6 days, do not restore stock (write 0 delta)
|
||||||
|
record_ledger_entry(
|
||||||
|
variant_id=item.variant_id,
|
||||||
|
event_type="RETURN",
|
||||||
|
qty=0,
|
||||||
|
reference_id=order.order_id,
|
||||||
|
db=db,
|
||||||
|
notes=f"Stock not restored: returned order {order.order_no} was past 6-day return window",
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# If not confirmed yet, release the ONLINE_RESERVE reservation (qty=0)
|
||||||
|
record_ledger_entry(
|
||||||
|
variant_id=item.variant_id,
|
||||||
|
event_type="ONLINE_RESERVE_RELEASE",
|
||||||
|
qty=0,
|
||||||
|
reference_id=order.order_id,
|
||||||
|
db=db,
|
||||||
|
notes=f"Released reservation for cancelled order {order.order_no}",
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
order.payment_status = "REFUNDED" if order.payment_status == "PAYMENT_CAPTURED" else "PAYMENT_FAILED"
|
||||||
|
|
||||||
|
# Add change log
|
||||||
|
history = OrderStatusHistory(
|
||||||
|
history_id=str(ulid.ULID()),
|
||||||
|
order_id=order.order_id,
|
||||||
|
previous_status=previous_status,
|
||||||
|
new_status=new_status,
|
||||||
|
changed_by=current_user.email,
|
||||||
|
reason=payload.reason or "Status updated via CRM Admin Panel"
|
||||||
|
)
|
||||||
|
db.add(history)
|
||||||
|
|
||||||
|
# Set status
|
||||||
|
order.status = new_status
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"message": "Order status updated successfully", "order_id": order_id, "new_status": order.status}
|
||||||
|
|
||||||
|
# --- Manual Order Creation Schemas ---
|
||||||
|
|
||||||
|
class AdminOrderCreateItem(BaseModel):
|
||||||
|
variant_id: str
|
||||||
|
quantity: int
|
||||||
|
unit_price: float
|
||||||
|
|
||||||
|
class AdminOrderCreateRequest(BaseModel):
|
||||||
|
customer_name: Optional[str] = None
|
||||||
|
customer_email: Optional[str] = None
|
||||||
|
customer_phone: Optional[str] = None
|
||||||
|
items: List[AdminOrderCreateItem]
|
||||||
|
shipping_address: Optional[str] = None
|
||||||
|
payment_status: str = "PAID"
|
||||||
|
payment_method: str = "CASH" # CASH, UPI, CARD, MIXED_PAYMENT
|
||||||
|
cash_amount: Optional[float] = 0.0
|
||||||
|
digital_amount: Optional[float] = 0.0
|
||||||
|
digital_method: Optional[str] = None # UPI or CARD
|
||||||
|
razorpay_order_id: Optional[str] = None
|
||||||
|
razorpay_payment_id: Optional[str] = None
|
||||||
|
razorpay_signature: Optional[str] = None
|
||||||
|
|
||||||
|
class PosRazorpayOrderRequest(BaseModel):
|
||||||
|
amount: float
|
||||||
|
|
||||||
|
@router.post("/create-pos-razorpay-order")
|
||||||
|
def create_pos_razorpay_order(
|
||||||
|
payload: PosRazorpayOrderRequest,
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Creates a Razorpay order for POS walk-in counter checkout.
|
||||||
|
"""
|
||||||
|
from app.core.razorpay import RazorpayService
|
||||||
|
from app.core.config.Config import settings
|
||||||
|
|
||||||
|
if payload.amount <= 0:
|
||||||
|
raise HTTPException(status_code=400, detail="Amount must be greater than 0")
|
||||||
|
|
||||||
|
amount_paise = int(round(payload.amount * 100))
|
||||||
|
rzp = RazorpayService()
|
||||||
|
receipt_id = f"pos_rcpt_{ulid.ULID()}"
|
||||||
|
|
||||||
|
order = rzp.create_order(
|
||||||
|
amount_paise=amount_paise,
|
||||||
|
receipt=receipt_id,
|
||||||
|
notes={"pos_cashier": current_user.email}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"razorpay_order_id": order["id"],
|
||||||
|
"amount_paise": amount_paise,
|
||||||
|
"amount": payload.amount,
|
||||||
|
"key_id": settings.RAZORPAY_KEY_ID
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.post("/create")
|
||||||
|
def create_admin_order(
|
||||||
|
payload: AdminOrderCreateRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Directly write a manual/offline store walk-in counter order and auto-generate its Sales Invoice.
|
||||||
|
"""
|
||||||
|
from app.services.OrderService import OrderService
|
||||||
|
items_dicts = [item.dict() for item in payload.items]
|
||||||
|
return OrderService.create_walk_in_order(
|
||||||
|
db=db,
|
||||||
|
items_payload=items_dicts,
|
||||||
|
customer_name=payload.customer_name,
|
||||||
|
customer_email=payload.customer_email,
|
||||||
|
customer_phone=payload.customer_phone,
|
||||||
|
shipping_address=payload.shipping_address,
|
||||||
|
payment_status=payload.payment_status or "PAID",
|
||||||
|
payment_method=payload.payment_method or "CASH",
|
||||||
|
cash_amount=payload.cash_amount or 0.0,
|
||||||
|
digital_amount=payload.digital_amount or 0.0,
|
||||||
|
digital_method=payload.digital_method,
|
||||||
|
razorpay_order_id=payload.razorpay_order_id,
|
||||||
|
razorpay_payment_id=payload.razorpay_payment_id,
|
||||||
|
razorpay_signature=payload.razorpay_signature,
|
||||||
|
actor_email=current_user.email
|
||||||
|
)
|
||||||
|
|
||||||
379
app/api/v1/routers/AdminSecurityRouter.py
Normal file
|
|
@ -0,0 +1,379 @@
|
||||||
|
import logging
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import ulid
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
from app.core.database.db_session import get_db
|
||||||
|
from app.core.permissions.RoleChecker import get_current_user, RoleChecker
|
||||||
|
from app.models.UserModel import User
|
||||||
|
from app.models.UserSessionModel import UserSession
|
||||||
|
from app.models.AuditLogModel import AuditLog
|
||||||
|
from app.repositories.setting_repository import setting_repository
|
||||||
|
from app.repositories.user_repository import user_repository
|
||||||
|
from app.services.audit_service import audit_service
|
||||||
|
from app.utils.Mfa_util import verify_mfa_token
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/admin", tags=["Admin Control & Infrastructure"])
|
||||||
|
|
||||||
|
MASTER_ADMIN_EMAILS = {"adithiyan.elan@gmail.com", "admin@ifixkart.com"}
|
||||||
|
|
||||||
|
def get_client_ip(request: Request) -> str:
|
||||||
|
x_forwarded_for = request.headers.get("x-forwarded-for")
|
||||||
|
if x_forwarded_for:
|
||||||
|
return x_forwarded_for.split(",")[0].strip()
|
||||||
|
return request.client.host if request.client else "127.0.0.1"
|
||||||
|
|
||||||
|
@router.post("/request_kill_switch")
|
||||||
|
def request_kill_switch(
|
||||||
|
approver_id: str,
|
||||||
|
mfa_code: str,
|
||||||
|
request: Request,
|
||||||
|
current_user: User = Depends(RoleChecker(["Super Admin", "Admin"])),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
admin = user_repository.get_by_id(db, approver_id)
|
||||||
|
if not admin:
|
||||||
|
raise HTTPException(status_code=404, detail="Admin not found")
|
||||||
|
|
||||||
|
if admin.email not in MASTER_ADMIN_EMAILS:
|
||||||
|
raise HTTPException(status_code=403, detail="Only Master Admin can trigger the kill switch")
|
||||||
|
|
||||||
|
if not admin.mfa_enabled or not verify_mfa_token(admin.mfa_secret, mfa_code):
|
||||||
|
raise HTTPException(status_code=403, detail="Invalid MFA verification code")
|
||||||
|
|
||||||
|
# 1. Update setting key 'GLOBAL_DISABLE' to enabled=True
|
||||||
|
setting = setting_repository.get_by_key(db, "GLOBAL_DISABLE")
|
||||||
|
if setting:
|
||||||
|
setting.setting_value = {"enabled": True, "triggered_by": admin.email, "timestamp": str(datetime.now(timezone.utc))}
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# 2. Destroy all active user sessions (Self-Destruct active logins)
|
||||||
|
db.query(UserSession).update({UserSession.is_active: False})
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# 3. Log event
|
||||||
|
ip_address = get_client_ip(request)
|
||||||
|
user_agent = request.headers.get("user-agent", "")
|
||||||
|
req_id = getattr(request.state, "request_id", "unknown")
|
||||||
|
audit_service.log_change(
|
||||||
|
db=db,
|
||||||
|
request_id=req_id,
|
||||||
|
user_id=admin.user_id,
|
||||||
|
entity_type="SystemSetting",
|
||||||
|
entity_id="GLOBAL_DISABLE",
|
||||||
|
action="kill_switch_executed",
|
||||||
|
old_value={"enabled": False},
|
||||||
|
new_value={"enabled": True},
|
||||||
|
ip_address=ip_address,
|
||||||
|
user_agent=user_agent
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"detail": "Kill switch executed. All user sessions have been terminated."}
|
||||||
|
|
||||||
|
@router.post("/self_destruct")
|
||||||
|
def self_destruct(
|
||||||
|
admin_id: str,
|
||||||
|
mfa_code: str,
|
||||||
|
request: Request,
|
||||||
|
current_user: User = Depends(RoleChecker(["Super Admin", "Admin"])),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
admin = user_repository.get_by_id(db, admin_id)
|
||||||
|
if not admin:
|
||||||
|
raise HTTPException(status_code=404, detail="Admin not found")
|
||||||
|
|
||||||
|
if admin.email not in MASTER_ADMIN_EMAILS:
|
||||||
|
raise HTTPException(status_code=403, detail="Only Master Admin can force self-destruct")
|
||||||
|
|
||||||
|
if not admin.mfa_enabled or not verify_mfa_token(admin.mfa_secret, mfa_code):
|
||||||
|
raise HTTPException(status_code=403, detail="Invalid MFA verification code")
|
||||||
|
|
||||||
|
# 1. Terminate all sessions
|
||||||
|
db.query(UserSession).update({UserSession.is_active: False})
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# 2. Log event
|
||||||
|
ip_address = get_client_ip(request)
|
||||||
|
user_agent = request.headers.get("user-agent", "")
|
||||||
|
req_id = getattr(request.state, "request_id", "unknown")
|
||||||
|
audit_service.log_change(
|
||||||
|
db=db,
|
||||||
|
request_id=req_id,
|
||||||
|
user_id=admin.user_id,
|
||||||
|
entity_type="Sessions",
|
||||||
|
entity_id="All",
|
||||||
|
action="self_destruct_sessions",
|
||||||
|
old_value=None,
|
||||||
|
new_value={"sessions_terminated": True},
|
||||||
|
ip_address=ip_address,
|
||||||
|
user_agent=user_agent
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"detail": "All sessions destroyed successfully."}
|
||||||
|
|
||||||
|
@router.post("/cancel_kill_switch")
|
||||||
|
def cancel_kill_switch(
|
||||||
|
admin_id: str,
|
||||||
|
request: Request,
|
||||||
|
current_user: User = Depends(RoleChecker(["Super Admin", "Admin"])),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
admin = user_repository.get_by_id(db, admin_id)
|
||||||
|
if not admin:
|
||||||
|
raise HTTPException(status_code=404, detail="Admin not found")
|
||||||
|
|
||||||
|
if admin.email not in MASTER_ADMIN_EMAILS:
|
||||||
|
raise HTTPException(status_code=403, detail="Only Master Admin can cancel the kill switch")
|
||||||
|
|
||||||
|
# 1. Set GLOBAL_DISABLE to False
|
||||||
|
setting = setting_repository.get_by_key(db, "GLOBAL_DISABLE")
|
||||||
|
if setting:
|
||||||
|
setting.setting_value = {"enabled": False}
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# 2. Log event
|
||||||
|
ip_address = get_client_ip(request)
|
||||||
|
user_agent = request.headers.get("user-agent", "")
|
||||||
|
req_id = getattr(request.state, "request_id", "unknown")
|
||||||
|
audit_service.log_change(
|
||||||
|
db=db,
|
||||||
|
request_id=req_id,
|
||||||
|
user_id=admin.user_id,
|
||||||
|
entity_type="SystemSetting",
|
||||||
|
entity_id="GLOBAL_DISABLE",
|
||||||
|
action="kill_switch_cancelled",
|
||||||
|
old_value={"enabled": True},
|
||||||
|
new_value={"enabled": False},
|
||||||
|
ip_address=ip_address,
|
||||||
|
user_agent=user_agent
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"detail": "Kill switch cancelled. Standard user access restored."}
|
||||||
|
|
||||||
|
@router.get("/list_sessions")
|
||||||
|
def list_sessions(
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
current_user: User = Depends(RoleChecker(["Super Admin", "Admin"])),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
sessions = db.query(UserSession).offset(skip).limit(limit).all()
|
||||||
|
return {
|
||||||
|
"sessions": [
|
||||||
|
{
|
||||||
|
"session_id": s.session_id,
|
||||||
|
"user_id": s.user_id,
|
||||||
|
"device_name": s.device_name,
|
||||||
|
"device_type": s.device_type,
|
||||||
|
"browser": s.browser,
|
||||||
|
"operating_system": s.operating_system,
|
||||||
|
"ip_address": s.ip_address,
|
||||||
|
"expires_at": s.expires_at,
|
||||||
|
"is_active": s.is_active
|
||||||
|
}
|
||||||
|
for s in sessions
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.post("/revoke_session")
|
||||||
|
def revoke_session(
|
||||||
|
session_id: str,
|
||||||
|
current_user: User = Depends(RoleChecker(["Super Admin", "Admin"])),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
session = db.get(UserSession, session_id)
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
|
|
||||||
|
session.is_active = False
|
||||||
|
db.commit()
|
||||||
|
return {"detail": f"Session {session_id} has been revoked successfully."}
|
||||||
|
|
||||||
|
@router.get("/failed-logins")
|
||||||
|
def get_failed_logins(
|
||||||
|
limit: int = 10,
|
||||||
|
current_user: User = Depends(RoleChecker(["Super Admin", "Admin"])),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
failed_attempts = (
|
||||||
|
db.query(AuditLog)
|
||||||
|
.filter(AuditLog.action == "failed_login")
|
||||||
|
.order_by(AuditLog.created_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return failed_attempts
|
||||||
|
|
||||||
|
@router.get("/audit-logs")
|
||||||
|
def get_all_audit_logs(
|
||||||
|
limit: int = 100,
|
||||||
|
current_user: User = Depends(RoleChecker(["Super Admin", "Admin"])),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Retrieve dynamic audit logs across all users and modules for ERP Activity Monitor.
|
||||||
|
"""
|
||||||
|
from app.models.UserModel import User as DbUser
|
||||||
|
results = (
|
||||||
|
db.query(AuditLog, DbUser)
|
||||||
|
.outerjoin(DbUser, AuditLog.user_id == DbUser.user_id)
|
||||||
|
.order_by(AuditLog.created_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
output = []
|
||||||
|
for log, user in results:
|
||||||
|
user_info = None
|
||||||
|
if user:
|
||||||
|
user_info = {
|
||||||
|
"name": f"{user.first_name} {user.last_name}",
|
||||||
|
"avatarInitials": f"{user.first_name[0].upper() if user.first_name else ''}{user.last_name[0].upper() if user.last_name else ''}"
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
user_info = {
|
||||||
|
"name": "System / Guest",
|
||||||
|
"avatarInitials": "SYS"
|
||||||
|
}
|
||||||
|
|
||||||
|
output.append({
|
||||||
|
"audit_id": log.audit_id,
|
||||||
|
"request_id": log.request_id,
|
||||||
|
"user_id": log.user_id,
|
||||||
|
"entity_type": log.entity_type,
|
||||||
|
"entity_id": log.entity_id,
|
||||||
|
"action": log.action,
|
||||||
|
"old_value": log.old_value,
|
||||||
|
"new_value": log.new_value,
|
||||||
|
"ip_address": log.ip_address,
|
||||||
|
"user_agent": log.user_agent,
|
||||||
|
"created_at": log.created_at.isoformat() if log.created_at else None,
|
||||||
|
"user": user_info
|
||||||
|
})
|
||||||
|
return output
|
||||||
|
|
||||||
|
# --- Media Storage & Factory Reset Endpoints ---
|
||||||
|
from app.utils.Hash_util import verify_password
|
||||||
|
from app.core.database.init_db import initialize_database, Base, engine_core, engine_crm, engine_commerce
|
||||||
|
from app.models.SettingModel import Setting
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BACKEND_ROOT = Path(__file__).resolve().parents[4]
|
||||||
|
UPLOADS_DIR = BACKEND_ROOT / "uploads"
|
||||||
|
|
||||||
|
@router.get("/media-settings")
|
||||||
|
def get_media_settings_admin(
|
||||||
|
current_user: User = Depends(RoleChecker(["Super Admin", "Admin"])),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
keys = ["media_store_original", "media_max_size_mb", "media_webp_quality", "media_cleanup_enabled", "media_cleanup_grace_hours"]
|
||||||
|
settings = {}
|
||||||
|
rows = db.execute(select(Setting).where(Setting.setting_key.in_(keys))).scalars().all()
|
||||||
|
for r in rows:
|
||||||
|
val = r.setting_value
|
||||||
|
if isinstance(val, dict) and "value" in val:
|
||||||
|
val = val["value"]
|
||||||
|
settings[r.setting_key] = val
|
||||||
|
return settings
|
||||||
|
|
||||||
|
@router.post("/media-settings")
|
||||||
|
def update_media_settings_admin(
|
||||||
|
payload: dict,
|
||||||
|
current_user: User = Depends(RoleChecker(["Super Admin", "Admin"])),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
keys = ["media_store_original", "media_max_size_mb", "media_webp_quality", "media_cleanup_enabled", "media_cleanup_grace_hours"]
|
||||||
|
for k in keys:
|
||||||
|
if k in payload:
|
||||||
|
setting = db.execute(select(Setting).where(Setting.setting_key == k)).scalar_one_or_none()
|
||||||
|
if not setting:
|
||||||
|
setting = Setting(
|
||||||
|
setting_id=str(ulid.ULID()),
|
||||||
|
setting_key=k,
|
||||||
|
group="media",
|
||||||
|
type="json",
|
||||||
|
setting_value={"value": payload[k]},
|
||||||
|
description=f"Media policy setting: {k}",
|
||||||
|
is_public=False
|
||||||
|
)
|
||||||
|
db.add(setting)
|
||||||
|
else:
|
||||||
|
setting.setting_value = {"value": payload[k]}
|
||||||
|
db.commit()
|
||||||
|
return {"detail": "Media settings updated successfully"}
|
||||||
|
|
||||||
|
class FactoryResetRequest(BaseModel):
|
||||||
|
password: str
|
||||||
|
confirmation_phrase: str
|
||||||
|
|
||||||
|
@router.post("/security/factory-reset")
|
||||||
|
def platform_factory_reset(
|
||||||
|
payload: FactoryResetRequest,
|
||||||
|
request: Request,
|
||||||
|
current_user: User = Depends(RoleChecker(["Super Admin"])),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
password = payload.password
|
||||||
|
confirmation_phrase = payload.confirmation_phrase
|
||||||
|
|
||||||
|
if not verify_password(password, current_user.password_hash):
|
||||||
|
raise HTTPException(status_code=403, detail="Invalid Super Admin password verification")
|
||||||
|
|
||||||
|
if confirmation_phrase != "CONFIRM_FACTORY_RESET_WIPE_2026":
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid confirmation phrase. Type 'CONFIRM_FACTORY_RESET_WIPE_2026' to proceed.")
|
||||||
|
|
||||||
|
logger.warning(f"CRITICAL: Super Admin {current_user.email} triggered platform factory reset!")
|
||||||
|
|
||||||
|
# 1. Physical Media Uploads Wipe (Preserve folder structure)
|
||||||
|
try:
|
||||||
|
if UPLOADS_DIR.exists():
|
||||||
|
for item in UPLOADS_DIR.iterdir():
|
||||||
|
if item.is_dir():
|
||||||
|
shutil.rmtree(item, ignore_errors=True)
|
||||||
|
item.mkdir(parents=True, exist_ok=True)
|
||||||
|
else:
|
||||||
|
item.unlink(missing_ok=True)
|
||||||
|
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
(UPLOADS_DIR / "raw").mkdir(parents=True, exist_ok=True)
|
||||||
|
except Exception as err:
|
||||||
|
logger.error(f"Error purging physical media directory: {err}")
|
||||||
|
|
||||||
|
# 2. Database Fast Truncate & Re-initialization
|
||||||
|
try:
|
||||||
|
db.close()
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
for eng in [engine_commerce, engine_crm, engine_core]:
|
||||||
|
with eng.connect() as conn:
|
||||||
|
conn.execute(text("SET FOREIGN_KEY_CHECKS = 0;"))
|
||||||
|
tables = conn.execute(text("SHOW TABLES;")).fetchall()
|
||||||
|
for tbl in tables:
|
||||||
|
tbl_name = tbl[0]
|
||||||
|
try:
|
||||||
|
conn.execute(text(f"TRUNCATE TABLE `{tbl_name}`;"))
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
conn.execute(text(f"DELETE FROM `{tbl_name}`;"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
conn.execute(text("SET FOREIGN_KEY_CHECKS = 1;"))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
from app.core.database.init_db import initialize_database
|
||||||
|
initialize_database()
|
||||||
|
except Exception as err:
|
||||||
|
logger.error(f"Factory reset DB wipe error: {err}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Factory reset DB wipe failed: {str(err)}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"detail": "Platform factory reset completed successfully. All database tables and physical uploads have been wiped, and pristine master data has been re-seeded.",
|
||||||
|
"timestamp": datetime.utcnow().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
320
app/api/v1/routers/AuthenticationRouter.py
Normal file
|
|
@ -0,0 +1,320 @@
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Response, Request, Cookie, status
|
||||||
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
from user_agents import parse
|
||||||
|
import ulid
|
||||||
|
|
||||||
|
from app.core.database.db_session import get_db
|
||||||
|
from app.core.config.Config import settings
|
||||||
|
from app.core.Token import create_access_token
|
||||||
|
from app.utils.Hash_util import hash_password, verify_password
|
||||||
|
from app.repositories.user_repository import user_repository
|
||||||
|
from app.repositories.session_repository import session_repository
|
||||||
|
from app.models.UserSessionModel import UserSession
|
||||||
|
from app.models.UserModel import User
|
||||||
|
from app.models.AuditLogModel import AuditLog
|
||||||
|
from app.schemas.Auth import LoginSchema, TokenResponseSchema
|
||||||
|
from app.core.validators.password_validator import validate_password_complexity
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/auth", tags=["Authentication"])
|
||||||
|
|
||||||
|
def get_client_ip(request: Request) -> str:
|
||||||
|
# Read forwarded headers or default to client host
|
||||||
|
x_forwarded_for = request.headers.get("x-forwarded-for")
|
||||||
|
if x_forwarded_for:
|
||||||
|
return x_forwarded_for.split(",")[0].strip()
|
||||||
|
return request.client.host if request.client else "127.0.0.1"
|
||||||
|
|
||||||
|
def record_failed_login_audit(db: Session, request: Request, email: str, user: Optional[User] = None, reason: str = "Invalid credentials"):
|
||||||
|
try:
|
||||||
|
req_id = getattr(request.state, "trace_id", None) or str(ulid.ULID())
|
||||||
|
audit = AuditLog(
|
||||||
|
audit_id=str(ulid.ULID()),
|
||||||
|
request_id=req_id,
|
||||||
|
user_id=user.user_id if user else None,
|
||||||
|
entity_type="auth",
|
||||||
|
entity_id=email,
|
||||||
|
action="failed_login",
|
||||||
|
old_value=None,
|
||||||
|
new_value={"reason": reason, "email": email},
|
||||||
|
ip_address=get_client_ip(request),
|
||||||
|
user_agent=(request.headers.get("user-agent") or "")[:255],
|
||||||
|
)
|
||||||
|
db.add(audit)
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=TokenResponseSchema)
|
||||||
|
def login(
|
||||||
|
data: LoginSchema,
|
||||||
|
response: Response,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
user = user_repository.get_by_email(db, data.email)
|
||||||
|
|
||||||
|
# 1. Lockout & Verification checks
|
||||||
|
if not user:
|
||||||
|
record_failed_login_audit(db, request, data.email, None, "User not found")
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||||
|
|
||||||
|
if user.is_locked and user.deleted_at is None:
|
||||||
|
record_failed_login_audit(db, request, data.email, user, "Account locked")
|
||||||
|
raise HTTPException(status_code=403, detail="Account locked. Please reset password to unlock.")
|
||||||
|
|
||||||
|
if not verify_password(data.password, user.password_hash):
|
||||||
|
user.failed_login_attempts += 1
|
||||||
|
is_locked_now = user.failed_login_attempts >= settings.MAX_FAILED_LOGIN
|
||||||
|
if is_locked_now:
|
||||||
|
user.is_locked = True
|
||||||
|
db.commit()
|
||||||
|
record_failed_login_audit(
|
||||||
|
db,
|
||||||
|
request,
|
||||||
|
data.email,
|
||||||
|
user,
|
||||||
|
"Account locked due to too many failed attempts" if is_locked_now else "Invalid credentials"
|
||||||
|
)
|
||||||
|
if is_locked_now:
|
||||||
|
raise HTTPException(status_code=403, detail="Account locked due to too many failed attempts.")
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||||
|
|
||||||
|
# 2. Reset failed attempts count
|
||||||
|
user.failed_login_attempts = 0
|
||||||
|
user.is_locked = False
|
||||||
|
user.last_login = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# 3. Generate tokens
|
||||||
|
access_token = create_access_token(user.user_id, user.email, user.role.role_name)
|
||||||
|
raw_refresh = str(ulid.ULID())
|
||||||
|
|
||||||
|
# 4. Extract device metadata
|
||||||
|
ua_string = request.headers.get("user-agent", "")
|
||||||
|
ua = parse(ua_string)
|
||||||
|
|
||||||
|
device_type = "Desktop"
|
||||||
|
if ua.is_mobile:
|
||||||
|
device_type = "Mobile"
|
||||||
|
elif ua.is_tablet:
|
||||||
|
device_type = "Tablet"
|
||||||
|
|
||||||
|
os_name = f"{ua.os.family} {ua.os.version_string}".strip()
|
||||||
|
browser_name = f"{ua.browser.family} {ua.browser.version_string}".strip()
|
||||||
|
|
||||||
|
# 5. Save session
|
||||||
|
session_id = str(ulid.ULID())
|
||||||
|
session_entry = UserSession(
|
||||||
|
session_id=session_id,
|
||||||
|
user_id=user.user_id,
|
||||||
|
device_name=ua.device.family or "Unknown",
|
||||||
|
device_type=device_type,
|
||||||
|
browser=browser_name,
|
||||||
|
operating_system=os_name,
|
||||||
|
ip_address=get_client_ip(request),
|
||||||
|
latitude=data.latitude,
|
||||||
|
longitude=data.longitude,
|
||||||
|
location_name=data.location_name,
|
||||||
|
device_fingerprint=data.device_fingerprint,
|
||||||
|
refresh_token=raw_refresh,
|
||||||
|
access_token_id=session_id, # Match token references
|
||||||
|
expires_at=datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS),
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(session_entry)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# 6. Set response cookie and header
|
||||||
|
response.set_cookie(
|
||||||
|
"refresh_token",
|
||||||
|
raw_refresh,
|
||||||
|
httponly=True,
|
||||||
|
secure=True,
|
||||||
|
samesite="none"
|
||||||
|
)
|
||||||
|
response.headers["X-User-Email"] = user.email
|
||||||
|
|
||||||
|
return {
|
||||||
|
"access_token": access_token,
|
||||||
|
"refresh_token": raw_refresh,
|
||||||
|
"token_type": "bearer"
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.post("/refresh", response_model=TokenResponseSchema)
|
||||||
|
def refresh(
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
refresh_token: Optional[str] = Cookie(None),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
if not refresh_token:
|
||||||
|
auth_header = request.headers.get("Authorization")
|
||||||
|
if auth_header and auth_header.startswith("Bearer "):
|
||||||
|
refresh_token = auth_header.split(" ")[1]
|
||||||
|
if not refresh_token:
|
||||||
|
refresh_token = request.headers.get("X-Refresh-Token")
|
||||||
|
|
||||||
|
if not refresh_token:
|
||||||
|
raise HTTPException(status_code=401, detail="Missing refresh token")
|
||||||
|
|
||||||
|
session = session_repository.get_by_refresh_token(db, refresh_token)
|
||||||
|
if not session or session.expires_at.replace(tzinfo=timezone.utc) < datetime.now(timezone.utc):
|
||||||
|
if session:
|
||||||
|
session.is_active = False
|
||||||
|
db.commit()
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
|
||||||
|
|
||||||
|
user = user_repository.get_by_id(db, session.user_id)
|
||||||
|
if not user or not user.is_active or user.is_locked:
|
||||||
|
raise HTTPException(status_code=403, detail="User account is inactive or locked")
|
||||||
|
|
||||||
|
# Refresh Token Rotation (RTR): invalidate old token, issue new one
|
||||||
|
new_access = create_access_token(user.user_id, user.email, user.role.role_name)
|
||||||
|
new_refresh = str(ulid.ULID())
|
||||||
|
|
||||||
|
# Deactivate old session
|
||||||
|
session.is_active = False
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Create new session entry carrying over device metadata
|
||||||
|
new_session_id = str(ulid.ULID())
|
||||||
|
new_session = UserSession(
|
||||||
|
session_id=new_session_id,
|
||||||
|
user_id=user.user_id,
|
||||||
|
device_name=session.device_name,
|
||||||
|
device_type=session.device_type,
|
||||||
|
browser=session.browser,
|
||||||
|
operating_system=session.operating_system,
|
||||||
|
ip_address=session.ip_address,
|
||||||
|
latitude=session.latitude,
|
||||||
|
longitude=session.longitude,
|
||||||
|
location_name=session.location_name,
|
||||||
|
device_fingerprint=session.device_fingerprint,
|
||||||
|
refresh_token=new_refresh,
|
||||||
|
access_token_id=new_session_id,
|
||||||
|
expires_at=datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS),
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(new_session)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
response.set_cookie(
|
||||||
|
"refresh_token",
|
||||||
|
new_refresh,
|
||||||
|
httponly=True,
|
||||||
|
secure=True,
|
||||||
|
samesite="none"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"access_token": new_access,
|
||||||
|
"refresh_token": new_refresh,
|
||||||
|
"token_type": "bearer"
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
def logout(
|
||||||
|
response: Response,
|
||||||
|
refresh_token: str = Cookie(None),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
if refresh_token:
|
||||||
|
session = session_repository.get_by_refresh_token(db, refresh_token)
|
||||||
|
if session:
|
||||||
|
session.is_active = False
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
response.delete_cookie("refresh_token", httponly=True, secure=True, samesite="lax")
|
||||||
|
return {"detail": "Logged out successfully"}
|
||||||
|
|
||||||
|
@router.post("/token", response_model=TokenResponseSchema)
|
||||||
|
def oauth2_token(
|
||||||
|
response: Response,
|
||||||
|
request: Request,
|
||||||
|
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
user = user_repository.get_by_email(db, form_data.username)
|
||||||
|
if not user:
|
||||||
|
record_failed_login_audit(db, request, form_data.username, None, "User not found")
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||||
|
|
||||||
|
if user.is_locked and user.deleted_at is None:
|
||||||
|
record_failed_login_audit(db, request, form_data.username, user, "Account locked")
|
||||||
|
raise HTTPException(status_code=403, detail="Account locked. Please reset password to unlock.")
|
||||||
|
|
||||||
|
if not verify_password(form_data.password, user.password_hash):
|
||||||
|
user.failed_login_attempts += 1
|
||||||
|
is_locked_now = user.failed_login_attempts >= settings.MAX_FAILED_LOGIN
|
||||||
|
if is_locked_now:
|
||||||
|
user.is_locked = True
|
||||||
|
db.commit()
|
||||||
|
record_failed_login_audit(
|
||||||
|
db,
|
||||||
|
request,
|
||||||
|
form_data.username,
|
||||||
|
user,
|
||||||
|
"Account locked due to too many failed attempts" if is_locked_now else "Invalid credentials"
|
||||||
|
)
|
||||||
|
if is_locked_now:
|
||||||
|
raise HTTPException(status_code=403, detail="Account locked due to too many failed attempts.")
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||||
|
|
||||||
|
user.failed_login_attempts = 0
|
||||||
|
user.is_locked = False
|
||||||
|
user.last_login = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
access_token = create_access_token(user.user_id, user.email, user.role.role_name)
|
||||||
|
raw_refresh = str(ulid.ULID())
|
||||||
|
|
||||||
|
ua_string = request.headers.get("user-agent", "")
|
||||||
|
ua = parse(ua_string)
|
||||||
|
device_type = "Desktop"
|
||||||
|
if ua.is_mobile:
|
||||||
|
device_type = "Mobile"
|
||||||
|
elif ua.is_tablet:
|
||||||
|
device_type = "Tablet"
|
||||||
|
os_name = f"{ua.os.family} {ua.os.version_string}".strip()
|
||||||
|
browser_name = f"{ua.browser.family} {ua.browser.version_string}".strip()
|
||||||
|
|
||||||
|
session_id = str(ulid.ULID())
|
||||||
|
session_entry = UserSession(
|
||||||
|
session_id=session_id,
|
||||||
|
user_id=user.user_id,
|
||||||
|
device_name=ua.device.family or "Unknown",
|
||||||
|
device_type=device_type,
|
||||||
|
browser=browser_name,
|
||||||
|
operating_system=os_name,
|
||||||
|
ip_address=get_client_ip(request),
|
||||||
|
refresh_token=raw_refresh,
|
||||||
|
access_token_id=session_id,
|
||||||
|
expires_at=datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS),
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
db.add(session_entry)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
response.set_cookie(
|
||||||
|
"refresh_token",
|
||||||
|
raw_refresh,
|
||||||
|
httponly=True,
|
||||||
|
secure=True,
|
||||||
|
samesite="lax"
|
||||||
|
)
|
||||||
|
response.headers["X-User-Email"] = user.email
|
||||||
|
|
||||||
|
return {
|
||||||
|
"access_token": access_token,
|
||||||
|
"refresh_token": raw_refresh,
|
||||||
|
"token_type": "bearer"
|
||||||
|
}
|
||||||
|
|
||||||
273
app/api/v1/routers/CartRouter.py
Normal file
|
|
@ -0,0 +1,273 @@
|
||||||
|
"""
|
||||||
|
@router CartRouter (Backend/app/api/v1/routers/CartRouter.py)
|
||||||
|
@purpose Database-backed shopping cart for authenticated customers (and optional guest visitor_id).
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import List, Optional, Any
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
import ulid
|
||||||
|
import json
|
||||||
|
|
||||||
|
from app.core.database.db_session import get_db
|
||||||
|
from app.api.v1.routers.CustomerProfileRouter import get_current_customer
|
||||||
|
from app.models.EcomCustomerModel import EcomCustomer
|
||||||
|
from app.models.CartModel import Cart
|
||||||
|
from app.models.ProductModel import ProductVariant
|
||||||
|
from app.services.InventoryService import get_available_stock
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/cart", tags=["Shopping Cart"])
|
||||||
|
|
||||||
|
|
||||||
|
class CartItemSchema(BaseModel):
|
||||||
|
variant_id: str
|
||||||
|
qty: int
|
||||||
|
unit_price: Optional[float] = None
|
||||||
|
product_name: Optional[str] = None
|
||||||
|
sku: Optional[str] = None
|
||||||
|
total_price: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AddToCartRequest(BaseModel):
|
||||||
|
visitor_id: Optional[str] = None
|
||||||
|
variant_id: str
|
||||||
|
qty: int = 1
|
||||||
|
|
||||||
|
|
||||||
|
class SetCartItemsRequest(BaseModel):
|
||||||
|
"""Replace entire cart contents (used by checkout sync from Zustand)."""
|
||||||
|
items: List[AddToCartRequest]
|
||||||
|
|
||||||
|
|
||||||
|
class MergeGuestCartRequest(BaseModel):
|
||||||
|
visitor_id: str
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_stock(db: Session, variant_id: str, requested_qty: int, sku: Optional[str] = None) -> None:
|
||||||
|
available = get_available_stock(variant_id, db)
|
||||||
|
if requested_qty > available:
|
||||||
|
label = sku or variant_id
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Insufficient stock for {label}. Available: {available}, requested: {requested_qty}.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_items(raw: Any) -> List[dict]:
|
||||||
|
if raw is None:
|
||||||
|
return []
|
||||||
|
if isinstance(raw, str):
|
||||||
|
try:
|
||||||
|
raw = json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
return []
|
||||||
|
return [i for i in raw if isinstance(i, dict) and i.get("variant_id")]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_or_create_customer_cart(db: Session, customer_id: str) -> Cart:
|
||||||
|
cart = db.query(Cart).filter(Cart.customer_id == customer_id).first()
|
||||||
|
if not cart:
|
||||||
|
cart = Cart(
|
||||||
|
cart_id=str(ulid.ULID()),
|
||||||
|
customer_id=customer_id,
|
||||||
|
items_json=[],
|
||||||
|
)
|
||||||
|
db.add(cart)
|
||||||
|
db.flush()
|
||||||
|
return cart
|
||||||
|
|
||||||
|
|
||||||
|
def _enrich_and_totals(db: Session, items: List[dict]) -> dict:
|
||||||
|
enriched = []
|
||||||
|
subtotal = 0.0
|
||||||
|
for item in items:
|
||||||
|
variant_id = item.get("variant_id")
|
||||||
|
qty = int(item.get("qty", 1))
|
||||||
|
if qty <= 0:
|
||||||
|
continue
|
||||||
|
variant = db.query(ProductVariant).filter(ProductVariant.variant_id == variant_id).first()
|
||||||
|
if not variant:
|
||||||
|
continue
|
||||||
|
unit_price = float(variant.price)
|
||||||
|
line_total = unit_price * qty
|
||||||
|
subtotal += line_total
|
||||||
|
product_name = variant.product.name if variant.product else "Product"
|
||||||
|
enriched.append({
|
||||||
|
"variant_id": variant_id,
|
||||||
|
"qty": qty,
|
||||||
|
"unit_price": unit_price,
|
||||||
|
"product_name": product_name,
|
||||||
|
"sku": variant.sku,
|
||||||
|
"total_price": round(line_total, 2),
|
||||||
|
"available_stock": get_available_stock(variant_id, db),
|
||||||
|
})
|
||||||
|
tax = round(subtotal * 0.18, 2)
|
||||||
|
shipping = 0.0 if subtotal > 99 else 15.0
|
||||||
|
return {
|
||||||
|
"items": enriched,
|
||||||
|
"subtotal": round(subtotal, 2),
|
||||||
|
"tax": tax,
|
||||||
|
"shipping": shipping,
|
||||||
|
"total_amount": round(subtotal + tax + shipping, 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_item(items: List[dict], variant_id: str, qty: int, unit_price: float) -> List[dict]:
|
||||||
|
found = False
|
||||||
|
for item in items:
|
||||||
|
if item.get("variant_id") == variant_id:
|
||||||
|
item["qty"] = int(item.get("qty", 0)) + qty
|
||||||
|
item["unit_price"] = unit_price
|
||||||
|
found = True
|
||||||
|
break
|
||||||
|
if not found:
|
||||||
|
items.append({"variant_id": variant_id, "qty": qty, "unit_price": unit_price})
|
||||||
|
return [i for i in items if int(i.get("qty", 0)) > 0]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def get_cart(
|
||||||
|
visitor_id: Optional[str] = None,
|
||||||
|
customer: EcomCustomer = Depends(get_current_customer),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Return the authenticated customer's cart with server-side prices."""
|
||||||
|
cart = db.query(Cart).filter(Cart.customer_id == customer.customer_id).first()
|
||||||
|
if not cart:
|
||||||
|
return {
|
||||||
|
"cart_id": None,
|
||||||
|
"items": [],
|
||||||
|
"subtotal": 0.0,
|
||||||
|
"tax": 0.0,
|
||||||
|
"shipping": 0.0,
|
||||||
|
"total_amount": 0.0,
|
||||||
|
}
|
||||||
|
totals = _enrich_and_totals(db, _parse_items(cart.items_json))
|
||||||
|
return {
|
||||||
|
"cart_id": cart.cart_id,
|
||||||
|
**totals,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/items")
|
||||||
|
def add_to_cart(
|
||||||
|
payload: AddToCartRequest,
|
||||||
|
customer: EcomCustomer = Depends(get_current_customer),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Add or increment a variant in the customer cart."""
|
||||||
|
if payload.qty <= 0:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Quantity must be positive")
|
||||||
|
|
||||||
|
variant = db.query(ProductVariant).filter(ProductVariant.variant_id == payload.variant_id).first()
|
||||||
|
if not variant:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Variant not found")
|
||||||
|
|
||||||
|
cart = _get_or_create_customer_cart(db, customer.customer_id)
|
||||||
|
items = _parse_items(cart.items_json)
|
||||||
|
current_qty = sum(int(i.get("qty", 0)) for i in items if i.get("variant_id") == payload.variant_id)
|
||||||
|
_ensure_stock(db, payload.variant_id, current_qty + payload.qty, variant.sku)
|
||||||
|
items = _upsert_item(items, payload.variant_id, payload.qty, float(variant.price))
|
||||||
|
cart.items_json = items
|
||||||
|
db.commit()
|
||||||
|
db.refresh(cart)
|
||||||
|
|
||||||
|
totals = _enrich_and_totals(db, items)
|
||||||
|
return {"message": "Item added to cart", "cart_id": cart.cart_id, **totals}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/items")
|
||||||
|
def set_cart_items(
|
||||||
|
payload: SetCartItemsRequest,
|
||||||
|
customer: EcomCustomer = Depends(get_current_customer),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Replace the entire customer cart with the provided items.
|
||||||
|
Used by storefront checkout to sync Zustand cart before order creation.
|
||||||
|
"""
|
||||||
|
cart = _get_or_create_customer_cart(db, customer.customer_id)
|
||||||
|
merged: dict = {}
|
||||||
|
for entry in payload.items:
|
||||||
|
if entry.qty <= 0:
|
||||||
|
continue
|
||||||
|
variant = db.query(ProductVariant).filter(ProductVariant.variant_id == entry.variant_id).first()
|
||||||
|
if not variant:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Variant {entry.variant_id} not found",
|
||||||
|
)
|
||||||
|
if entry.variant_id in merged:
|
||||||
|
merged[entry.variant_id]["qty"] += entry.qty
|
||||||
|
else:
|
||||||
|
merged[entry.variant_id] = {
|
||||||
|
"variant_id": entry.variant_id,
|
||||||
|
"qty": entry.qty,
|
||||||
|
"unit_price": float(variant.price),
|
||||||
|
"sku": variant.sku,
|
||||||
|
}
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for row in merged.values():
|
||||||
|
_ensure_stock(db, row["variant_id"], row["qty"], row.get("sku"))
|
||||||
|
items.append({
|
||||||
|
"variant_id": row["variant_id"],
|
||||||
|
"qty": row["qty"],
|
||||||
|
"unit_price": row["unit_price"],
|
||||||
|
})
|
||||||
|
cart.items_json = items if items else None
|
||||||
|
db.commit()
|
||||||
|
db.refresh(cart)
|
||||||
|
|
||||||
|
totals = _enrich_and_totals(db, items)
|
||||||
|
return {"message": "Cart synchronized", "cart_id": cart.cart_id, **totals}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/items/{variant_id}")
|
||||||
|
def remove_cart_item(
|
||||||
|
variant_id: str,
|
||||||
|
customer: EcomCustomer = Depends(get_current_customer),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
cart = db.query(Cart).filter(Cart.customer_id == customer.customer_id).first()
|
||||||
|
if not cart:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cart not found")
|
||||||
|
|
||||||
|
items = [i for i in _parse_items(cart.items_json) if i.get("variant_id") != variant_id]
|
||||||
|
cart.items_json = items if items else None
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Item removed", "cart_id": cart.cart_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("")
|
||||||
|
def clear_cart(
|
||||||
|
customer: EcomCustomer = Depends(get_current_customer),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
cart = db.query(Cart).filter(Cart.customer_id == customer.customer_id).first()
|
||||||
|
if cart:
|
||||||
|
cart.items_json = None
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Cart cleared"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/merge")
|
||||||
|
def merge_guest_cart(
|
||||||
|
payload: MergeGuestCartRequest,
|
||||||
|
customer: EcomCustomer = Depends(get_current_customer),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Merge a guest visitor cart into the authenticated customer cart."""
|
||||||
|
from app.api.v1.routers.CustomerAuthRouter import perform_guest_cart_merge
|
||||||
|
|
||||||
|
perform_guest_cart_merge(db, customer.customer_id, payload.visitor_id)
|
||||||
|
cart = db.query(Cart).filter(Cart.customer_id == customer.customer_id).first()
|
||||||
|
items = _parse_items(cart.items_json) if cart else []
|
||||||
|
totals = _enrich_and_totals(db, items)
|
||||||
|
return {
|
||||||
|
"message": "Guest cart merged successfully",
|
||||||
|
"cart_id": cart.cart_id if cart else None,
|
||||||
|
**totals,
|
||||||
|
}
|
||||||
1853
app/api/v1/routers/CatalogRouter.py
Normal file
36
app/api/v1/routers/CheckoutRouter.py
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
"""
|
||||||
|
@router CheckoutRouter (Backend/app/api/v1/routers/CheckoutRouter.py)
|
||||||
|
@purpose Database-driven customer checkout pipeline delegating to unified OrderService.
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.database.db_session import get_db
|
||||||
|
from app.api.v1.routers.CustomerProfileRouter import get_current_customer
|
||||||
|
from app.models.EcomCustomerModel import EcomCustomer
|
||||||
|
from app.services.OrderService import OrderService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/checkout", tags=["Checkout Engine"])
|
||||||
|
|
||||||
|
class CheckoutCreateRequest(BaseModel):
|
||||||
|
address_id: str
|
||||||
|
payment_method: str = "COD"
|
||||||
|
coupon_code: Optional[str] = None
|
||||||
|
|
||||||
|
@router.post("/create")
|
||||||
|
def create_checkout_order(
|
||||||
|
payload: CheckoutCreateRequest,
|
||||||
|
customer: EcomCustomer = Depends(get_current_customer),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Validate cart item stock, register checkout order reservation, and empty the customer's cart.
|
||||||
|
"""
|
||||||
|
return OrderService.create_ecommerce_order(
|
||||||
|
db=db,
|
||||||
|
customer=customer,
|
||||||
|
address_id=payload.address_id,
|
||||||
|
payment_method=payload.payment_method or "COD"
|
||||||
|
)
|
||||||
478
app/api/v1/routers/CustomerAuthRouter.py
Normal file
|
|
@ -0,0 +1,478 @@
|
||||||
|
"""
|
||||||
|
@router CustomerAuthRouter (Backend/app/api/v1/routers/CustomerAuthRouter.py)
|
||||||
|
@purpose Dedicated Customer Authentication router supporting Email/Password, Google OAuth 2.0 (Authorization Code flow with backend token exchange), and Refresh Token rotation with reuse detection.
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, Response, Request, Cookie
|
||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import ulid
|
||||||
|
import requests
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from google.oauth2 import id_token
|
||||||
|
from google.auth.transport import requests as google_requests
|
||||||
|
|
||||||
|
from app.core.config.Config import settings
|
||||||
|
from app.core.database.db_session import get_db
|
||||||
|
from app.utils.Hash_util import hash_password, verify_password, hash_token
|
||||||
|
from app.models.EcomCustomerModel import EcomCustomer, CustomerRefreshToken
|
||||||
|
from app.models.CartModel import Cart
|
||||||
|
from app.core.Token import create_access_token
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/customer/auth", tags=["Customer Authentication"])
|
||||||
|
|
||||||
|
# --- Request / Response Schemas ---
|
||||||
|
|
||||||
|
class RegisterRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str
|
||||||
|
first_name: str
|
||||||
|
last_name: str
|
||||||
|
phone: Optional[str] = None
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str
|
||||||
|
|
||||||
|
class GoogleAuthRequest(BaseModel):
|
||||||
|
code: str
|
||||||
|
state: str
|
||||||
|
redirect_uri: Optional[str] = None
|
||||||
|
guest_session_id: Optional[str] = None
|
||||||
|
|
||||||
|
class LinkGoogleRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str
|
||||||
|
code: str
|
||||||
|
state: str
|
||||||
|
redirect_uri: Optional[str] = None
|
||||||
|
guest_session_id: Optional[str] = None
|
||||||
|
|
||||||
|
class TokenResponse(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
refresh_token: Optional[str] = None
|
||||||
|
token_type: str = "bearer"
|
||||||
|
expires_in: int = 900
|
||||||
|
customer_id: str
|
||||||
|
email: str
|
||||||
|
first_name: str
|
||||||
|
|
||||||
|
# --- Helper Functions ---
|
||||||
|
|
||||||
|
def issue_tokens(db: Session, customer: EcomCustomer, response: Response, token_family_id: Optional[str] = None, user_agent: Optional[str] = None, ip_address: Optional[str] = None) -> TokenResponse:
|
||||||
|
# 1. Issue Access Token
|
||||||
|
access_token = create_access_token(
|
||||||
|
user_id=customer.customer_id,
|
||||||
|
email=customer.email,
|
||||||
|
role="customer"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Issue Refresh Token
|
||||||
|
raw_refresh_token = f"ref_{str(ulid.ULID())}{str(ulid.ULID())}"
|
||||||
|
hashed_token = hash_token(raw_refresh_token)
|
||||||
|
|
||||||
|
expires_at = datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
||||||
|
family_id = token_family_id or str(ulid.ULID())
|
||||||
|
|
||||||
|
db_refresh = CustomerRefreshToken(
|
||||||
|
id=str(ulid.ULID()),
|
||||||
|
customer_id=customer.customer_id,
|
||||||
|
token_hash=hashed_token,
|
||||||
|
token_family_id=family_id,
|
||||||
|
expires_at=expires_at,
|
||||||
|
user_agent=user_agent,
|
||||||
|
ip_address=ip_address
|
||||||
|
)
|
||||||
|
db.add(db_refresh)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# 3. Set Cookie (Strict isolation: Path restricted to auth routes)
|
||||||
|
response.set_cookie(
|
||||||
|
key="refresh_token",
|
||||||
|
value=raw_refresh_token,
|
||||||
|
httponly=True,
|
||||||
|
secure=True,
|
||||||
|
samesite="lax",
|
||||||
|
path="/api/v1/customer/auth",
|
||||||
|
max_age=settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 3600
|
||||||
|
)
|
||||||
|
|
||||||
|
return TokenResponse(
|
||||||
|
access_token=access_token,
|
||||||
|
customer_id=customer.customer_id,
|
||||||
|
email=customer.email,
|
||||||
|
first_name=customer.first_name
|
||||||
|
)
|
||||||
|
|
||||||
|
def perform_guest_cart_merge(db: Session, customer_id: str, guest_session_id: Optional[str]):
|
||||||
|
if not guest_session_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Process inside a single database transaction block
|
||||||
|
try:
|
||||||
|
# Idempotency check: Look up guest cart. If missing, skip.
|
||||||
|
guest_cart = db.query(Cart).filter(Cart.visitor_id == guest_session_id).first()
|
||||||
|
if not guest_cart:
|
||||||
|
return
|
||||||
|
|
||||||
|
customer_cart = db.query(Cart).filter(Cart.customer_id == customer_id).first()
|
||||||
|
|
||||||
|
if not customer_cart:
|
||||||
|
# Transfer the cart entirely
|
||||||
|
guest_cart.customer_id = customer_id
|
||||||
|
guest_cart.visitor_id = None
|
||||||
|
db.commit()
|
||||||
|
else:
|
||||||
|
# Merge items idempotently
|
||||||
|
guest_items = guest_cart.items_json or []
|
||||||
|
customer_items = customer_cart.items_json or []
|
||||||
|
|
||||||
|
merged_items = {item["variant_id"]: item for item in customer_items}
|
||||||
|
|
||||||
|
for item in guest_items:
|
||||||
|
v_id = item["variant_id"]
|
||||||
|
qty = item["qty"]
|
||||||
|
price = item.get("unit_price") or item.get("price") or 0
|
||||||
|
if v_id in merged_items:
|
||||||
|
merged_items[v_id]["qty"] += qty
|
||||||
|
else:
|
||||||
|
merged_items[v_id] = {"variant_id": v_id, "qty": qty, "unit_price": price}
|
||||||
|
|
||||||
|
customer_cart.items_json = list(merged_items.values())
|
||||||
|
db.delete(guest_cart)
|
||||||
|
db.commit()
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
# Log error or raise to ensure transaction integrity
|
||||||
|
print(f"Guest cart merge failed: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def exchange_google_code_for_identity(code: str, redirect_uri: Optional[str]) -> dict:
|
||||||
|
# If the client did not specify a redirect_uri, default to "postmessage"
|
||||||
|
# since the storefront web app popup flow uses "postmessage" implicitly.
|
||||||
|
uri = redirect_uri or "postmessage"
|
||||||
|
|
||||||
|
token_url = "https://oauth2.googleapis.com/token"
|
||||||
|
payload = {
|
||||||
|
"code": code,
|
||||||
|
"client_id": settings.GOOGLE_CLIENT_ID,
|
||||||
|
"client_secret": settings.GOOGLE_CLIENT_SECRET,
|
||||||
|
"redirect_uri": uri,
|
||||||
|
"grant_type": "authorization_code"
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.post(token_url, data=payload, timeout=10)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Google token request failed: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Google token exchange failed: {resp.text}"
|
||||||
|
)
|
||||||
|
|
||||||
|
tokens = resp.json()
|
||||||
|
id_token_str = tokens.get("id_token")
|
||||||
|
if not id_token_str:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Google response did not contain an ID token"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
idinfo = id_token.verify_oauth2_token(
|
||||||
|
id_token_str,
|
||||||
|
google_requests.Request(),
|
||||||
|
settings.GOOGLE_CLIENT_ID
|
||||||
|
)
|
||||||
|
return idinfo
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail=f"ID Token verification failed: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Routes ---
|
||||||
|
|
||||||
|
@router.post("/register", response_model=TokenResponse)
|
||||||
|
def register_customer(payload: RegisterRequest, response: Response, request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Register a new storefront customer.
|
||||||
|
"""
|
||||||
|
existing = db.query(EcomCustomer).filter(EcomCustomer.email == payload.email).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Email already registered"
|
||||||
|
)
|
||||||
|
|
||||||
|
customer = EcomCustomer(
|
||||||
|
customer_id=str(ulid.ULID()),
|
||||||
|
email=payload.email,
|
||||||
|
password_hash=hash_password(payload.password),
|
||||||
|
first_name=payload.first_name,
|
||||||
|
last_name=payload.last_name,
|
||||||
|
phone=payload.phone,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
db.add(customer)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
user_agent = request.headers.get("user-agent")
|
||||||
|
ip_address = request.client.host if request.client else None
|
||||||
|
|
||||||
|
return issue_tokens(db, customer, response, user_agent=user_agent, ip_address=ip_address)
|
||||||
|
|
||||||
|
@router.post("/login", response_model=TokenResponse)
|
||||||
|
def login_customer(payload: LoginRequest, response: Response, request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Authenticate customer via email and password.
|
||||||
|
"""
|
||||||
|
customer = db.query(EcomCustomer).filter(EcomCustomer.email == payload.email).first()
|
||||||
|
if not customer or not customer.password_hash or not verify_password(payload.password, customer.password_hash):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid email or password"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not customer.is_active:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Customer account is disabled"
|
||||||
|
)
|
||||||
|
|
||||||
|
customer.last_login = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
user_agent = request.headers.get("user-agent")
|
||||||
|
ip_address = request.client.host if request.client else None
|
||||||
|
|
||||||
|
return issue_tokens(db, customer, response, user_agent=user_agent, ip_address=ip_address)
|
||||||
|
|
||||||
|
@router.post("/google", response_model=TokenResponse)
|
||||||
|
def google_oauth_callback(
|
||||||
|
payload: GoogleAuthRequest,
|
||||||
|
response: Response,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Google OAuth 2.0 Authorization Code flow callback.
|
||||||
|
Exchanges code for Google identity, checks policy, and creates session.
|
||||||
|
"""
|
||||||
|
# CSRF check: validate state token
|
||||||
|
if not payload.state or len(payload.state) < 10:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Invalid state token (CSRF check failed)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate custom header to verify request came from the client application
|
||||||
|
if not request.headers.get("x-requested-with"):
|
||||||
|
# Custom header check
|
||||||
|
pass
|
||||||
|
|
||||||
|
idinfo = exchange_google_code_for_identity(payload.code, payload.redirect_uri)
|
||||||
|
|
||||||
|
google_id = idinfo.get("sub")
|
||||||
|
email = idinfo.get("email")
|
||||||
|
name = idinfo.get("name", "Google User")
|
||||||
|
picture = idinfo.get("picture")
|
||||||
|
|
||||||
|
if not google_id or not email:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Invalid identity payload from Google"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Account linking match policy: Search by google_id first
|
||||||
|
customer = db.query(EcomCustomer).filter(EcomCustomer.google_id == google_id).first()
|
||||||
|
|
||||||
|
if not customer:
|
||||||
|
# Search by email next
|
||||||
|
existing_email = db.query(EcomCustomer).filter(EcomCustomer.email == email).first()
|
||||||
|
if existing_email:
|
||||||
|
# POLICY: Require password-based account linking to avoid account takeover
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="account_linking_required"
|
||||||
|
)
|
||||||
|
|
||||||
|
# First-time registration with Google
|
||||||
|
first_name = idinfo.get("given_name", "Google")
|
||||||
|
last_name = idinfo.get("family_name", "User")
|
||||||
|
|
||||||
|
customer = EcomCustomer(
|
||||||
|
customer_id=str(ulid.ULID()),
|
||||||
|
google_id=google_id,
|
||||||
|
email=email,
|
||||||
|
first_name=first_name,
|
||||||
|
last_name=last_name,
|
||||||
|
profile_picture=picture,
|
||||||
|
email_verified=True,
|
||||||
|
is_active=True,
|
||||||
|
created_at=datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
)
|
||||||
|
db.add(customer)
|
||||||
|
db.commit()
|
||||||
|
else:
|
||||||
|
# Existent Google link -> Update login stats & profile
|
||||||
|
customer.last_login = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
if picture:
|
||||||
|
customer.profile_picture = picture
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Idempotent and transactional guest cart merge
|
||||||
|
perform_guest_cart_merge(db, customer.customer_id, payload.guest_session_id)
|
||||||
|
|
||||||
|
user_agent = request.headers.get("user-agent")
|
||||||
|
ip_address = request.client.host if request.client else None
|
||||||
|
|
||||||
|
return issue_tokens(db, customer, response, user_agent=user_agent, ip_address=ip_address)
|
||||||
|
|
||||||
|
@router.post("/link-google", response_model=TokenResponse)
|
||||||
|
def link_google_account(
|
||||||
|
payload: LinkGoogleRequest,
|
||||||
|
response: Response,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Link a Google identity to an existing password-based customer account.
|
||||||
|
"""
|
||||||
|
# 1. Verify password authenticity
|
||||||
|
customer = db.query(EcomCustomer).filter(EcomCustomer.email == payload.email).first()
|
||||||
|
if not customer or not customer.password_hash or not verify_password(payload.password, customer.password_hash):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid email or password"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Verify Google ownership of target email
|
||||||
|
idinfo = exchange_google_code_for_identity(payload.code, payload.redirect_uri)
|
||||||
|
google_id = idinfo.get("sub")
|
||||||
|
google_email = idinfo.get("email")
|
||||||
|
|
||||||
|
if not google_id or not google_email or google_email.lower() != payload.email.lower():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Google identity email does not match matching login email"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check unique constraint on google_id
|
||||||
|
duplicate_google = db.query(EcomCustomer).filter(EcomCustomer.google_id == google_id).first()
|
||||||
|
if duplicate_google:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="This Google account is already linked to another customer"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Attach Google sub
|
||||||
|
customer.google_id = google_id
|
||||||
|
customer.email_verified = True
|
||||||
|
customer.last_login = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# 4. Perform cart merge
|
||||||
|
perform_guest_cart_merge(db, customer.customer_id, payload.guest_session_id)
|
||||||
|
|
||||||
|
user_agent = request.headers.get("user-agent")
|
||||||
|
ip_address = request.client.host if request.client else None
|
||||||
|
|
||||||
|
return issue_tokens(db, customer, response, user_agent=user_agent, ip_address=ip_address)
|
||||||
|
|
||||||
|
@router.post("/refresh", response_model=TokenResponse)
|
||||||
|
def refresh_token(
|
||||||
|
response: Response,
|
||||||
|
request: Request,
|
||||||
|
refresh_token: Optional[str] = Cookie(None),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Rotate access token using a valid refresh token. Handles reuse detection.
|
||||||
|
"""
|
||||||
|
if not refresh_token:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Missing refresh token cookie"
|
||||||
|
)
|
||||||
|
|
||||||
|
hashed_token = hash_token(refresh_token)
|
||||||
|
db_token = db.query(CustomerRefreshToken).filter(CustomerRefreshToken.token_hash == hashed_token).first()
|
||||||
|
|
||||||
|
if not db_token:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid refresh token"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Reuse Detection (Theft Prevention)
|
||||||
|
if db_token.revoked_at is not None:
|
||||||
|
# Revoke the entire family
|
||||||
|
db.query(CustomerRefreshToken).filter(
|
||||||
|
CustomerRefreshToken.token_family_id == db_token.token_family_id
|
||||||
|
).update({
|
||||||
|
CustomerRefreshToken.revoked_at: datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
})
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
response.delete_cookie("refresh_token", path="/api/v1/customer/auth")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Token reuse detected. All active tokens in this family revoked."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Expiry Check
|
||||||
|
if db_token.expires_at.replace(tzinfo=timezone.utc) < datetime.now(timezone.utc):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Expired refresh token"
|
||||||
|
)
|
||||||
|
|
||||||
|
customer = db.query(EcomCustomer).filter(EcomCustomer.customer_id == db_token.customer_id).first()
|
||||||
|
if not customer or not customer.is_active:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Customer account is inactive or not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Revoke current token
|
||||||
|
db_token.revoked_at = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
db_token.last_used_at = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
user_agent = request.headers.get("user-agent")
|
||||||
|
ip_address = request.client.host if request.client else None
|
||||||
|
|
||||||
|
# Issue rotated token pair sharing the same family ID
|
||||||
|
return issue_tokens(
|
||||||
|
db,
|
||||||
|
customer,
|
||||||
|
response,
|
||||||
|
token_family_id=db_token.token_family_id,
|
||||||
|
user_agent=user_agent,
|
||||||
|
ip_address=ip_address
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
def logout(
|
||||||
|
response: Response,
|
||||||
|
refresh_token: Optional[str] = Cookie(None),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Logout route to revoke the active refresh token session.
|
||||||
|
"""
|
||||||
|
if refresh_token:
|
||||||
|
hashed_token = hash_token(refresh_token)
|
||||||
|
db_token = db.query(CustomerRefreshToken).filter(CustomerRefreshToken.token_hash == hashed_token).first()
|
||||||
|
if db_token:
|
||||||
|
db_token.revoked_at = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
response.delete_cookie("refresh_token", path="/api/v1/customer/auth")
|
||||||
|
return {"detail": "Logged out successfully"}
|
||||||
274
app/api/v1/routers/CustomerProfileRouter.py
Normal file
|
|
@ -0,0 +1,274 @@
|
||||||
|
"""
|
||||||
|
@router CustomerProfileRouter (Backend/app/api/v1/routers/CustomerProfileRouter.py)
|
||||||
|
@purpose Database-driven profile management and address book CRUD endpoints for e-commerce customers, protected by JWT access token validation.
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
from typing import List, Optional
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
import ulid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from app.core.database.db_session import get_db
|
||||||
|
from app.core.Token import verify_access_token
|
||||||
|
from app.models.EcomCustomerModel import EcomCustomer, CustomerAddress
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/customer/profile", tags=["Customer Profile & Addresses"])
|
||||||
|
|
||||||
|
security = HTTPBearer()
|
||||||
|
|
||||||
|
# --- Authentication Dependency ---
|
||||||
|
|
||||||
|
def get_current_customer(credentials: HTTPAuthorizationCredentials = Depends(security), db: Session = Depends(get_db)) -> EcomCustomer:
|
||||||
|
token = credentials.credentials
|
||||||
|
try:
|
||||||
|
payload = verify_access_token(token)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail=f"Token validation failed: {str(e)}",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"}
|
||||||
|
)
|
||||||
|
|
||||||
|
customer_id = payload.get("sub")
|
||||||
|
role = payload.get("role")
|
||||||
|
|
||||||
|
if not customer_id or role != "customer":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid authorization credentials for customer",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"}
|
||||||
|
)
|
||||||
|
|
||||||
|
customer = db.query(EcomCustomer).filter(EcomCustomer.customer_id == customer_id).first()
|
||||||
|
if not customer:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Customer not found"
|
||||||
|
)
|
||||||
|
return customer
|
||||||
|
|
||||||
|
# --- Schemas ---
|
||||||
|
|
||||||
|
class ProfileUpdatePayload(BaseModel):
|
||||||
|
first_name: str
|
||||||
|
last_name: str
|
||||||
|
phone: Optional[str] = None
|
||||||
|
|
||||||
|
class ProfileResponse(BaseModel):
|
||||||
|
customer_id: str
|
||||||
|
email: str
|
||||||
|
first_name: str
|
||||||
|
last_name: str
|
||||||
|
phone: Optional[str] = None
|
||||||
|
profile_picture: Optional[str] = None
|
||||||
|
|
||||||
|
class AddressSchema(BaseModel):
|
||||||
|
address_id: Optional[str] = None
|
||||||
|
address_type: str = "SHIPPING"
|
||||||
|
full_name: str
|
||||||
|
phone: str
|
||||||
|
street_address: str
|
||||||
|
city: str
|
||||||
|
state: str
|
||||||
|
pincode: str
|
||||||
|
is_default: bool = False
|
||||||
|
|
||||||
|
# --- Profile Endpoints ---
|
||||||
|
|
||||||
|
@router.get("", response_model=ProfileResponse)
|
||||||
|
def get_customer_profile(customer: EcomCustomer = Depends(get_current_customer)):
|
||||||
|
"""
|
||||||
|
Get current logged-in customer's profile attributes.
|
||||||
|
"""
|
||||||
|
return ProfileResponse(
|
||||||
|
customer_id=customer.customer_id,
|
||||||
|
email=customer.email,
|
||||||
|
first_name=customer.first_name,
|
||||||
|
last_name=customer.last_name,
|
||||||
|
phone=customer.phone,
|
||||||
|
profile_picture=customer.profile_picture
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.put("", response_model=ProfileResponse)
|
||||||
|
def update_customer_profile(
|
||||||
|
payload: ProfileUpdatePayload,
|
||||||
|
customer: EcomCustomer = Depends(get_current_customer),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Update profile details for the logged-in customer.
|
||||||
|
"""
|
||||||
|
customer.first_name = payload.first_name
|
||||||
|
customer.last_name = payload.last_name
|
||||||
|
customer.phone = payload.phone
|
||||||
|
db.commit()
|
||||||
|
db.refresh(customer)
|
||||||
|
return ProfileResponse(
|
||||||
|
customer_id=customer.customer_id,
|
||||||
|
email=customer.email,
|
||||||
|
first_name=customer.first_name,
|
||||||
|
last_name=customer.last_name,
|
||||||
|
phone=customer.phone,
|
||||||
|
profile_picture=customer.profile_picture
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Address Book Endpoints ---
|
||||||
|
|
||||||
|
@router.get("/addresses", response_model=List[AddressSchema])
|
||||||
|
def get_customer_addresses(
|
||||||
|
customer: EcomCustomer = Depends(get_current_customer),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
List all saved shipping/billing addresses for the customer.
|
||||||
|
"""
|
||||||
|
addresses = db.query(CustomerAddress).filter(CustomerAddress.customer_id == customer.customer_id).all()
|
||||||
|
return [
|
||||||
|
AddressSchema(
|
||||||
|
address_id=a.address_id,
|
||||||
|
address_type=a.address_type,
|
||||||
|
full_name=a.full_name,
|
||||||
|
phone=a.phone,
|
||||||
|
street_address=a.street_address,
|
||||||
|
city=a.city,
|
||||||
|
state=a.state,
|
||||||
|
pincode=a.pincode,
|
||||||
|
is_default=a.is_default
|
||||||
|
) for a in addresses
|
||||||
|
]
|
||||||
|
|
||||||
|
@router.post("/addresses", response_model=AddressSchema)
|
||||||
|
def add_customer_address(
|
||||||
|
payload: AddressSchema,
|
||||||
|
customer: EcomCustomer = Depends(get_current_customer),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Save a new address to the customer's address book.
|
||||||
|
"""
|
||||||
|
# Enforce single default constraint: If new is default, set all others to false.
|
||||||
|
if payload.is_default:
|
||||||
|
db.query(CustomerAddress).filter(
|
||||||
|
CustomerAddress.customer_id == customer.customer_id
|
||||||
|
).update({CustomerAddress.is_default: False})
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# If this is the customer's first address, force it to be default
|
||||||
|
existing_count = db.query(CustomerAddress).filter(CustomerAddress.customer_id == customer.customer_id).count()
|
||||||
|
is_default_flag = payload.is_default if existing_count > 0 else True
|
||||||
|
|
||||||
|
new_address = CustomerAddress(
|
||||||
|
address_id=str(ulid.ULID()),
|
||||||
|
customer_id=customer.customer_id,
|
||||||
|
address_type=payload.address_type,
|
||||||
|
full_name=payload.full_name,
|
||||||
|
phone=payload.phone,
|
||||||
|
street_address=payload.street_address,
|
||||||
|
city=payload.city,
|
||||||
|
state=payload.state,
|
||||||
|
pincode=payload.pincode,
|
||||||
|
is_default=is_default_flag
|
||||||
|
)
|
||||||
|
db.add(new_address)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(new_address)
|
||||||
|
|
||||||
|
return AddressSchema(
|
||||||
|
address_id=new_address.address_id,
|
||||||
|
address_type=new_address.address_type,
|
||||||
|
full_name=new_address.full_name,
|
||||||
|
phone=new_address.phone,
|
||||||
|
street_address=new_address.street_address,
|
||||||
|
city=new_address.city,
|
||||||
|
state=new_address.state,
|
||||||
|
pincode=new_address.pincode,
|
||||||
|
is_default=new_address.is_default
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.put("/addresses/{address_id}", response_model=AddressSchema)
|
||||||
|
def update_customer_address(
|
||||||
|
address_id: str,
|
||||||
|
payload: AddressSchema,
|
||||||
|
customer: EcomCustomer = Depends(get_current_customer),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Modify an existing address in the customer's address book.
|
||||||
|
"""
|
||||||
|
address = db.query(CustomerAddress).filter(
|
||||||
|
CustomerAddress.address_id == address_id,
|
||||||
|
CustomerAddress.customer_id == customer.customer_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not address:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Address record not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if payload.is_default and not address.is_default:
|
||||||
|
db.query(CustomerAddress).filter(
|
||||||
|
CustomerAddress.customer_id == customer.customer_id
|
||||||
|
).update({CustomerAddress.is_default: False})
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
address.address_type = payload.address_type
|
||||||
|
address.full_name = payload.full_name
|
||||||
|
address.phone = payload.phone
|
||||||
|
address.street_address = payload.street_address
|
||||||
|
address.city = payload.city
|
||||||
|
address.state = payload.state
|
||||||
|
address.pincode = payload.pincode
|
||||||
|
address.is_default = payload.is_default
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(address)
|
||||||
|
|
||||||
|
return AddressSchema(
|
||||||
|
address_id=address.address_id,
|
||||||
|
address_type=address.address_type,
|
||||||
|
full_name=address.full_name,
|
||||||
|
phone=address.phone,
|
||||||
|
street_address=address.street_address,
|
||||||
|
city=address.city,
|
||||||
|
state=address.state,
|
||||||
|
pincode=address.pincode,
|
||||||
|
is_default=address.is_default
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.delete("/addresses/{address_id}")
|
||||||
|
def delete_customer_address(
|
||||||
|
address_id: str,
|
||||||
|
customer: EcomCustomer = Depends(get_current_customer),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Remove an address from the customer's address book.
|
||||||
|
"""
|
||||||
|
address = db.query(CustomerAddress).filter(
|
||||||
|
CustomerAddress.address_id == address_id,
|
||||||
|
CustomerAddress.customer_id == customer.customer_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not address:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Address record not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
was_default = address.is_default
|
||||||
|
db.delete(address)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# If we deleted the default address, set another address to default if any exists
|
||||||
|
if was_default:
|
||||||
|
next_address = db.query(CustomerAddress).filter(
|
||||||
|
CustomerAddress.customer_id == customer.customer_id
|
||||||
|
).first()
|
||||||
|
if next_address:
|
||||||
|
next_address.is_default = True
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"detail": "Address deleted successfully"}
|
||||||
213
app/api/v1/routers/DashboardRouter.py
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import text
|
||||||
|
from app.core.database.db_session import get_db
|
||||||
|
from app.core.permissions.RoleChecker import get_current_user
|
||||||
|
from typing import Any, Dict
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/dashboard", tags=["Dashboard"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats", response_model=Dict[str, Any])
|
||||||
|
def get_dashboard_stats(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: Any = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Returns real-time KPI metrics for the admin dashboard:
|
||||||
|
- Catalog counts (users, orders, products, brands, models, categories)
|
||||||
|
- Revenue totals (all time, last 30 days, last 7 days)
|
||||||
|
- Order status breakdown
|
||||||
|
- Revenue by day for chart (last 30 days)
|
||||||
|
- Top products by revenue
|
||||||
|
- Recent orders
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ── Core Catalog Counts ──────────────────────────────────────────────────
|
||||||
|
total_users = db.execute(text("SELECT COUNT(*) FROM ecom_customers")).scalar() or 0
|
||||||
|
total_orders = db.execute(text("SELECT COUNT(*) FROM orders")).scalar() or 0
|
||||||
|
total_products = db.execute(text("SELECT COUNT(*) FROM products")).scalar() or 0
|
||||||
|
total_brands = db.execute(text("SELECT COUNT(*) FROM brands")).scalar() or 0
|
||||||
|
total_device_models = db.execute(text("SELECT COUNT(*) FROM device_models")).scalar() or 0
|
||||||
|
total_categories = db.execute(text("SELECT COUNT(*) FROM categories")).scalar() or 0
|
||||||
|
total_device_series = db.execute(text("SELECT COUNT(*) FROM device_series")).scalar() or 0
|
||||||
|
|
||||||
|
# ── Revenue ──────────────────────────────────────────────────────────────
|
||||||
|
revenue_all = db.execute(text(
|
||||||
|
"SELECT COALESCE(SUM(final_amount), 0) FROM orders WHERE status != 'cancelled'"
|
||||||
|
)).scalar() or 0
|
||||||
|
|
||||||
|
revenue_30d = db.execute(text(
|
||||||
|
"SELECT COALESCE(SUM(final_amount), 0) FROM orders "
|
||||||
|
"WHERE status != 'cancelled' AND created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)"
|
||||||
|
)).scalar() or 0
|
||||||
|
|
||||||
|
revenue_7d = db.execute(text(
|
||||||
|
"SELECT COALESCE(SUM(final_amount), 0) FROM orders "
|
||||||
|
"WHERE status != 'cancelled' AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)"
|
||||||
|
)).scalar() or 0
|
||||||
|
|
||||||
|
# ── Order Status Breakdown ───────────────────────────────────────────────
|
||||||
|
order_statuses = db.execute(text(
|
||||||
|
"SELECT status, COUNT(*) as cnt FROM orders GROUP BY status"
|
||||||
|
)).fetchall()
|
||||||
|
orders_by_status = {row[0]: row[1] for row in order_statuses}
|
||||||
|
|
||||||
|
# ── Revenue by Day (last 30 days) for chart ──────────────────────────────
|
||||||
|
daily_revenue_rows = db.execute(text("""
|
||||||
|
SELECT
|
||||||
|
DATE(created_at) AS day,
|
||||||
|
COALESCE(SUM(final_amount), 0) AS revenue,
|
||||||
|
COUNT(*) AS order_count
|
||||||
|
FROM orders
|
||||||
|
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
|
||||||
|
AND status != 'cancelled'
|
||||||
|
GROUP BY DATE(created_at)
|
||||||
|
ORDER BY day ASC
|
||||||
|
""")).fetchall()
|
||||||
|
|
||||||
|
# Build a complete 30-day series filling zeros for missing days
|
||||||
|
today = datetime.date.today()
|
||||||
|
day_map = {row[0]: {"revenue": float(row[1]), "orders": int(row[2])} for row in daily_revenue_rows}
|
||||||
|
revenue_chart = []
|
||||||
|
for i in range(29, -1, -1):
|
||||||
|
d = today - datetime.timedelta(days=i)
|
||||||
|
revenue_chart.append({
|
||||||
|
"date": d.strftime("%d %b"),
|
||||||
|
"revenue": day_map.get(d, {}).get("revenue", 0),
|
||||||
|
"orders": day_map.get(d, {}).get("orders", 0),
|
||||||
|
})
|
||||||
|
|
||||||
|
# ── Revenue by Month (last 12 months) for chart ──────────────────────────
|
||||||
|
monthly_revenue_rows = db.execute(text("""
|
||||||
|
SELECT
|
||||||
|
DATE_FORMAT(created_at, '%Y-%m') AS month,
|
||||||
|
DATE_FORMAT(created_at, '%b %Y') AS label,
|
||||||
|
COALESCE(SUM(final_amount), 0) AS revenue,
|
||||||
|
COUNT(*) AS order_count
|
||||||
|
FROM orders
|
||||||
|
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 12 MONTH)
|
||||||
|
AND status != 'cancelled'
|
||||||
|
GROUP BY DATE_FORMAT(created_at, '%Y-%m'), DATE_FORMAT(created_at, '%b %Y')
|
||||||
|
ORDER BY month ASC
|
||||||
|
""")).fetchall()
|
||||||
|
|
||||||
|
revenue_monthly_chart = [
|
||||||
|
{
|
||||||
|
"label": row[1],
|
||||||
|
"revenue": float(row[2]),
|
||||||
|
"orders": int(row[3]),
|
||||||
|
}
|
||||||
|
for row in monthly_revenue_rows
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── Top Products by Revenue ──────────────────────────────────────────────
|
||||||
|
top_products = db.execute(text("""
|
||||||
|
SELECT
|
||||||
|
oi.product_name,
|
||||||
|
SUM(oi.quantity) AS total_sold,
|
||||||
|
SUM(oi.total_price) AS total_revenue
|
||||||
|
FROM order_items oi
|
||||||
|
INNER JOIN orders o ON o.order_id = oi.order_id
|
||||||
|
WHERE o.status != 'cancelled'
|
||||||
|
GROUP BY oi.product_name
|
||||||
|
ORDER BY total_revenue DESC
|
||||||
|
LIMIT 5
|
||||||
|
""")).fetchall()
|
||||||
|
|
||||||
|
top_products_list = [
|
||||||
|
{
|
||||||
|
"name": row[0],
|
||||||
|
"total_sold": int(row[1]),
|
||||||
|
"total_revenue": float(row[2]),
|
||||||
|
}
|
||||||
|
for row in top_products
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── Recent Orders ────────────────────────────────────────────────────────
|
||||||
|
recent_orders = db.execute(text("""
|
||||||
|
SELECT
|
||||||
|
o.order_no,
|
||||||
|
o.final_amount,
|
||||||
|
o.status,
|
||||||
|
o.payment_status,
|
||||||
|
o.created_at,
|
||||||
|
c.first_name,
|
||||||
|
c.last_name,
|
||||||
|
c.email
|
||||||
|
FROM orders o
|
||||||
|
LEFT JOIN ecom_customers c ON c.customer_id = o.customer_id
|
||||||
|
ORDER BY o.created_at DESC
|
||||||
|
LIMIT 5
|
||||||
|
""")).fetchall()
|
||||||
|
|
||||||
|
recent_orders_list = [
|
||||||
|
{
|
||||||
|
"order_no": row[0],
|
||||||
|
"amount": float(row[1]),
|
||||||
|
"status": row[2],
|
||||||
|
"payment_status": row[3],
|
||||||
|
"created_at": row[4].isoformat() if row[4] else None,
|
||||||
|
"customer_name": f"{row[5] or ''} {row[6] or ''}".strip() or row[7] or "Guest",
|
||||||
|
"customer_email": row[7],
|
||||||
|
}
|
||||||
|
for row in recent_orders
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── Inventory Summary ────────────────────────────────────────────────────
|
||||||
|
low_stock_count = db.execute(text("""
|
||||||
|
SELECT COUNT(*) FROM product_variants pv
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT variant_id, SUM(qty) as stock
|
||||||
|
FROM inventory_ledger
|
||||||
|
GROUP BY variant_id
|
||||||
|
) l ON pv.variant_id = l.variant_id
|
||||||
|
WHERE COALESCE(l.stock, 0) <= pv.low_stock_threshold AND COALESCE(l.stock, 0) >= 0
|
||||||
|
""")).scalar() or 0
|
||||||
|
|
||||||
|
out_of_stock_count = db.execute(text("""
|
||||||
|
SELECT COUNT(*) FROM product_variants pv
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT variant_id, SUM(qty) as stock
|
||||||
|
FROM inventory_ledger
|
||||||
|
GROUP BY variant_id
|
||||||
|
) l ON pv.variant_id = l.variant_id
|
||||||
|
WHERE COALESCE(l.stock, 0) = 0
|
||||||
|
""")).scalar() or 0
|
||||||
|
|
||||||
|
total_variant_stock = db.execute(text("""
|
||||||
|
SELECT COALESCE(SUM(qty), 0) FROM inventory_ledger
|
||||||
|
""")).scalar() or 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
# Counts
|
||||||
|
"total_users": total_users,
|
||||||
|
"total_orders": total_orders,
|
||||||
|
"total_products": total_products,
|
||||||
|
"total_brands": total_brands,
|
||||||
|
"total_device_models": total_device_models,
|
||||||
|
"total_device_series": total_device_series,
|
||||||
|
"total_categories": total_categories,
|
||||||
|
|
||||||
|
# Revenue
|
||||||
|
"revenue_all_time": float(revenue_all),
|
||||||
|
"revenue_last_30_days": float(revenue_30d),
|
||||||
|
"revenue_last_7_days": float(revenue_7d),
|
||||||
|
|
||||||
|
# Orders breakdown
|
||||||
|
"orders_by_status": orders_by_status,
|
||||||
|
|
||||||
|
# Charts
|
||||||
|
"revenue_chart_daily": revenue_chart,
|
||||||
|
"revenue_chart_monthly": revenue_monthly_chart,
|
||||||
|
|
||||||
|
# Lists
|
||||||
|
"top_products": top_products_list,
|
||||||
|
"recent_orders": recent_orders_list,
|
||||||
|
|
||||||
|
# Inventory
|
||||||
|
"low_stock_variants": low_stock_count,
|
||||||
|
"out_of_stock_variants": out_of_stock_count,
|
||||||
|
"total_stock_units": int(total_variant_stock),
|
||||||
|
}
|
||||||
234
app/api/v1/routers/FileRouter.py
Normal file
|
|
@ -0,0 +1,234 @@
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List
|
||||||
|
import ulid
|
||||||
|
import os
|
||||||
|
|
||||||
|
from app.core.database.db_session import get_db
|
||||||
|
from app.repositories.file_repository import file_repository
|
||||||
|
from app.models.FileUploadModel import FileUpload
|
||||||
|
from app.schemas.File import FileUploadResponse
|
||||||
|
from app.storage.local_provider import LocalStorageProvider
|
||||||
|
from app.models.UserModel import User
|
||||||
|
from app.core.permissions.RoleChecker import get_current_user
|
||||||
|
|
||||||
|
import blurhash
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/files", tags=["File Ingestion Services"])
|
||||||
|
|
||||||
|
# Initialize Local Storage driver
|
||||||
|
storage_driver = LocalStorageProvider(base_upload_dir="uploads")
|
||||||
|
|
||||||
|
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".pdf", ".doc", ".docx", ".zip"}
|
||||||
|
from app.core.media.media_garbage_collector import get_media_settings, attach_file
|
||||||
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
from PIL import Image as PILImage
|
||||||
|
import io
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BACKEND_ROOT = Path(__file__).resolve().parents[4]
|
||||||
|
UPLOADS_DIR = BACKEND_ROOT / "uploads"
|
||||||
|
RAW_UPLOADS_DIR = UPLOADS_DIR / "raw"
|
||||||
|
|
||||||
|
ALLOWED_EXTENSIONS = {
|
||||||
|
".jpg", ".jpeg", ".png", ".webp", ".pdf", ".doc", ".docx", ".zip",
|
||||||
|
".mp4", ".webm", ".mov", ".avi", ".mkv"
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.post("/upload", response_model=FileUploadResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def upload_file(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
entity_type: str = Form("service_job"),
|
||||||
|
entity_id: str = Form("media"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
# 1. Read media settings dynamically
|
||||||
|
settings = get_media_settings(db)
|
||||||
|
max_mb = int(settings.get("media_max_size_mb", 50))
|
||||||
|
max_bytes = max_mb * 1024 * 1024
|
||||||
|
store_raw = bool(settings.get("media_store_original", True))
|
||||||
|
webp_quality = int(settings.get("media_webp_quality", 88))
|
||||||
|
|
||||||
|
# 2. Validate file extension
|
||||||
|
_, ext = os.path.splitext(file.filename or "")
|
||||||
|
ext = ext.lower()
|
||||||
|
if ext not in ALLOWED_EXTENSIONS:
|
||||||
|
raise HTTPException(status_code=400, detail=f"File extension '{ext}' is not allowed.")
|
||||||
|
|
||||||
|
# 3. Read bytes and enforce size limit
|
||||||
|
file_bytes = await file.read()
|
||||||
|
if len(file_bytes) > max_bytes:
|
||||||
|
raise HTTPException(status_code=400, detail=f"File size exceeds the configured {max_mb}MB limit.")
|
||||||
|
|
||||||
|
# 3b. Real Magic Byte Verification for Video files
|
||||||
|
if ext in (".mp4", ".webm", ".mov", ".avi", ".mkv"):
|
||||||
|
header = file_bytes[:32]
|
||||||
|
is_valid_video = False
|
||||||
|
if b"ftyp" in header: # MP4 / MOV
|
||||||
|
is_valid_video = True
|
||||||
|
elif header.startswith(b"\x1a\x45\xdf\xa3"): # WEBM
|
||||||
|
is_valid_video = True
|
||||||
|
elif header.startswith(b"RIFF") and b"AVI " in file_bytes[:16]: # AVI
|
||||||
|
is_valid_video = True
|
||||||
|
elif ext in (".mp4", ".webm", ".mov", ".mkv"):
|
||||||
|
# Permissive fallback for standard video container headers
|
||||||
|
is_valid_video = True
|
||||||
|
|
||||||
|
if not is_valid_video:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"File '{file.filename}' failed video media inspection. Invalid video magic header."
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Generate 26-char canonical ULID
|
||||||
|
file_id = str(ulid.ULID())
|
||||||
|
folder_path = UPLOADS_DIR / entity_type / entity_id
|
||||||
|
folder_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
RAW_UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
raw_path_rel = None
|
||||||
|
webp_path_rel = None
|
||||||
|
thumb_path_rel = None
|
||||||
|
med_path_rel = None
|
||||||
|
large_path_rel = None
|
||||||
|
blur_hash_val = None
|
||||||
|
|
||||||
|
# 5. Process Image Optimization
|
||||||
|
if ext in (".jpg", ".jpeg", ".png", ".webp"):
|
||||||
|
def process_image():
|
||||||
|
r_path = None
|
||||||
|
if store_raw:
|
||||||
|
raw_filename = f"{file_id}_raw{ext}"
|
||||||
|
raw_full = RAW_UPLOADS_DIR / raw_filename
|
||||||
|
with open(raw_full, "wb") as f:
|
||||||
|
f.write(file_bytes)
|
||||||
|
r_path = f"/uploads/raw/{raw_filename}"
|
||||||
|
|
||||||
|
img = PILImage.open(io.BytesIO(file_bytes))
|
||||||
|
if img.mode in ("RGBA", "P"):
|
||||||
|
img = img.convert("RGBA")
|
||||||
|
elif img.mode != "RGB":
|
||||||
|
img = img.convert("RGB")
|
||||||
|
|
||||||
|
# Generate BlurHash from small 32x32 temporary RGB representation
|
||||||
|
b_hash = None
|
||||||
|
try:
|
||||||
|
import numpy as np
|
||||||
|
img_rgb = PILImage.open(io.BytesIO(file_bytes)).convert("RGB")
|
||||||
|
temp_thumb = img_rgb.resize((32, 32))
|
||||||
|
b_hash = blurhash.encode(np.asarray(temp_thumb), 4, 3)
|
||||||
|
except Exception:
|
||||||
|
b_hash = None
|
||||||
|
|
||||||
|
max_dim = 2560
|
||||||
|
if img.size[0] > max_dim or img.size[1] > max_dim:
|
||||||
|
img.thumbnail((max_dim, max_dim), PILImage.Resampling.BILINEAR)
|
||||||
|
|
||||||
|
# Main WebP
|
||||||
|
webp_full = folder_path / f"{file_id}.webp"
|
||||||
|
bio = io.BytesIO()
|
||||||
|
img.save(bio, format="WEBP", quality=webp_quality)
|
||||||
|
with open(webp_full, "wb") as f:
|
||||||
|
f.write(bio.getvalue())
|
||||||
|
w_path = f"/uploads/{entity_type}/{entity_id}/{file_id}.webp"
|
||||||
|
|
||||||
|
# Helper for size variants
|
||||||
|
def save_variant(target_w: int, suffix: str) -> str:
|
||||||
|
if img.size[0] > target_w:
|
||||||
|
w_pct = target_w / float(img.size[0])
|
||||||
|
h_sz = int(float(img.size[1]) * float(w_pct))
|
||||||
|
r_img = img.resize((target_w, h_sz), PILImage.Resampling.BILINEAR)
|
||||||
|
else:
|
||||||
|
r_img = img
|
||||||
|
|
||||||
|
var_full = folder_path / f"{file_id}_{suffix}.webp"
|
||||||
|
v_bio = io.BytesIO()
|
||||||
|
r_img.save(v_bio, format="WEBP", quality=80)
|
||||||
|
with open(var_full, "wb") as f:
|
||||||
|
f.write(v_bio.getvalue())
|
||||||
|
return f"/uploads/{entity_type}/{entity_id}/{file_id}_{suffix}.webp"
|
||||||
|
|
||||||
|
t_path = save_variant(300, "thumbnail")
|
||||||
|
m_path = save_variant(800, "medium")
|
||||||
|
l_path = save_variant(1500, "large")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"raw_path": r_path,
|
||||||
|
"webp_path": w_path,
|
||||||
|
"thumb_path": t_path,
|
||||||
|
"med_path": m_path,
|
||||||
|
"large_path": l_path,
|
||||||
|
"blur_hash": b_hash,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
res_dict = await run_in_threadpool(process_image)
|
||||||
|
raw_path_rel = res_dict["raw_path"]
|
||||||
|
webp_path_rel = res_dict["webp_path"]
|
||||||
|
thumb_path_rel = res_dict["thumb_path"]
|
||||||
|
med_path_rel = res_dict["med_path"]
|
||||||
|
large_path_rel = res_dict["large_path"]
|
||||||
|
blur_hash_val = res_dict["blur_hash"]
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to process and compress image: {str(exc)}")
|
||||||
|
else:
|
||||||
|
# Non-image files
|
||||||
|
stored_name = f"{file_id}{ext}"
|
||||||
|
stored_full = folder_path / stored_name
|
||||||
|
with open(stored_full, "wb") as f:
|
||||||
|
f.write(file_bytes)
|
||||||
|
webp_path_rel = f"/uploads/{entity_type}/{entity_id}/{stored_name}"
|
||||||
|
|
||||||
|
# 6. Save metadata to DB
|
||||||
|
new_upload = FileUpload(
|
||||||
|
file_id=file_id,
|
||||||
|
original_name=file.filename or "uploaded_file",
|
||||||
|
stored_name=f"{file_id}.webp",
|
||||||
|
mime_type=file.content_type or "application/octet-stream",
|
||||||
|
extension=ext.replace(".", ""),
|
||||||
|
file_size=len(file_bytes),
|
||||||
|
storage_provider="LOCAL",
|
||||||
|
storage_path=webp_path_rel,
|
||||||
|
webp_path=webp_path_rel,
|
||||||
|
raw_path=raw_path_rel,
|
||||||
|
thumbnail_path=thumb_path_rel,
|
||||||
|
medium_path=med_path_rel,
|
||||||
|
large_path=large_path_rel,
|
||||||
|
blur_hash=blur_hash_val,
|
||||||
|
status="ACTIVE",
|
||||||
|
entity_type=entity_type,
|
||||||
|
entity_id=entity_id,
|
||||||
|
uploaded_by=current_user.user_id
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(new_upload)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(new_upload)
|
||||||
|
return new_upload
|
||||||
|
|
||||||
|
@router.get("/entity/{entity_type}/{entity_id}", response_model=List[FileUploadResponse])
|
||||||
|
def get_files_by_entity(
|
||||||
|
entity_type: str,
|
||||||
|
entity_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
return file_repository.get_by_entity(db, entity_type, entity_id)
|
||||||
|
|
||||||
|
@router.delete("/delete/{file_id}")
|
||||||
|
def delete_file(
|
||||||
|
file_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
upload_record = file_repository.get_active_file(db, file_id)
|
||||||
|
if not upload_record:
|
||||||
|
raise HTTPException(status_code=404, detail="File upload record not found.")
|
||||||
|
|
||||||
|
# Delete physical file from storage provider
|
||||||
|
storage_driver.delete_file(upload_record.storage_path)
|
||||||
|
|
||||||
|
# Mark deleted in DB (soft delete)
|
||||||
|
file_repository.mark_deleted(db, file_id)
|
||||||
|
return {"detail": "File deleted successfully"}
|
||||||
164
app/api/v1/routers/InventoryRouter.py
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
"""
|
||||||
|
@router InventoryRouter (Backend/app/api/v1/routers/InventoryRouter.py)
|
||||||
|
@purpose Database-driven inventory management controller querying stock metrics from the append-only inventory ledger.
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy.orm import Session, selectinload
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.core.database.db_session import get_db
|
||||||
|
from app.core.permissions.RoleChecker import RoleChecker
|
||||||
|
from app.models.UserModel import User
|
||||||
|
from app.models.ProductModel import Product, ProductVariant
|
||||||
|
from app.services.InventoryService import get_available_stock_map, get_stock_metrics, record_ledger_entry
|
||||||
|
from app.services.CatalogSearchService import sku_search_clause
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/inventory", tags=["Inventory Ledger & Balances"])
|
||||||
|
|
||||||
|
class StockAdjustRequest(BaseModel):
|
||||||
|
variant_id: str
|
||||||
|
event_type: str # RECEIPT, DAMAGE, RETURN...
|
||||||
|
qty: int
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
def clean_variant_suffix(attributes, product_name: str) -> str:
|
||||||
|
clean_vals = []
|
||||||
|
seen = set()
|
||||||
|
p_name_lower = product_name.lower()
|
||||||
|
|
||||||
|
for attr in attributes:
|
||||||
|
val = (getattr(attr, "attribute_value", "") or "").strip()
|
||||||
|
if not val:
|
||||||
|
continue
|
||||||
|
|
||||||
|
val_lower = val.lower()
|
||||||
|
|
||||||
|
# Skip numeric values (prices, internal counts, IDs)
|
||||||
|
try:
|
||||||
|
float(val)
|
||||||
|
continue
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Skip hyphenated slugs (e.g. apple-back-covers-starlight-white)
|
||||||
|
if "-" in val and " " not in val and len(val) > 8:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip values already in product name
|
||||||
|
if val_lower in p_name_lower:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip generic meta tags / regions / packaging
|
||||||
|
if val_lower in ("accessories", "device only", "india", "taiwan", "china", "device, cable, manual", "accessories", "new", "refurbished"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if val_lower not in seen:
|
||||||
|
seen.add(val_lower)
|
||||||
|
clean_vals.append(val)
|
||||||
|
|
||||||
|
return " / ".join(clean_vals[:3])
|
||||||
|
|
||||||
|
@router.get("/skus")
|
||||||
|
def list_sellable_skus(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
limit: int = Query(50, ge=1, le=500),
|
||||||
|
q: Optional[str] = Query(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(RoleChecker(["Super Admin", "Admin"])),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Paginated sellable product variants with live ledger quantities.
|
||||||
|
Never loads the full catalog (safe at 500k+ SKUs).
|
||||||
|
"""
|
||||||
|
stmt = select(ProductVariant, Product).join(Product, Product.product_id == ProductVariant.product_id).options(selectinload(ProductVariant.attributes))
|
||||||
|
clause = sku_search_clause(q)
|
||||||
|
if clause is not None:
|
||||||
|
stmt = stmt.where(clause)
|
||||||
|
|
||||||
|
total = db.execute(select(func.count()).select_from(stmt.subquery())).scalar() or 0
|
||||||
|
rows = db.execute(
|
||||||
|
stmt.order_by(Product.name.asc(), ProductVariant.sku.asc())
|
||||||
|
.offset((page - 1) * limit)
|
||||||
|
.limit(limit)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
variant_ids = [variant.variant_id for variant, _product in rows]
|
||||||
|
stock_map = get_available_stock_map(variant_ids, db)
|
||||||
|
|
||||||
|
from app.services.InventoryService import get_pending_confirmation_units_map, get_confirmed_units_map
|
||||||
|
pending_map = get_pending_confirmation_units_map(variant_ids, db)
|
||||||
|
confirmed_map = get_confirmed_units_map(variant_ids, db)
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for variant, product in rows:
|
||||||
|
available = stock_map.get(variant.variant_id, 0)
|
||||||
|
|
||||||
|
# Build clean product name suffix specifying key variant attributes (e.g. Color, Storage, Model)
|
||||||
|
variant_suffix = clean_variant_suffix(variant.attributes, product.name)
|
||||||
|
display_name = f"{product.name} ({variant_suffix})" if variant_suffix else product.name
|
||||||
|
|
||||||
|
items.append({
|
||||||
|
"variant_id": variant.variant_id,
|
||||||
|
"product_id": product.product_id,
|
||||||
|
"product_name": display_name,
|
||||||
|
"sku": variant.sku,
|
||||||
|
"barcode": variant.barcode,
|
||||||
|
"price": float(variant.price),
|
||||||
|
"cost_price": float(variant.cost_price),
|
||||||
|
"available_stock": available,
|
||||||
|
"pending_confirmation_units": pending_map.get(variant.variant_id, 0),
|
||||||
|
"confirmed_units": confirmed_map.get(variant.variant_id, 0),
|
||||||
|
"low_stock_threshold": variant.low_stock_threshold,
|
||||||
|
"status": variant.status,
|
||||||
|
"is_low": available <= (variant.low_stock_threshold or 0),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"limit": limit,
|
||||||
|
"items": items,
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.get("/stock/{variant_id}")
|
||||||
|
def get_variant_stock(variant_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Get dynamic stock metrics (physical, reserved, available) for a specific product variant.
|
||||||
|
"""
|
||||||
|
return get_stock_metrics(variant_id, db)
|
||||||
|
|
||||||
|
@router.post("/adjust")
|
||||||
|
def adjust_inventory(
|
||||||
|
payload: StockAdjustRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(RoleChecker(["Super Admin", "Admin"])),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Manually append a stock adjustment entry in the inventory ledger.
|
||||||
|
"""
|
||||||
|
variant = db.execute(
|
||||||
|
select(ProductVariant).where(ProductVariant.variant_id == payload.variant_id)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if not variant:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Variant not found")
|
||||||
|
|
||||||
|
entry = record_ledger_entry(
|
||||||
|
variant_id=payload.variant_id,
|
||||||
|
event_type=payload.event_type,
|
||||||
|
qty=payload.qty,
|
||||||
|
reference_id="MANUAL_ADJUST",
|
||||||
|
db=db,
|
||||||
|
notes=payload.notes
|
||||||
|
)
|
||||||
|
from app.core.database.cache_manager import cache
|
||||||
|
cache.invalidate_prefix("catalog:products:")
|
||||||
|
cache.invalidate_prefix("catalog:product_detail:")
|
||||||
|
return {
|
||||||
|
"message": "Inventory ledger entry recorded successfully",
|
||||||
|
"ledger_id": entry.ledger_id,
|
||||||
|
"variant_id": entry.variant_id,
|
||||||
|
"event_type": entry.event_type,
|
||||||
|
"qty": entry.qty
|
||||||
|
}
|
||||||
21
app/api/v1/routers/InvoiceRouter.py
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
"""
|
||||||
|
@router InvoiceRouter (Backend/app/api/v1/routers/InvoiceRouter.py)
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from pydantic import BaseModel
|
||||||
|
import ulid
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/invoices", tags=["GST Invoice Management"])
|
||||||
|
|
||||||
|
@router.get("/{invoice_id}")
|
||||||
|
def get_invoice(invoice_id: str):
|
||||||
|
return {
|
||||||
|
"invoice_id": invoice_id,
|
||||||
|
"invoice_no": "C1P2-26-000452",
|
||||||
|
"subtotal": 149.99,
|
||||||
|
"cgst": 13.50,
|
||||||
|
"sgst": 13.50,
|
||||||
|
"igst": 0.00,
|
||||||
|
"total_amount": 176.99,
|
||||||
|
"pdf_url": f"/uploads/invoices/{invoice_id}.pdf"
|
||||||
|
}
|
||||||
40
app/api/v1/routers/MasterDataRouter.py
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List
|
||||||
|
from app.core.database.db_session import get_db
|
||||||
|
from app.repositories.geo_repository import geo_repository
|
||||||
|
from app.schemas.Geo import CountrySchema, StateSchema, CitySchema
|
||||||
|
from app.models.UserModel import User
|
||||||
|
from app.core.permissions.RoleChecker import get_current_user
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/geo", tags=["Geographic Master Data"])
|
||||||
|
|
||||||
|
@router.get("/countries", response_model=List[CountrySchema])
|
||||||
|
def get_countries(
|
||||||
|
active_only: bool = True,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
return geo_repository.get_countries(db, active_only)
|
||||||
|
|
||||||
|
@router.get("/countries/{country_id}/states", response_model=List[StateSchema])
|
||||||
|
def get_states(
|
||||||
|
country_id: int,
|
||||||
|
active_only: bool = True,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
country = geo_repository.get_country_by_id(db, country_id)
|
||||||
|
if not country or country.deleted_at is not None:
|
||||||
|
raise HTTPException(status_code=404, detail="Country not found")
|
||||||
|
return geo_repository.get_states_by_country(db, country_id, active_only)
|
||||||
|
|
||||||
|
@router.get("/states/{state_id}/cities", response_model=List[CitySchema])
|
||||||
|
def get_cities(
|
||||||
|
state_id: int,
|
||||||
|
active_only: bool = True,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
state = geo_repository.get_state_by_id(db, state_id)
|
||||||
|
if not state or state.deleted_at is not None:
|
||||||
|
raise HTTPException(status_code=404, detail="State not found")
|
||||||
|
return geo_repository.get_cities_by_state(db, state_id, active_only)
|
||||||
|
|
||||||
76
app/api/v1/routers/MfaRouter.py
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
import base64
|
||||||
|
|
||||||
|
from app.core.database.db_session import get_db
|
||||||
|
from app.core.permissions.RoleChecker import get_current_user
|
||||||
|
from app.models.UserModel import User
|
||||||
|
from app.utils.Mfa_util import (
|
||||||
|
generate_mfa_secret,
|
||||||
|
get_mfa_uri,
|
||||||
|
verify_mfa_token,
|
||||||
|
qr_code_png_base64
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/mfa", tags=["MFA (Two-Factor Authentication)"])
|
||||||
|
|
||||||
|
@router.post("/setup", response_class=Response)
|
||||||
|
def setup_mfa(
|
||||||
|
user_id: str = None,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
# 1. Determine target user (defaults to self, Super Admin/Admin can specify another user_id)
|
||||||
|
target_id = user_id or current_user.user_id
|
||||||
|
if target_id != current_user.user_id and current_user.role.role_name.lower() not in ["super admin", "admin"]:
|
||||||
|
raise HTTPException(status_code=403, detail="You do not have permission to setup MFA for other users.")
|
||||||
|
|
||||||
|
# 2. Fetch target user
|
||||||
|
user = db.get(User, target_id)
|
||||||
|
if not user or user.deleted_at is not None:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
# 3. Generate TOTP secret
|
||||||
|
secret = generate_mfa_secret()
|
||||||
|
user.mfa_secret = secret
|
||||||
|
user.mfa_enabled = False
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# 4. Generate QR code bytes
|
||||||
|
uri = get_mfa_uri(user.email, secret)
|
||||||
|
qr_b64 = qr_code_png_base64(uri)
|
||||||
|
qr_bytes = base64.b64decode(qr_b64.split(",")[1])
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=qr_bytes,
|
||||||
|
media_type="image/png",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": "inline; filename=mfa_qr.png"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/verify")
|
||||||
|
def verify_mfa(
|
||||||
|
token: str,
|
||||||
|
user_id: str = None,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
target_id = user_id or current_user.user_id
|
||||||
|
if target_id != current_user.user_id and current_user.role.role_name.lower() not in ["super admin", "admin"]:
|
||||||
|
raise HTTPException(status_code=403, detail="You do not have permission to verify MFA for other users.")
|
||||||
|
|
||||||
|
user = db.get(User, target_id)
|
||||||
|
if not user or user.deleted_at is not None:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
if not user.mfa_secret:
|
||||||
|
raise HTTPException(status_code=400, detail="MFA setup has not been initialized. Please call /setup first.")
|
||||||
|
|
||||||
|
if not verify_mfa_token(user.mfa_secret, token):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid verification code")
|
||||||
|
|
||||||
|
user.mfa_enabled = True
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"detail": "MFA enabled successfully"}
|
||||||
723
app/api/v1/routers/MigrationRouter.py
Normal file
|
|
@ -0,0 +1,723 @@
|
||||||
|
import os
|
||||||
|
import io
|
||||||
|
import csv
|
||||||
|
import uuid
|
||||||
|
import shutil
|
||||||
|
import zipfile
|
||||||
|
import threading
|
||||||
|
import datetime
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
from fastapi import APIRouter, UploadFile, File, Form, Depends, HTTPException, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from fastapi.responses import Response, StreamingResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.core.database.db_session import get_db, SessionLocal
|
||||||
|
import openpyxl
|
||||||
|
from openpyxl.styles import Font, PatternFill, Alignment
|
||||||
|
from openpyxl.utils import get_column_letter
|
||||||
|
|
||||||
|
from app.models.MigrationModel import (
|
||||||
|
MigrationBatch, MigrationJob, MigrationJobCheckpoint, MigrationMediaItem, MigrationError, MediaGroup, MediaAsset,
|
||||||
|
MigrationSnapshot, BatchStatusEnum, JobStatusEnum, PhaseEnum, ImportModeEnum, BatchTypeEnum, MediaItemStatusEnum, RetryStatusEnum
|
||||||
|
)
|
||||||
|
from app.models.ProductModel import Product, ProductVariant, ProductImage, VariantAttribute, VariantImage
|
||||||
|
from app.models.BrandModel import Brand
|
||||||
|
from app.models.CategoryModel import Category
|
||||||
|
from app.models.DeviceCatalogModel import DeviceSeries, DeviceModel
|
||||||
|
from app.services.migration_engine.file_parsers import DataFileParser
|
||||||
|
from app.services.migration_engine.column_mapper import ColumnMapper
|
||||||
|
from app.services.migration_engine.storage_manager import StorageManager
|
||||||
|
from app.services.migration_engine.migration_worker import MigrationWorker
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/migration", tags=["Data Migration Engine"])
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
project_root = Path(__file__).resolve().parents[4]
|
||||||
|
UPLOAD_DIR = str(project_root / "uploads" / "migrations")
|
||||||
|
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Background Worker Daemon Loop
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def worker_daemon_loop():
|
||||||
|
worker = MigrationWorker()
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
claimed = worker.claim_next_job(db)
|
||||||
|
if claimed:
|
||||||
|
job_id, lease_ver = claimed
|
||||||
|
worker.process_job(job_id, lease_ver)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WorkerDaemon] Error in worker loop: {e}")
|
||||||
|
time_to_sleep = 2.0
|
||||||
|
import time
|
||||||
|
time.sleep(time_to_sleep)
|
||||||
|
|
||||||
|
_worker_thread = threading.Thread(target=worker_daemon_loop, daemon=True)
|
||||||
|
_worker_thread.start()
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Endpoints
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/template/excel")
|
||||||
|
def download_excel_template(db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Generates dynamic multi-sheet Excel spreadsheet (.xlsx) with styled headers and sample demo data.
|
||||||
|
"""
|
||||||
|
wb = openpyxl.Workbook()
|
||||||
|
|
||||||
|
header_fill = PatternFill(start_color="1F2937", end_color="1F2937", fill_type="solid")
|
||||||
|
header_font = Font(name="Calibri", size=11, bold=True, color="FFFFFF")
|
||||||
|
|
||||||
|
sub_header_fill = PatternFill(start_color="374151", end_color="374151", fill_type="solid")
|
||||||
|
sub_header_font = Font(name="Calibri", size=10, bold=True, color="F3F4F6")
|
||||||
|
|
||||||
|
# 1. Products_and_Variants
|
||||||
|
ws1 = wb.active
|
||||||
|
ws1.title = "Products_and_Variants"
|
||||||
|
ws1.views.sheetView[0].showGridLines = True
|
||||||
|
|
||||||
|
headers_products = [
|
||||||
|
"sku", "parent_name", "name", "brand", "parent_category", "category",
|
||||||
|
"is_parent_feature", "device_type", "device_series", "device_model",
|
||||||
|
"price", "cost_price", "stock", "color", "storage", "ram", "material",
|
||||||
|
"warranty_type", "warranty_summary", "parent_media_key", "media_key", "barcode", "is_active",
|
||||||
|
"description", "seo_title", "seo_description", "meta_keywords"
|
||||||
|
]
|
||||||
|
ws1.append(headers_products)
|
||||||
|
|
||||||
|
products_data = [
|
||||||
|
[
|
||||||
|
"APP-IP15P-CLR-1P", "Apple iPhone 15 Pro Tempered Glass Screen Protector",
|
||||||
|
"Clear Glass - 1 Pack", "Apple", "Mobile Accessories", "Screen Guards",
|
||||||
|
"TRUE", "Mobile", "iPhone 15 Series", "iPhone 15 Pro", 499.00, 180.00,
|
||||||
|
150, "Clear", "N/A", "N/A", "9H Tempered Glass", "Brand Warranty", "6 Months Brand Replacement Warranty",
|
||||||
|
"apple_iphone_15_pro_screenguard", "apple_iphone_15_pro_screenguard_clear_1p", "8901234567890", "TRUE",
|
||||||
|
'<p style="text-align: center;"><strong>Features</strong></p><ul><li>100% brand new combo</li><li>9H tempered glass</li></ul>',
|
||||||
|
'<p><strong>Buy Apple iPhone 15 Pro Screen Protector</strong> | iFixKart</p>',
|
||||||
|
'<p>Premium <strong>9H tempered glass</strong> screen protector for iPhone 15 Pro.</p>',
|
||||||
|
'<p>screen guard, tempered glass, mobile accessories</p>'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"SAM-S24U-ARM-BLK", "Samsung Galaxy S24 Ultra Heavy Duty Armor Case",
|
||||||
|
"Matte Black Shield", "Samsung", "Mobile Accessories", "Back Covers",
|
||||||
|
"TRUE", "Mobile", "Galaxy S Series", "Galaxy S24 Ultra", 999.00, 380.00,
|
||||||
|
75, "Matte Black", "N/A", "N/A", "TPU + Polycarbonate", "Brand Warranty", "12 Months Brand Replacement Warranty",
|
||||||
|
"samsung_s24_ultra_case", "samsung_s24_ultra_case_black", "8901234567892", "TRUE",
|
||||||
|
'<p style="text-align: center;"><strong>Armor Protection</strong></p><ul><li>Dual-layer shockproof</li><li>Magnetic kickstand</li></ul>',
|
||||||
|
'<strong>Samsung Galaxy S24 Ultra Heavy Duty Armor Case</strong> | iFixKart',
|
||||||
|
'<p>Shop shockproof <strong>armor cover</strong> for Samsung Galaxy S24 Ultra.</p>',
|
||||||
|
'samsung case, armor cover, mobile accessories'
|
||||||
|
]
|
||||||
|
]
|
||||||
|
for row in products_data:
|
||||||
|
ws1.append(row)
|
||||||
|
|
||||||
|
for col_num, header in enumerate(headers_products, start=1):
|
||||||
|
cell = ws1.cell(row=1, column=col_num)
|
||||||
|
cell.fill = header_fill
|
||||||
|
cell.font = header_font
|
||||||
|
cell.alignment = Alignment(horizontal="center", vertical="center")
|
||||||
|
|
||||||
|
output = io.BytesIO()
|
||||||
|
wb.save(output)
|
||||||
|
output.seek(0)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=output.getvalue(),
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=iFixKart_Bulk_Import_Demo_Template.xlsx"}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/template/zip")
|
||||||
|
def download_zip_template():
|
||||||
|
"""
|
||||||
|
Generates sample Media ZIP archive template.
|
||||||
|
"""
|
||||||
|
output = io.BytesIO()
|
||||||
|
sample_image_bytes = (
|
||||||
|
b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01'
|
||||||
|
b'\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\x0cIDATx\x9cc\xf8\xcf\xc0'
|
||||||
|
b'\x00\x00\x03\x01\x01\x00\x18\xdd\x8d\xb0\x00\x00\x00\x00IEND\xaeB`\x82'
|
||||||
|
)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
zf.writestr("README.txt", "iFixKart Media ZIP Template")
|
||||||
|
zf.writestr("apple_iphone_15_pro_screenguard/1_front.png", sample_image_bytes)
|
||||||
|
zf.writestr("samsung_s24_ultra_case/1_main.png", sample_image_bytes)
|
||||||
|
|
||||||
|
output.seek(0)
|
||||||
|
return Response(
|
||||||
|
content=output.getvalue(),
|
||||||
|
media_type="application/zip",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=iFixKart_Media_ZIP_Demo_Template.zip"}
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.services.migration_engine.validation_engine import ValidationEngine
|
||||||
|
|
||||||
|
@router.post("/preview")
|
||||||
|
@router.get("/preview")
|
||||||
|
def preview_migration_data(job_id: Optional[str] = None, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Dynamic preview endpoint returning mapped file columns and sample rows.
|
||||||
|
"""
|
||||||
|
job = None
|
||||||
|
if job_id:
|
||||||
|
job = db.query(MigrationJob).filter(MigrationJob.id == job_id).first()
|
||||||
|
if not job:
|
||||||
|
job = db.query(MigrationJob).order_by(MigrationJob.started_at.desc(), MigrationJob.id.desc()).first()
|
||||||
|
|
||||||
|
if not job:
|
||||||
|
return {"status": "success", "valid": True, "preview_items": []}
|
||||||
|
|
||||||
|
job_dir = StorageManager.get_job_dir(job.id)
|
||||||
|
dataset_dir = os.path.join(job_dir, "dataset")
|
||||||
|
if not os.path.exists(dataset_dir):
|
||||||
|
return {"status": "success", "valid": True, "preview_items": []}
|
||||||
|
|
||||||
|
files = os.listdir(dataset_dir)
|
||||||
|
if not files:
|
||||||
|
return {"status": "success", "valid": True, "preview_items": []}
|
||||||
|
|
||||||
|
filepath = os.path.join(dataset_dir, files[0])
|
||||||
|
fmt = os.path.splitext(filepath)[1].lower().replace(".", "").upper()
|
||||||
|
headers = DataFileParser.get_headers(filepath, fmt)
|
||||||
|
column_maps = ColumnMapper.suggest_mappings(headers)
|
||||||
|
|
||||||
|
preview_items = []
|
||||||
|
for idx, (row_num, row_dict) in enumerate(DataFileParser.stream_rows(filepath, fmt)):
|
||||||
|
if idx >= 10:
|
||||||
|
break
|
||||||
|
mapped = ColumnMapper.apply_mapping(row_dict, column_maps)
|
||||||
|
preview_items.append(mapped)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"job_id": job.id,
|
||||||
|
"valid": True,
|
||||||
|
"headers": headers,
|
||||||
|
"column_maps": column_maps,
|
||||||
|
"preview_items": preview_items
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.get("/media-groups/preview")
|
||||||
|
@router.post("/media-groups/preview")
|
||||||
|
def get_media_groups_preview(db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Returns indexed MediaGroup records with their assets for the Media Library tab.
|
||||||
|
"""
|
||||||
|
groups = db.query(MediaGroup).order_by(MediaGroup.created_at.desc()).all()
|
||||||
|
res = []
|
||||||
|
for g in groups:
|
||||||
|
assets = []
|
||||||
|
for a in g.media_assets:
|
||||||
|
assets.append({
|
||||||
|
"id": a.id,
|
||||||
|
"original_filename": a.original_filename,
|
||||||
|
"cdn_url": a.cdn_url,
|
||||||
|
"thumbnail_url": a.thumbnail_url,
|
||||||
|
"file_size_bytes": a.file_size_bytes,
|
||||||
|
"mime_type": a.mime_type
|
||||||
|
})
|
||||||
|
res.append({
|
||||||
|
"id": g.id,
|
||||||
|
"media_key": g.media_key,
|
||||||
|
"brand_name": g.brand_name,
|
||||||
|
"model_name": g.model_name,
|
||||||
|
"variant_tag": g.variant_tag,
|
||||||
|
"assets_count": len(assets),
|
||||||
|
"media_assets": assets
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"total_count": len(res),
|
||||||
|
"media_groups": res
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.post("/dry-run")
|
||||||
|
@router.get("/dry-run")
|
||||||
|
@router.post("/dry_run")
|
||||||
|
@router.get("/dry_run")
|
||||||
|
def dry_run_migration_data(job_id: Optional[str] = None, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Dynamic dry-run endpoint executing real ValidationEngine checks against uploaded file rows.
|
||||||
|
"""
|
||||||
|
job = None
|
||||||
|
if job_id:
|
||||||
|
job = db.query(MigrationJob).filter(MigrationJob.id == job_id).first()
|
||||||
|
if not job:
|
||||||
|
job = db.query(MigrationJob).order_by(MigrationJob.started_at.desc(), MigrationJob.id.desc()).first()
|
||||||
|
|
||||||
|
if not job:
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"message": "No active migration job found to validate.",
|
||||||
|
"valid": True,
|
||||||
|
"total_rows": 0,
|
||||||
|
"valid_rows": 0,
|
||||||
|
"invalid_rows": 0,
|
||||||
|
"errors": [],
|
||||||
|
"warnings": []
|
||||||
|
}
|
||||||
|
|
||||||
|
job_dir = StorageManager.get_job_dir(job.id)
|
||||||
|
dataset_dir = os.path.join(job_dir, "dataset")
|
||||||
|
if not os.path.exists(dataset_dir) or not os.listdir(dataset_dir):
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"message": f"Job {job.id} initialized. Dataset file pending.",
|
||||||
|
"valid": True,
|
||||||
|
"total_rows": 0,
|
||||||
|
"valid_rows": 0,
|
||||||
|
"invalid_rows": 0,
|
||||||
|
"errors": [],
|
||||||
|
"warnings": []
|
||||||
|
}
|
||||||
|
|
||||||
|
filepath = os.path.join(dataset_dir, os.listdir(dataset_dir)[0])
|
||||||
|
fmt = os.path.splitext(filepath)[1].lower().replace(".", "").upper()
|
||||||
|
headers = DataFileParser.get_headers(filepath, fmt)
|
||||||
|
column_maps = ColumnMapper.suggest_mappings(headers)
|
||||||
|
|
||||||
|
mapped_rows = []
|
||||||
|
for row_num, row_dict in DataFileParser.stream_rows(filepath, fmt):
|
||||||
|
mapped = ColumnMapper.apply_mapping(row_dict, column_maps)
|
||||||
|
mapped_rows.append((row_num, mapped))
|
||||||
|
|
||||||
|
total_rows = len(mapped_rows)
|
||||||
|
errors, warnings = ValidationEngine.validate_batch(mapped_rows, db, import_mode="UPSERT")
|
||||||
|
|
||||||
|
invalid_rows_count = len(set(e["row_number"] for e in errors))
|
||||||
|
valid_rows_count = max(0, total_rows - invalid_rows_count)
|
||||||
|
|
||||||
|
job.total_records = total_rows
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"job_id": job.id,
|
||||||
|
"message": f"Dynamic Dry-Run Validation complete for {total_rows} rows.",
|
||||||
|
"valid": invalid_rows_count == 0,
|
||||||
|
"total_rows": total_rows,
|
||||||
|
"valid_rows": valid_rows_count,
|
||||||
|
"invalid_rows": invalid_rows_count,
|
||||||
|
"errors": errors[:100],
|
||||||
|
"warnings": warnings[:100],
|
||||||
|
"headers": headers,
|
||||||
|
"column_maps": column_maps
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.post("/execute")
|
||||||
|
@router.get("/execute")
|
||||||
|
def execute_migration_batch(job_id: Optional[str] = None, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Dynamic execution endpoint enqueuing job for persistent MigrationWorker processing.
|
||||||
|
"""
|
||||||
|
job = None
|
||||||
|
if job_id:
|
||||||
|
job = db.query(MigrationJob).filter(MigrationJob.id == job_id).first()
|
||||||
|
if not job:
|
||||||
|
job = db.query(MigrationJob).order_by(MigrationJob.started_at.desc(), MigrationJob.id.desc()).first()
|
||||||
|
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(status_code=404, detail="No migration job found to execute.")
|
||||||
|
|
||||||
|
# Only mark as QUEUED if the job is not already RUNNING or COMPLETED
|
||||||
|
if job.status not in (JobStatusEnum.RUNNING, JobStatusEnum.COMPLETED):
|
||||||
|
job.status = JobStatusEnum.QUEUED
|
||||||
|
if job.current_phase == PhaseEnum.UPLOAD:
|
||||||
|
job.current_phase = PhaseEnum.VALIDATE
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"job_id": job.id,
|
||||||
|
"batch_id": job.batch_id,
|
||||||
|
"job_status": job.status,
|
||||||
|
"message": f"Migration Job {job.id} enqueued. Background worker thread will execute pipeline phases."
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload")
|
||||||
|
async def upload_migration_file(
|
||||||
|
file: Optional[UploadFile] = File(None),
|
||||||
|
media_file: Optional[UploadFile] = File(None),
|
||||||
|
batch_type: str = Form("PRODUCTS"),
|
||||||
|
import_mode: str = Form("UPSERT"),
|
||||||
|
media_structure: str = Form("AUTO"),
|
||||||
|
user_id: str = Form("admin-user-01"),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
64KB Streamed Upload Endpoint. Streams dataset spreadsheet and/or ZIP archive directly to disk,
|
||||||
|
creates MigrationBatch & MigrationJob, and returns immediate job_id in QUEUED status.
|
||||||
|
"""
|
||||||
|
if not file and not media_file:
|
||||||
|
raise HTTPException(status_code=400, detail="Please upload a dataset file (.xlsx/.csv) or media ZIP archive (.zip).")
|
||||||
|
|
||||||
|
batch_id = str(uuid.uuid4())
|
||||||
|
job_id = str(uuid.uuid4())
|
||||||
|
job_dir = StorageManager.get_job_dir(job_id)
|
||||||
|
|
||||||
|
# Save job configuration including media_structure (AUTO vs FLAT)
|
||||||
|
StorageManager.save_job_config(job_id, {"media_structure": media_structure, "import_mode": import_mode})
|
||||||
|
|
||||||
|
batch = MigrationBatch(
|
||||||
|
id=batch_id,
|
||||||
|
batch_type=BatchTypeEnum(batch_type) if batch_type in BatchTypeEnum.__members__ else BatchTypeEnum.PRODUCTS,
|
||||||
|
user_id=user_id,
|
||||||
|
import_mode=ImportModeEnum(import_mode) if import_mode in ImportModeEnum.__members__ else ImportModeEnum.UPSERT,
|
||||||
|
status=BatchStatusEnum.PENDING
|
||||||
|
)
|
||||||
|
db.add(batch)
|
||||||
|
|
||||||
|
file_name = ""
|
||||||
|
file_format = "CSV"
|
||||||
|
|
||||||
|
if file:
|
||||||
|
file_name = file.filename
|
||||||
|
ext = os.path.splitext(file.filename)[1].lower().replace(".", "")
|
||||||
|
file_format = ext.upper()
|
||||||
|
dataset_dest = os.path.join(job_dir, "dataset", file.filename)
|
||||||
|
await StorageManager.save_upload_stream_async(file, dataset_dest)
|
||||||
|
|
||||||
|
if media_file:
|
||||||
|
archive_dest = os.path.join(job_dir, "archives", media_file.filename)
|
||||||
|
await StorageManager.save_upload_stream_async(media_file, archive_dest)
|
||||||
|
if not file_name:
|
||||||
|
file_name = media_file.filename
|
||||||
|
file_format = "ZIP"
|
||||||
|
|
||||||
|
job = MigrationJob(
|
||||||
|
id=job_id,
|
||||||
|
batch_id=batch_id,
|
||||||
|
job_type=batch_type,
|
||||||
|
is_dry_run=False,
|
||||||
|
file_name=file_name,
|
||||||
|
file_format=file_format,
|
||||||
|
status=JobStatusEnum.QUEUED,
|
||||||
|
current_phase=PhaseEnum.UPLOAD
|
||||||
|
)
|
||||||
|
db.add(job)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"job_id": job.id,
|
||||||
|
"batch_id": batch.id,
|
||||||
|
"job_status": job.status,
|
||||||
|
"current_phase": job.current_phase,
|
||||||
|
"message": f"File uploaded safely to disk. Migration Job {job.id} queued for background processing."
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.get("/batches")
|
||||||
|
@router.get("/jobs")
|
||||||
|
@router.get("/history")
|
||||||
|
def list_migration_jobs(db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
List all historical migration jobs for the admin dashboard.
|
||||||
|
"""
|
||||||
|
jobs = db.query(MigrationJob).order_by(MigrationJob.started_at.desc(), MigrationJob.id.desc()).all()
|
||||||
|
res = []
|
||||||
|
for j in jobs:
|
||||||
|
dur_secs = 0
|
||||||
|
if j.started_at:
|
||||||
|
end_t = j.completed_at or j.finished_at or datetime.datetime.utcnow()
|
||||||
|
dur_secs = max(0, int((end_t - j.started_at).total_seconds()))
|
||||||
|
|
||||||
|
dur_mins = dur_secs // 60
|
||||||
|
dur_s = dur_secs % 60
|
||||||
|
duration_fmt = f"{dur_mins}m {dur_s}s" if dur_mins > 0 else f"{dur_s}s"
|
||||||
|
|
||||||
|
res.append({
|
||||||
|
"id": j.id,
|
||||||
|
"job_id": j.id,
|
||||||
|
"batch_id": j.batch_id,
|
||||||
|
"file_name": j.file_name,
|
||||||
|
"file_format": j.file_format,
|
||||||
|
"status": j.status,
|
||||||
|
"current_phase": j.current_phase,
|
||||||
|
"current_batch": j.current_batch,
|
||||||
|
"total_batches": j.total_batches,
|
||||||
|
"processed_records": j.processed_records,
|
||||||
|
"total_records": j.total_records,
|
||||||
|
"successful_records": j.successful_records,
|
||||||
|
"failed_records": j.failed_records,
|
||||||
|
"worker_id": j.worker_id,
|
||||||
|
"heartbeat_at": j.heartbeat_at.isoformat() if j.heartbeat_at else None,
|
||||||
|
"started_at": j.started_at.strftime("%Y-%m-%d %H:%M:%S") if j.started_at else "Pending",
|
||||||
|
"completed_at": j.completed_at.strftime("%Y-%m-%d %H:%M:%S") if j.completed_at else None,
|
||||||
|
"duration_seconds": dur_secs,
|
||||||
|
"duration_formatted": duration_fmt,
|
||||||
|
"error_message": j.error_message
|
||||||
|
})
|
||||||
|
return {"status": "success", "batches": res, "jobs": res}
|
||||||
|
|
||||||
|
class BulkDeleteMediaRequest(BaseModel):
|
||||||
|
ids: list[str]
|
||||||
|
|
||||||
|
@router.delete("/media-groups/{group_id}")
|
||||||
|
def delete_single_media_group(group_id: str, db: Session = Depends(get_db)):
|
||||||
|
db.query(MigrationMediaItem).filter(MigrationMediaItem.id == group_id).delete()
|
||||||
|
db.commit()
|
||||||
|
return {"status": "success", "message": f"Media item {group_id} deleted."}
|
||||||
|
|
||||||
|
@router.post("/media-groups/bulk-delete")
|
||||||
|
def bulk_delete_media_groups(payload: BulkDeleteMediaRequest, db: Session = Depends(get_db)):
|
||||||
|
if payload.ids:
|
||||||
|
db.query(MigrationMediaItem).filter(MigrationMediaItem.id.in_(payload.ids)).delete(synchronize_session=False)
|
||||||
|
db.commit()
|
||||||
|
return {"status": "success", "message": f"Deleted {len(payload.ids)} media items."}
|
||||||
|
|
||||||
|
@router.post("/purge-all")
|
||||||
|
@router.post("/purge_all")
|
||||||
|
def purge_all_migration_data(db: Session = Depends(get_db)):
|
||||||
|
from sqlalchemy import text
|
||||||
|
try:
|
||||||
|
db.execute(text("DELETE FROM migration_snapshots"))
|
||||||
|
db.execute(text("DELETE FROM migration_errors"))
|
||||||
|
db.execute(text("DELETE FROM migration_job_checkpoints"))
|
||||||
|
db.execute(text("DELETE FROM migration_media_items"))
|
||||||
|
db.execute(text("DELETE FROM migration_jobs"))
|
||||||
|
db.execute(text("DELETE FROM migration_batches"))
|
||||||
|
db.commit()
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
print(f"Purge warning: {e}")
|
||||||
|
return {"status": "success", "message": "All migration history and media groups purged."}
|
||||||
|
|
||||||
|
@router.get("/jobs/{job_id}/status")
|
||||||
|
def get_job_telemetry(job_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Fetches real-time telemetry metrics for a migration job.
|
||||||
|
"""
|
||||||
|
job = db.query(MigrationJob).filter(MigrationJob.id == job_id).first()
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(status_code=404, detail="Migration job not found")
|
||||||
|
|
||||||
|
checkpoints = db.query(MigrationJobCheckpoint).filter(MigrationJobCheckpoint.job_id == job_id).all()
|
||||||
|
phase_checkpoints = {}
|
||||||
|
for c in checkpoints:
|
||||||
|
phase_checkpoints[c.phase] = {
|
||||||
|
"last_successful_batch": c.last_successful_batch,
|
||||||
|
"total_batches": c.total_batches,
|
||||||
|
"processed_records": c.processed_records,
|
||||||
|
"failed_records": c.failed_records
|
||||||
|
}
|
||||||
|
|
||||||
|
pct = 0.0
|
||||||
|
if job.total_records > 0:
|
||||||
|
pct = round((job.processed_records / job.total_records) * 100.0, 2)
|
||||||
|
|
||||||
|
elapsed_seconds = 0
|
||||||
|
if job.started_at:
|
||||||
|
end_time = job.completed_at or job.finished_at or datetime.datetime.utcnow()
|
||||||
|
elapsed_seconds = max(0, int((end_time - job.started_at).total_seconds()))
|
||||||
|
|
||||||
|
processing_rate = 0.0
|
||||||
|
estimated_seconds_remaining = None
|
||||||
|
if job.status == JobStatusEnum.QUEUED:
|
||||||
|
eta_formatted = "Queued in Line"
|
||||||
|
elif job.status == JobStatusEnum.RUNNING and job.processed_records == 0:
|
||||||
|
eta_formatted = "Starting Batch Processing..."
|
||||||
|
elif job.status == JobStatusEnum.COMPLETED:
|
||||||
|
eta_formatted = "0s (Completed)"
|
||||||
|
else:
|
||||||
|
eta_formatted = "Calculating..."
|
||||||
|
|
||||||
|
if elapsed_seconds > 0 and job.processed_records > 0:
|
||||||
|
processing_rate = round(job.processed_records / elapsed_seconds, 2)
|
||||||
|
remaining_records = max(0, job.total_records - job.processed_records)
|
||||||
|
if remaining_records > 0 and processing_rate > 0:
|
||||||
|
estimated_seconds_remaining = int(remaining_records / processing_rate)
|
||||||
|
mins = estimated_seconds_remaining // 60
|
||||||
|
secs = estimated_seconds_remaining % 60
|
||||||
|
eta_formatted = f"{mins}m {secs}s" if mins > 0 else f"{secs}s"
|
||||||
|
elif remaining_records == 0 and job.status == JobStatusEnum.COMPLETED:
|
||||||
|
eta_formatted = "0s (Completed)"
|
||||||
|
|
||||||
|
elapsed_mins = elapsed_seconds // 60
|
||||||
|
elapsed_secs = elapsed_seconds % 60
|
||||||
|
elapsed_formatted = f"{elapsed_mins}m {elapsed_secs}s" if elapsed_mins > 0 else f"{elapsed_secs}s"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"job_id": job.id,
|
||||||
|
"batch_id": job.batch_id,
|
||||||
|
"job_status": job.status,
|
||||||
|
"current_phase": job.current_phase,
|
||||||
|
"worker_id": job.worker_id,
|
||||||
|
"heartbeat_at": job.heartbeat_at.isoformat() if job.heartbeat_at else None,
|
||||||
|
"lease_version": job.lease_version,
|
||||||
|
"current_batch": job.current_batch,
|
||||||
|
"total_batches": job.total_batches,
|
||||||
|
"last_successful_batch": job.last_successful_batch,
|
||||||
|
"processed_records": job.processed_records,
|
||||||
|
"total_records": job.total_records,
|
||||||
|
"successful_records": job.successful_records,
|
||||||
|
"failed_records": job.failed_records,
|
||||||
|
"expected_products": job.expected_products,
|
||||||
|
"expected_variants": job.expected_variants,
|
||||||
|
"expected_media_items": job.expected_media_items,
|
||||||
|
"progress_percentage": pct,
|
||||||
|
"elapsed_seconds": elapsed_seconds,
|
||||||
|
"elapsed_formatted": elapsed_formatted,
|
||||||
|
"processing_rate": processing_rate,
|
||||||
|
"estimated_seconds_remaining": estimated_seconds_remaining,
|
||||||
|
"eta_formatted": eta_formatted,
|
||||||
|
"phase_checkpoints": phase_checkpoints,
|
||||||
|
"error_message": job.error_message
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.get("/jobs/{job_id}/stream")
|
||||||
|
def stream_job_telemetry(job_id: str):
|
||||||
|
"""
|
||||||
|
Streams real-time Server-Sent Events (SSE) telemetry data for a migration job.
|
||||||
|
"""
|
||||||
|
def event_generator():
|
||||||
|
import json, time
|
||||||
|
max_duration = 3600 # Max 1 hour stream safeguard
|
||||||
|
start_stream = time.time()
|
||||||
|
|
||||||
|
while time.time() - start_stream < max_duration:
|
||||||
|
local_db = SessionLocal()
|
||||||
|
try:
|
||||||
|
telemetry = get_job_telemetry(job_id=job_id, db=local_db)
|
||||||
|
data_str = json.dumps(telemetry, default=str)
|
||||||
|
yield f"data: {data_str}\n\n"
|
||||||
|
|
||||||
|
job_status = telemetry.get("job_status")
|
||||||
|
if job_status in [JobStatusEnum.COMPLETED, JobStatusEnum.FAILED, JobStatusEnum.CANCELLED, "COMPLETED", "FAILED", "CANCELLED"]:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
err_payload = json.dumps({"status": "error", "error_message": str(e)})
|
||||||
|
yield f"data: {err_payload}\n\n"
|
||||||
|
break
|
||||||
|
finally:
|
||||||
|
local_db.close()
|
||||||
|
|
||||||
|
time.sleep(1.0)
|
||||||
|
|
||||||
|
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
||||||
|
|
||||||
|
@router.post("/jobs/{job_id}/cancel")
|
||||||
|
def cancel_job(job_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Flags job status as CANCELLING. Worker finishes active batch transaction before halting cleanly.
|
||||||
|
"""
|
||||||
|
job = db.query(MigrationJob).filter(MigrationJob.id == job_id).first()
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(status_code=404, detail="Migration job not found")
|
||||||
|
|
||||||
|
if job.status in (JobStatusEnum.COMPLETED, JobStatusEnum.CANCELLED, JobStatusEnum.FAILED):
|
||||||
|
return {"status": "info", "message": f"Job is already in terminal status {job.status}"}
|
||||||
|
|
||||||
|
job.status = JobStatusEnum.CANCELLING
|
||||||
|
job.cancel_requested_at = datetime.datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"status": "success", "message": f"Cancellation requested for job {job_id}. Worker will halt cleanly after current batch finishes."}
|
||||||
|
|
||||||
|
@router.post("/jobs/{job_id}/resume")
|
||||||
|
def resume_job(job_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Resumes an interrupted, cancelled, or failed job from its last successful phase checkpoint.
|
||||||
|
"""
|
||||||
|
job = db.query(MigrationJob).filter(MigrationJob.id == job_id).first()
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(status_code=404, detail="Migration job not found")
|
||||||
|
|
||||||
|
if job.status == JobStatusEnum.RUNNING:
|
||||||
|
return {"status": "info", "message": "Job is already running."}
|
||||||
|
|
||||||
|
job.status = JobStatusEnum.QUEUED
|
||||||
|
job.cancel_requested_at = None
|
||||||
|
job.error_message = None
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"status": "success", "message": f"Job {job_id} re-queued and will resume from phase {job.current_phase} batch {job.last_successful_batch}."}
|
||||||
|
|
||||||
|
@router.get("/jobs/{job_id}/errors/export")
|
||||||
|
def export_job_errors(job_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Exports CSV failure report: Row Number, SKU, Product Name, File Name, Phase, Error Type, Error Message, Retry Status.
|
||||||
|
"""
|
||||||
|
job = db.query(MigrationJob).filter(MigrationJob.id == job_id).first()
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(status_code=404, detail="Migration job not found")
|
||||||
|
|
||||||
|
errors = db.query(MigrationError).filter(MigrationError.job_id == job_id).order_by(MigrationError.row_number.asc()).all()
|
||||||
|
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.writer(output)
|
||||||
|
writer.writerow([
|
||||||
|
"Row Number", "SKU", "Product Name", "File Name", "Entity Type",
|
||||||
|
"Phase", "Error Type", "Severity", "Error Message", "Suggested Fix", "Retry Status"
|
||||||
|
])
|
||||||
|
|
||||||
|
for e in errors:
|
||||||
|
writer.writerow([
|
||||||
|
e.row_number, e.sku or "", e.product_name or "", e.file_name or "", e.entity_type or "",
|
||||||
|
e.phase, e.error_type, e.severity, e.error_message, e.suggested_fix or "", e.retry_status
|
||||||
|
])
|
||||||
|
|
||||||
|
output.seek(0)
|
||||||
|
return StreamingResponse(
|
||||||
|
io.BytesIO(output.getvalue().encode("utf-8")),
|
||||||
|
media_type="text/csv",
|
||||||
|
headers={"Content-Disposition": f"attachment; filename=Migration_Errors_Job_{job_id[:8]}.csv"}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/jobs/{job_id}/media")
|
||||||
|
def list_job_media(
|
||||||
|
job_id: str,
|
||||||
|
page: int = 1,
|
||||||
|
limit: int = 50,
|
||||||
|
search: Optional[str] = None,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Server-side paginated media gallery for admin console to view 30,000+ indexed media assets without DOM lag.
|
||||||
|
"""
|
||||||
|
query = db.query(MigrationMediaItem).filter(MigrationMediaItem.job_id == job_id)
|
||||||
|
if search:
|
||||||
|
query = query.filter(
|
||||||
|
(MigrationMediaItem.file_name.ilike(f"%{search}%")) |
|
||||||
|
(MigrationMediaItem.media_key.ilike(f"%{search}%")) |
|
||||||
|
(MigrationMediaItem.sha256.ilike(f"%{search}%"))
|
||||||
|
)
|
||||||
|
|
||||||
|
total_count = query.count()
|
||||||
|
items = query.offset((page - 1) * limit).limit(limit).all()
|
||||||
|
|
||||||
|
res = []
|
||||||
|
for item in items:
|
||||||
|
res.append({
|
||||||
|
"id": item.id,
|
||||||
|
"batch_number": item.batch_number,
|
||||||
|
"file_name": item.file_name,
|
||||||
|
"archive_name": item.archive_name,
|
||||||
|
"zip_entry_path": item.zip_entry_path,
|
||||||
|
"media_key": item.media_key,
|
||||||
|
"sha256": item.sha256,
|
||||||
|
"storage_path": item.storage_path,
|
||||||
|
"cdn_url": f"/uploads/migrations/{job_id}/media/{item.sha256[:2]}/{item.sha256}.jpg" if item.sha256 else None,
|
||||||
|
"status": item.status,
|
||||||
|
"error": item.error
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"total_count": total_count,
|
||||||
|
"page": page,
|
||||||
|
"limit": limit,
|
||||||
|
"total_pages": (total_count // limit) + (1 if total_count % limit > 0 else 0),
|
||||||
|
"items": res
|
||||||
|
}
|
||||||
188
app/api/v1/routers/OrderRouter.py
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
"""
|
||||||
|
@router OrderRouter (Backend/app/api/v1/routers/OrderRouter.py)
|
||||||
|
@purpose Database-driven order history and detail tracking for storefront customers, protected by JWT authentication.
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import List, Optional
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from datetime import datetime
|
||||||
|
import ulid
|
||||||
|
|
||||||
|
from app.core.database.db_session import get_db
|
||||||
|
from app.api.v1.routers.CustomerProfileRouter import get_current_customer
|
||||||
|
from app.models.EcomCustomerModel import EcomCustomer
|
||||||
|
from app.models.OrderModel import Order, OrderItem, OrderStatusHistory
|
||||||
|
from app.services.InventoryService import record_ledger_entry
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/orders", tags=["Order Management"])
|
||||||
|
|
||||||
|
# --- Schemas ---
|
||||||
|
|
||||||
|
class OrderItemSchema(BaseModel):
|
||||||
|
product_name: str
|
||||||
|
sku: str
|
||||||
|
quantity: int
|
||||||
|
unit_price: float
|
||||||
|
total_price: float
|
||||||
|
|
||||||
|
class OrderDetailResponse(BaseModel):
|
||||||
|
order_id: str
|
||||||
|
order_no: str
|
||||||
|
total_amount: float
|
||||||
|
discount_amount: float
|
||||||
|
tax_amount: float
|
||||||
|
shipping_cost: float
|
||||||
|
final_amount: float
|
||||||
|
status: str
|
||||||
|
payment_status: str
|
||||||
|
fulfillment_status: str
|
||||||
|
shipping_address_json: Optional[str] = None
|
||||||
|
billing_address_json: Optional[str] = None
|
||||||
|
tracking_number: Optional[str] = None
|
||||||
|
courier_name: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
items: List[OrderItemSchema]
|
||||||
|
|
||||||
|
class OrderListResponse(BaseModel):
|
||||||
|
order_id: str
|
||||||
|
order_no: str
|
||||||
|
final_amount: float
|
||||||
|
status: str
|
||||||
|
payment_status: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
# --- Endpoints ---
|
||||||
|
|
||||||
|
@router.get("", response_model=List[OrderListResponse])
|
||||||
|
def list_customer_orders(
|
||||||
|
customer: EcomCustomer = Depends(get_current_customer),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
List order history for the logged-in customer.
|
||||||
|
"""
|
||||||
|
orders = (
|
||||||
|
db.query(Order)
|
||||||
|
.filter(Order.customer_id == customer.customer_id)
|
||||||
|
.order_by(Order.created_at.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
OrderListResponse(
|
||||||
|
order_id=o.order_id,
|
||||||
|
order_no=o.order_no,
|
||||||
|
final_amount=float(o.final_amount),
|
||||||
|
status=o.status,
|
||||||
|
payment_status=o.payment_status,
|
||||||
|
created_at=o.created_at
|
||||||
|
) for o in orders
|
||||||
|
]
|
||||||
|
|
||||||
|
@router.get("/{order_id}", response_model=OrderDetailResponse)
|
||||||
|
def get_order_details(
|
||||||
|
order_id: str,
|
||||||
|
customer: EcomCustomer = Depends(get_current_customer),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Retrieve full details for a specific order. Enforces customer ownership.
|
||||||
|
"""
|
||||||
|
order = (
|
||||||
|
db.query(Order)
|
||||||
|
.filter(Order.order_id == order_id, Order.customer_id == customer.customer_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not order:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Order not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
return OrderDetailResponse(
|
||||||
|
order_id=order.order_id,
|
||||||
|
order_no=order.order_no,
|
||||||
|
total_amount=float(order.total_amount),
|
||||||
|
discount_amount=float(order.discount_amount),
|
||||||
|
tax_amount=float(order.tax_amount),
|
||||||
|
shipping_cost=float(order.shipping_cost),
|
||||||
|
final_amount=float(order.final_amount),
|
||||||
|
status=order.status,
|
||||||
|
payment_status=order.payment_status,
|
||||||
|
fulfillment_status=order.fulfillment_status,
|
||||||
|
shipping_address_json=order.shipping_address_json,
|
||||||
|
billing_address_json=order.billing_address_json,
|
||||||
|
tracking_number=order.tracking_number,
|
||||||
|
courier_name=order.courier_name,
|
||||||
|
created_at=order.created_at,
|
||||||
|
items=[
|
||||||
|
OrderItemSchema(
|
||||||
|
product_name=item.product_name,
|
||||||
|
sku=item.sku,
|
||||||
|
quantity=item.quantity,
|
||||||
|
unit_price=float(item.unit_price),
|
||||||
|
total_price=float(item.total_price)
|
||||||
|
) for item in order.items
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/{order_id}/cancel")
|
||||||
|
def cancel_order(
|
||||||
|
order_id: str,
|
||||||
|
customer: EcomCustomer = Depends(get_current_customer),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Cancel an order if it is in an editable state (ORDER_CREATED or PAYMENT_PENDING).
|
||||||
|
Releases ONLINE_RESERVE inventory reservations.
|
||||||
|
"""
|
||||||
|
order = (
|
||||||
|
db.query(Order)
|
||||||
|
.filter(Order.order_id == order_id, Order.customer_id == customer.customer_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not order:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Order not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if order.status not in ["ORDER_CREATED", "PAYMENT_PENDING"]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Order cannot be cancelled. Current status is {order.status}."
|
||||||
|
)
|
||||||
|
|
||||||
|
previous_status = order.status
|
||||||
|
|
||||||
|
for item in order.items:
|
||||||
|
record_ledger_entry(
|
||||||
|
variant_id=item.variant_id,
|
||||||
|
event_type="ONLINE_RESERVE_RELEASE",
|
||||||
|
qty=item.quantity,
|
||||||
|
reference_id=order.order_id,
|
||||||
|
db=db,
|
||||||
|
notes=f"Released reservation for customer-cancelled order {order.order_no}",
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
history = OrderStatusHistory(
|
||||||
|
history_id=str(ulid.ULID()),
|
||||||
|
order_id=order.order_id,
|
||||||
|
previous_status=previous_status,
|
||||||
|
new_status="CANCELLED",
|
||||||
|
changed_by=customer.email,
|
||||||
|
reason="Cancelled by customer from profile portal"
|
||||||
|
)
|
||||||
|
db.add(history)
|
||||||
|
|
||||||
|
order.status = "CANCELLED"
|
||||||
|
order.fulfillment_status = "CANCELLED"
|
||||||
|
if order.payment_status == "PAYMENT_CAPTURED":
|
||||||
|
order.payment_status = "REFUNDED"
|
||||||
|
else:
|
||||||
|
order.payment_status = "PAYMENT_FAILED"
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"message": "Order cancelled successfully", "order_id": order_id}
|
||||||
154
app/api/v1/routers/PaymentRouter.py
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
"""
|
||||||
|
@router PaymentRouter (Backend/app/api/v1/routers/PaymentRouter.py)
|
||||||
|
@purpose API router handling Razorpay order initiation, cryptographic verification, status checks, and webhook ingestion.
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, Header, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.core.database.db_session import get_db
|
||||||
|
from app.api.v1.routers.CustomerProfileRouter import get_current_customer
|
||||||
|
from app.models.EcomCustomerModel import EcomCustomer
|
||||||
|
from app.models.PaymentLedgerModel import PaymentLedger
|
||||||
|
from app.core.payment_orchestrator import payment_orchestrator
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/payment", tags=["Razorpay Payment Engine"])
|
||||||
|
|
||||||
|
# --- Request / Response Models ---
|
||||||
|
|
||||||
|
class PaymentInitiateRequest(BaseModel):
|
||||||
|
order_id: str
|
||||||
|
|
||||||
|
class PaymentInitiateResponse(BaseModel):
|
||||||
|
order_id: str
|
||||||
|
rzp_order_id: str
|
||||||
|
rzp_key_id: str
|
||||||
|
amount: int
|
||||||
|
currency: str
|
||||||
|
security_token: str
|
||||||
|
zero_amount: Optional[bool] = False
|
||||||
|
status: Optional[str] = None
|
||||||
|
invoice_id: Optional[str] = None
|
||||||
|
invoice_no: Optional[str] = None
|
||||||
|
|
||||||
|
class PaymentCancelRequest(BaseModel):
|
||||||
|
order_id: str
|
||||||
|
reason: Optional[str] = "Payment cancelled by user"
|
||||||
|
|
||||||
|
class PaymentVerifyRequest(BaseModel):
|
||||||
|
order_id: str
|
||||||
|
razorpay_order_id: str
|
||||||
|
razorpay_payment_id: str
|
||||||
|
razorpay_signature: str
|
||||||
|
|
||||||
|
@router.post("/initiate", response_model=PaymentInitiateResponse)
|
||||||
|
def initiate_payment(
|
||||||
|
payload: PaymentInitiateRequest,
|
||||||
|
request: Request,
|
||||||
|
customer: EcomCustomer = Depends(get_current_customer),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Step 1: Initiate payment intent for an e-commerce order (creates Razorpay Order).
|
||||||
|
"""
|
||||||
|
client_ip = request.client.host if request.client else None
|
||||||
|
user_agent = request.headers.get("user-agent")
|
||||||
|
|
||||||
|
result = payment_orchestrator.initiate_payment(
|
||||||
|
db, payload.order_id, client_ip=client_ip, user_agent=user_agent
|
||||||
|
)
|
||||||
|
|
||||||
|
if "error" in result:
|
||||||
|
raise HTTPException(status_code=result["status_code"], detail=result["error"])
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
@router.post("/verify")
|
||||||
|
def verify_payment(
|
||||||
|
payload: PaymentVerifyRequest,
|
||||||
|
customer: EcomCustomer = Depends(get_current_customer),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Step 2: Cryptographic signature verification + secondary server-side provider fetch.
|
||||||
|
"""
|
||||||
|
result = payment_orchestrator.verify_payment(
|
||||||
|
db,
|
||||||
|
order_id=payload.order_id,
|
||||||
|
rzp_order_id=payload.razorpay_order_id,
|
||||||
|
rzp_payment_id=payload.razorpay_payment_id,
|
||||||
|
rzp_signature=payload.razorpay_signature
|
||||||
|
)
|
||||||
|
|
||||||
|
if "error" in result:
|
||||||
|
raise HTTPException(status_code=result["status_code"], detail=result["error"])
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
@router.post("/cancel")
|
||||||
|
def cancel_payment(
|
||||||
|
payload: PaymentCancelRequest,
|
||||||
|
customer: EcomCustomer = Depends(get_current_customer),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Cancel pending payment intent and release reserved stock.
|
||||||
|
"""
|
||||||
|
result = payment_orchestrator.cancel_payment(
|
||||||
|
db, order_id=payload.order_id, reason=payload.reason or "Payment cancelled by user"
|
||||||
|
)
|
||||||
|
|
||||||
|
if "error" in result:
|
||||||
|
raise HTTPException(status_code=result["status_code"], detail=result["error"])
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
@router.get("/status/{order_id}")
|
||||||
|
def get_payment_status(
|
||||||
|
order_id: str,
|
||||||
|
customer: EcomCustomer = Depends(get_current_customer),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Returns latest payment ledger status for an order.
|
||||||
|
"""
|
||||||
|
entry = (
|
||||||
|
db.query(PaymentLedger)
|
||||||
|
.filter(PaymentLedger.order_id == order_id)
|
||||||
|
.order_by(PaymentLedger.created_at.desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not entry:
|
||||||
|
raise HTTPException(status_code=404, detail="No payment session found for this order")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"order_id": order_id,
|
||||||
|
"payment_id": entry.payment_id,
|
||||||
|
"provider": entry.provider,
|
||||||
|
"status": entry.status,
|
||||||
|
"transaction_ref": entry.transaction_ref,
|
||||||
|
"amount": float(entry.amount),
|
||||||
|
"currency": entry.currency,
|
||||||
|
"created_at": entry.created_at
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.post("/webhook")
|
||||||
|
async def razorpay_webhook(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Asynchronous Webhook receiver with HMAC verification and idempotency check.
|
||||||
|
"""
|
||||||
|
raw_body = await request.body()
|
||||||
|
signature = request.headers.get("X-Razorpay-Signature") or request.headers.get("x-razorpay-signature")
|
||||||
|
|
||||||
|
if not signature:
|
||||||
|
raise HTTPException(status_code=400, detail="Missing X-Razorpay-Signature header")
|
||||||
|
|
||||||
|
success = payment_orchestrator.process_webhook(db, raw_body, signature)
|
||||||
|
if not success:
|
||||||
|
return {"status": "ignored", "reason": "invalid_signature_or_payload"}
|
||||||
|
|
||||||
|
return {"status": "SUCCESS"}
|
||||||
281
app/api/v1/routers/PosSyncRouter.py
Normal file
|
|
@ -0,0 +1,281 @@
|
||||||
|
"""
|
||||||
|
@router PosSyncRouter (Backend/app/api/v1/routers/PosSyncRouter.py)
|
||||||
|
Fully Dynamic Database-Driven POS Synchronization Gateway (Zero Hardcoded Data)
|
||||||
|
"""
|
||||||
|
import ulid
|
||||||
|
from datetime import datetime, date
|
||||||
|
from typing import List, Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select, func, desc
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.core.database.db_session import get_db
|
||||||
|
from app.models.POSTerminalModel import POSTerminal, POSTransactionLog
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/pos", tags=["Offline POS Synchronization"])
|
||||||
|
|
||||||
|
|
||||||
|
class POSTransactionItem(BaseModel):
|
||||||
|
variant_id: str
|
||||||
|
qty: int
|
||||||
|
unit_price: float
|
||||||
|
|
||||||
|
|
||||||
|
class POSTransaction(BaseModel):
|
||||||
|
pos_transaction_id: str
|
||||||
|
local_sequence: int
|
||||||
|
invoice_no: str
|
||||||
|
total_amount: float
|
||||||
|
created_at: str
|
||||||
|
items: List[POSTransactionItem]
|
||||||
|
|
||||||
|
|
||||||
|
class POSSyncPayload(BaseModel):
|
||||||
|
device_id: str
|
||||||
|
terminal_id: str
|
||||||
|
store_id: str
|
||||||
|
transactions: List[POSTransaction]
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterTerminalRequest(BaseModel):
|
||||||
|
terminal_id: str
|
||||||
|
store_name: str
|
||||||
|
ip_address: Optional[str] = "127.0.0.1"
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/terminals/register")
|
||||||
|
def register_pos_terminal(payload: RegisterTerminalRequest, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Dynamically registers or updates a POS terminal.
|
||||||
|
"""
|
||||||
|
terminal = db.execute(
|
||||||
|
select(POSTerminal).where(POSTerminal.terminal_id == payload.terminal_id)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
if not terminal:
|
||||||
|
terminal = POSTerminal(
|
||||||
|
terminal_id=payload.terminal_id,
|
||||||
|
store_name=payload.store_name,
|
||||||
|
status="ONLINE",
|
||||||
|
ip_address=payload.ip_address,
|
||||||
|
last_heartbeat=datetime.utcnow(),
|
||||||
|
synced_today=0,
|
||||||
|
pending_queue=0,
|
||||||
|
)
|
||||||
|
db.add(terminal)
|
||||||
|
else:
|
||||||
|
terminal.store_name = payload.store_name
|
||||||
|
terminal.ip_address = payload.ip_address
|
||||||
|
terminal.status = "ONLINE"
|
||||||
|
terminal.last_heartbeat = datetime.utcnow()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(terminal)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "SUCCESS",
|
||||||
|
"message": f"Terminal '{terminal.terminal_id}' registered successfully.",
|
||||||
|
"terminal_id": terminal.terminal_id,
|
||||||
|
"store_name": terminal.store_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sync")
|
||||||
|
def sync_pos_transactions(payload: POSSyncPayload, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Idempotent database-persisted synchronization gateway for offline POS transactions.
|
||||||
|
Dynamically registers unknown terminals upon sync submission.
|
||||||
|
"""
|
||||||
|
terminal = db.execute(
|
||||||
|
select(POSTerminal).where(POSTerminal.terminal_id == payload.terminal_id)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
if not terminal:
|
||||||
|
terminal = POSTerminal(
|
||||||
|
terminal_id=payload.terminal_id,
|
||||||
|
store_name=payload.store_id or f"Store ({payload.terminal_id})",
|
||||||
|
status="ONLINE",
|
||||||
|
last_heartbeat=datetime.utcnow(),
|
||||||
|
synced_today=0,
|
||||||
|
pending_queue=0,
|
||||||
|
)
|
||||||
|
db.add(terminal)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(terminal)
|
||||||
|
|
||||||
|
processed = []
|
||||||
|
for tx in payload.transactions:
|
||||||
|
# Check idempotency (prevent duplicate syncs)
|
||||||
|
existing = db.execute(
|
||||||
|
select(POSTransactionLog).where(
|
||||||
|
POSTransactionLog.pos_transaction_id == tx.pos_transaction_id
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
processed.append({
|
||||||
|
"pos_transaction_id": tx.pos_transaction_id,
|
||||||
|
"status": "ALREADY_SYNCED",
|
||||||
|
"invoice_no": tx.invoice_no,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
log_entry = POSTransactionLog(
|
||||||
|
sync_id=f"POS-LOG-{str(ulid.ULID())[:8]}",
|
||||||
|
terminal_id=terminal.terminal_id,
|
||||||
|
invoice_no=tx.invoice_no,
|
||||||
|
pos_transaction_id=tx.pos_transaction_id,
|
||||||
|
items_count=len(tx.items),
|
||||||
|
total_amount=tx.total_amount,
|
||||||
|
status="SYNCED",
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(log_entry)
|
||||||
|
|
||||||
|
terminal.synced_today = (terminal.synced_today or 0) + 1
|
||||||
|
terminal.last_heartbeat = datetime.utcnow()
|
||||||
|
terminal.status = "ONLINE"
|
||||||
|
|
||||||
|
processed.append({
|
||||||
|
"pos_transaction_id": tx.pos_transaction_id,
|
||||||
|
"status": "SYNCED",
|
||||||
|
"invoice_no": tx.invoice_no,
|
||||||
|
})
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "SUCCESS",
|
||||||
|
"synced_count": len(processed),
|
||||||
|
"terminal_id": payload.terminal_id,
|
||||||
|
"results": processed,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
def get_pos_sync_status(db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Returns live health telemetry for offline POS sync gateways and store terminals strictly from MySQL DB.
|
||||||
|
"""
|
||||||
|
terminals = db.execute(select(POSTerminal)).scalars().all()
|
||||||
|
|
||||||
|
today_start = datetime.combine(date.today(), datetime.min.time())
|
||||||
|
|
||||||
|
revenue_today = db.execute(
|
||||||
|
select(func.sum(POSTransactionLog.total_amount)).where(
|
||||||
|
POSTransactionLog.created_at >= today_start,
|
||||||
|
POSTransactionLog.status == "SYNCED",
|
||||||
|
)
|
||||||
|
).scalar() or 0.0
|
||||||
|
|
||||||
|
synced_today_total = db.execute(
|
||||||
|
select(func.count(POSTransactionLog.sync_id)).where(
|
||||||
|
POSTransactionLog.created_at >= today_start,
|
||||||
|
POSTransactionLog.status == "SYNCED",
|
||||||
|
)
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
pending_retries = db.execute(
|
||||||
|
select(func.count(POSTransactionLog.sync_id)).where(
|
||||||
|
POSTransactionLog.status == "PENDING_RETRY"
|
||||||
|
)
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
terminals_list = []
|
||||||
|
now = datetime.utcnow()
|
||||||
|
for t in terminals:
|
||||||
|
time_diff = (now - t.last_heartbeat).total_seconds() if t.last_heartbeat else 99999
|
||||||
|
if time_diff < 60:
|
||||||
|
heartbeat_str = f"{int(time_diff)} seconds ago"
|
||||||
|
elif time_diff < 3600:
|
||||||
|
heartbeat_str = f"{int(time_diff // 60)} minutes ago"
|
||||||
|
else:
|
||||||
|
heartbeat_str = f"{int(time_diff // 3600)} hours ago"
|
||||||
|
|
||||||
|
terminals_list.append({
|
||||||
|
"terminal_id": t.terminal_id,
|
||||||
|
"store_name": t.store_name,
|
||||||
|
"status": t.status,
|
||||||
|
"ip_address": t.ip_address or "127.0.0.1",
|
||||||
|
"last_heartbeat": heartbeat_str,
|
||||||
|
"synced_today": t.synced_today,
|
||||||
|
"pending_queue": t.pending_queue,
|
||||||
|
})
|
||||||
|
|
||||||
|
last_log = db.execute(
|
||||||
|
select(POSTransactionLog).order_by(desc(POSTransactionLog.created_at)).limit(1)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
last_sync = last_log.created_at.isoformat() if last_log else None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"gateway_status": "ONLINE",
|
||||||
|
"active_terminals_count": len(terminals),
|
||||||
|
"synced_today_count": synced_today_total,
|
||||||
|
"pending_retries_count": pending_retries,
|
||||||
|
"offline_revenue_today": round(revenue_today, 2),
|
||||||
|
"last_sync_timestamp": last_sync,
|
||||||
|
"terminals": terminals_list,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/logs")
|
||||||
|
def get_pos_sync_logs(
|
||||||
|
terminal_id: Optional[str] = None,
|
||||||
|
status: Optional[str] = None,
|
||||||
|
limit: int = 50,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Returns real database audit logs for offline POS sync transaction batches.
|
||||||
|
"""
|
||||||
|
stmt = select(POSTransactionLog).order_by(desc(POSTransactionLog.created_at))
|
||||||
|
if terminal_id:
|
||||||
|
stmt = stmt.where(POSTransactionLog.terminal_id == terminal_id)
|
||||||
|
if status:
|
||||||
|
stmt = stmt.where(POSTransactionLog.status == status)
|
||||||
|
|
||||||
|
logs = db.execute(stmt.limit(limit)).scalars().all()
|
||||||
|
|
||||||
|
logs_list = [
|
||||||
|
{
|
||||||
|
"sync_id": log.sync_id,
|
||||||
|
"terminal_id": log.terminal_id,
|
||||||
|
"invoice_no": log.invoice_no,
|
||||||
|
"pos_transaction_id": log.pos_transaction_id,
|
||||||
|
"items_count": log.items_count,
|
||||||
|
"total_amount": log.total_amount,
|
||||||
|
"status": log.status,
|
||||||
|
"created_at": log.created_at.isoformat(),
|
||||||
|
"error_detail": log.error_detail,
|
||||||
|
}
|
||||||
|
for log in logs
|
||||||
|
]
|
||||||
|
|
||||||
|
total_count = db.execute(select(func.count(POSTransactionLog.sync_id))).scalar() or 0
|
||||||
|
|
||||||
|
return {"total": total_count, "logs": logs_list}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/terminals/{terminal_id}/reset")
|
||||||
|
def reset_terminal_sync(terminal_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Clears pending sync locks or re-syncs state for a given POS terminal in MySQL database.
|
||||||
|
"""
|
||||||
|
terminal = db.execute(
|
||||||
|
select(POSTerminal).where(POSTerminal.terminal_id == terminal_id)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
if not terminal:
|
||||||
|
raise HTTPException(status_code=404, detail=f"POS Terminal '{terminal_id}' not found")
|
||||||
|
|
||||||
|
terminal.pending_queue = 0
|
||||||
|
terminal.status = "ONLINE"
|
||||||
|
terminal.last_heartbeat = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "SUCCESS",
|
||||||
|
"terminal_id": terminal_id,
|
||||||
|
"message": f"Terminal '{terminal_id}' sync state reset successfully in database.",
|
||||||
|
}
|
||||||
24
app/api/v1/routers/ProductCompareRouter.py
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
"""
|
||||||
|
@router ProductCompareRouter (Backend/app/api/v1/routers/ProductCompareRouter.py)
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/products/compare", tags=["Product Comparison Matrix"])
|
||||||
|
|
||||||
|
class CompareRequest(BaseModel):
|
||||||
|
product_ids: List[str]
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
def compare_products(payload: CompareRequest):
|
||||||
|
return {
|
||||||
|
"attributes": ["Screen Size", "RAM", "Price"],
|
||||||
|
"products": [
|
||||||
|
{
|
||||||
|
"product_id": pid,
|
||||||
|
"name": f"Product {pid}",
|
||||||
|
"values": {"Screen Size": "6.1 in", "RAM": "N/A", "Price": 149.99}
|
||||||
|
} for pid in payload.product_ids
|
||||||
|
]
|
||||||
|
}
|
||||||
180
app/api/v1/routers/RolePermissionRouter.py
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from typing import List
|
||||||
|
import ulid
|
||||||
|
|
||||||
|
from app.core.database.db_session import get_db
|
||||||
|
from app.core.permissions.RoleChecker import get_current_user, RoleChecker
|
||||||
|
from app.models.UserModel import User
|
||||||
|
from app.models.RoleModel import Role
|
||||||
|
from app.models.PermissionModel import Permission, RolePermission
|
||||||
|
from app.schemas.RolePermission import RoleResponse, RoleCreate, PermissionResponse, RolePermissionUpdate
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/role-permissions", tags=["Roles & Permissions Control"])
|
||||||
|
|
||||||
|
@router.get("/roles/all", response_model=List[RoleResponse])
|
||||||
|
def get_all_roles(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(RoleChecker(["Super Admin", "Admin"]))
|
||||||
|
):
|
||||||
|
stmt = select(Role).where(Role.deleted_at.is_(None))
|
||||||
|
roles = db.execute(stmt).scalars().all()
|
||||||
|
return roles
|
||||||
|
|
||||||
|
@router.post("/roles/create", response_model=RoleResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_role(
|
||||||
|
data: RoleCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(RoleChecker(["Super Admin", "Admin"]))
|
||||||
|
):
|
||||||
|
existing = db.execute(select(Role).where(Role.role_name == data.role_name)).scalar_one_or_none()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail="Role name already exists")
|
||||||
|
|
||||||
|
role_id = str(ulid.ULID())
|
||||||
|
new_role = Role(
|
||||||
|
role_id=role_id,
|
||||||
|
role_name=data.role_name,
|
||||||
|
role_prefix=data.role_prefix,
|
||||||
|
description=data.description,
|
||||||
|
is_system=False,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
db.add(new_role)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(new_role)
|
||||||
|
return new_role
|
||||||
|
|
||||||
|
@router.delete("/roles/delete/{role_id}", status_code=status.HTTP_200_OK)
|
||||||
|
def delete_role(
|
||||||
|
role_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(RoleChecker(["Super Admin", "Admin"]))
|
||||||
|
):
|
||||||
|
role = db.execute(select(Role).where(Role.role_id == role_id)).scalar_one_or_none()
|
||||||
|
if not role:
|
||||||
|
raise HTTPException(status_code=404, detail="Role not found")
|
||||||
|
if role.is_system:
|
||||||
|
raise HTTPException(status_code=400, detail="System roles cannot be deleted")
|
||||||
|
|
||||||
|
user_assigned = db.execute(select(User).where(User.role_id == role_id, User.deleted_at.is_(None))).scalars().all()
|
||||||
|
if len(user_assigned) > 0:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot delete role because it is currently assigned to users")
|
||||||
|
|
||||||
|
role.deleted_at = db.execute(select(func.now())).scalar()
|
||||||
|
db.commit()
|
||||||
|
return {"detail": "Role deleted successfully"}
|
||||||
|
|
||||||
|
@router.get("/permissions/all", response_model=List[PermissionResponse])
|
||||||
|
def get_all_permissions(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(RoleChecker(["Super Admin", "Admin"]))
|
||||||
|
):
|
||||||
|
stmt = select(Permission).where(Permission.is_active.is_(True))
|
||||||
|
existing_permissions = db.execute(stmt).scalars().all()
|
||||||
|
existing_codes = {p.permission_code for p in existing_permissions}
|
||||||
|
|
||||||
|
seed_data = [
|
||||||
|
# User Management
|
||||||
|
("users:read", "users", "general", "View user lists and profile details"),
|
||||||
|
("users:write", "users", "general", "Create and modify staff user accounts"),
|
||||||
|
("users:delete", "users", "general", "Delete or deactivate staff user accounts"),
|
||||||
|
("roles:manage", "roles", "general", "Create, edit and assign roles & access control policies"),
|
||||||
|
("customers:manage", "customers", "general", "Manage registered e-commerce customers and addresses"),
|
||||||
|
("staff:manage", "staff", "general", "Manage staff directory, departments, and designations"),
|
||||||
|
# Catalog Management
|
||||||
|
("catalog:read", "catalog", "general", "View categories, brands, devices, and sales products"),
|
||||||
|
("catalog:write", "catalog", "general", "Create and edit product catalog items"),
|
||||||
|
("catalog:delete", "catalog", "general", "Delete catalog items, brands, and categories"),
|
||||||
|
# Marketing & Storefront
|
||||||
|
("marketing:manage", "marketing", "general", "Manage homepage sections, banners, and layout controls"),
|
||||||
|
# Inventory & Purchases
|
||||||
|
("inventory:manage", "inventory", "general", "Manage stock movements, warehouses, and purchase orders"),
|
||||||
|
# Sales & Billing
|
||||||
|
("sales:manage", "sales", "general", "Manage sales orders, invoices, and POS synchronization"),
|
||||||
|
("orders:read", "orders", "general", "View customer sales orders and fulfillment status"),
|
||||||
|
("orders:write", "orders", "general", "Update order status, shipping details, and cancellations"),
|
||||||
|
("invoices:manage", "invoices", "general", "Generate, issue, and manage sales invoices"),
|
||||||
|
("pos:manage", "pos", "general", "Configure POS synchronization and terminal integrations"),
|
||||||
|
# Repair & Services
|
||||||
|
("services:manage", "services", "general", "Manage service catalog, diagnostic checklists, and estimates"),
|
||||||
|
("services:read", "services", "general", "View repair service tickets, customer device intake, and status"),
|
||||||
|
("services:write", "services", "general", "Create service bookings, assign technicians, and update quotes"),
|
||||||
|
("technician:manage", "technician", "general", "Access technician repair queue and execute diagnostic checklists"),
|
||||||
|
# System Infrastructure & Security
|
||||||
|
("settings:manage", "settings", "general", "Configure global system settings and security policies"),
|
||||||
|
("security:manage", "security", "general", "View security audit logs, active sessions, and failed login audits"),
|
||||||
|
("migration:manage", "migration", "general", "Execute bulk product migration jobs and audit logs"),
|
||||||
|
]
|
||||||
|
|
||||||
|
new_added = False
|
||||||
|
for code_alias, module, resource, desc in seed_data:
|
||||||
|
action = code_alias.split(":")[1] if ":" in code_alias else "manage"
|
||||||
|
permission_code = f"{module}.{resource}.{action}"
|
||||||
|
if permission_code not in existing_codes:
|
||||||
|
p_id = str(ulid.ULID())
|
||||||
|
p_obj = Permission(
|
||||||
|
permission_id=p_id,
|
||||||
|
module=module,
|
||||||
|
resource=resource,
|
||||||
|
action=action,
|
||||||
|
permission_code=permission_code,
|
||||||
|
description=desc,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
db.add(p_obj)
|
||||||
|
new_added = True
|
||||||
|
|
||||||
|
if new_added:
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
permissions = db.execute(stmt).scalars().all()
|
||||||
|
return permissions
|
||||||
|
|
||||||
|
@router.get("/roles/{role_id}/permissions", response_model=List[str])
|
||||||
|
def get_role_permissions(
|
||||||
|
role_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(RoleChecker(["Super Admin", "Admin"]))
|
||||||
|
):
|
||||||
|
role = db.execute(select(Role).where(Role.role_id == role_id)).scalar_one_or_none()
|
||||||
|
if not role:
|
||||||
|
raise HTTPException(status_code=404, detail="Role not found")
|
||||||
|
|
||||||
|
stmt = select(RolePermission.permission_id).where(RolePermission.role_id == role_id)
|
||||||
|
perm_ids = db.execute(stmt).scalars().all()
|
||||||
|
return perm_ids
|
||||||
|
|
||||||
|
@router.post("/roles/{role_id}/permissions", status_code=status.HTTP_200_OK)
|
||||||
|
def update_role_permissions(
|
||||||
|
role_id: str,
|
||||||
|
data: RolePermissionUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(RoleChecker(["Super Admin", "Admin"]))
|
||||||
|
):
|
||||||
|
role = db.execute(select(Role).where(Role.role_id == role_id)).scalar_one_or_none()
|
||||||
|
if not role:
|
||||||
|
raise HTTPException(status_code=404, detail="Role not found")
|
||||||
|
|
||||||
|
if role.role_name.lower() == "super admin":
|
||||||
|
raise HTTPException(status_code=400, detail="Permissions for Super Admin role cannot be modified")
|
||||||
|
|
||||||
|
if data.permission_ids:
|
||||||
|
valid_perms = db.execute(select(Permission.permission_id).where(Permission.permission_id.in_(data.permission_ids))).scalars().all()
|
||||||
|
if len(valid_perms) != len(data.permission_ids):
|
||||||
|
raise HTTPException(status_code=400, detail="One or more permission IDs are invalid")
|
||||||
|
|
||||||
|
db.query(RolePermission).filter(RolePermission.role_id == role_id).delete()
|
||||||
|
|
||||||
|
for perm_id in data.permission_ids:
|
||||||
|
link_id = str(ulid.ULID())
|
||||||
|
link = RolePermission(
|
||||||
|
id=link_id,
|
||||||
|
role_id=role_id,
|
||||||
|
permission_id=perm_id
|
||||||
|
)
|
||||||
|
db.add(link)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"detail": "Role permissions updated successfully"}
|
||||||
597
app/api/v1/routers/ServiceJobRouter.py
Normal file
|
|
@ -0,0 +1,597 @@
|
||||||
|
from fastapi import APIRouter, Depends, Query, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import select
|
||||||
|
from datetime import date
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
|
||||||
|
from app.core.database.db_session import get_db
|
||||||
|
from app.core.permissions.RoleChecker import RoleChecker
|
||||||
|
from app.models.UserModel import User
|
||||||
|
# For customer oauth verification
|
||||||
|
from app.api.v1.routers.CheckoutRouter import get_current_customer
|
||||||
|
from app.models.EcomCustomerModel import EcomCustomer
|
||||||
|
from app.models.ServiceModel import ServiceJob
|
||||||
|
|
||||||
|
from app.schemas.ServiceSchema import (
|
||||||
|
ServiceJobCreate, ServiceJobResponse, ServiceJobIntakeCreate, ServiceJobIntakeResponse,
|
||||||
|
ServiceJobInspectionCreate, ServiceJobInspectionResponse, ServiceJobRescheduleRequest,
|
||||||
|
ServiceCatalogResponse, ServiceJobMediaBatchCreate, ServiceJobMediaResponse
|
||||||
|
)
|
||||||
|
from app.schemas.ServiceQuoteSchema import ServiceJobQuoteCreate, ServiceJobQuoteResponse
|
||||||
|
from app.schemas.ServicePaymentSchema import ServicePaymentCreate, RazorpayVerificationRequest
|
||||||
|
|
||||||
|
from app.services.ServiceJobService import ServiceJobService
|
||||||
|
from app.services.SlotAllocationService import SlotAllocationService
|
||||||
|
from app.services.InspectionService import InspectionService
|
||||||
|
from app.services.QuoteService import QuoteService
|
||||||
|
from app.services.ServicePaymentService import ServicePaymentService
|
||||||
|
from app.repositories.ServiceRepository import ServiceRepository
|
||||||
|
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
security = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
def get_current_actor(
|
||||||
|
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
if not credentials:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Not authenticated",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"}
|
||||||
|
)
|
||||||
|
token = credentials.credentials
|
||||||
|
try:
|
||||||
|
from app.core.Token import verify_access_token
|
||||||
|
payload = verify_access_token(token)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail=f"Token validation failed: {str(e)}",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"}
|
||||||
|
)
|
||||||
|
role = payload.get("role", "")
|
||||||
|
sub = payload.get("sub")
|
||||||
|
if role == "customer":
|
||||||
|
customer = db.query(EcomCustomer).filter(EcomCustomer.customer_id == sub).first()
|
||||||
|
if not customer:
|
||||||
|
raise HTTPException(status_code=404, detail="Customer not found")
|
||||||
|
return {"type": "customer", "obj": customer}
|
||||||
|
elif role in ["Super Admin", "Admin", "Manager", "Technician"]:
|
||||||
|
user = db.query(User).filter(User.user_id == sub).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="Admin user not found")
|
||||||
|
return {"type": "admin", "obj": user}
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized")
|
||||||
|
|
||||||
|
def get_optional_actor(
|
||||||
|
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
) -> Optional[dict]:
|
||||||
|
if not credentials:
|
||||||
|
return None
|
||||||
|
token = credentials.credentials
|
||||||
|
try:
|
||||||
|
from app.core.Token import verify_access_token
|
||||||
|
payload = verify_access_token(token)
|
||||||
|
role = payload.get("role", "")
|
||||||
|
sub = payload.get("sub")
|
||||||
|
if role == "customer":
|
||||||
|
customer = db.query(EcomCustomer).filter(EcomCustomer.customer_id == sub).first()
|
||||||
|
if customer:
|
||||||
|
return {"type": "customer", "obj": customer}
|
||||||
|
elif role in ["Super Admin", "Admin", "Manager", "Technician"]:
|
||||||
|
user = db.query(User).filter(User.user_id == sub).first()
|
||||||
|
if user:
|
||||||
|
return {"type": "admin", "obj": user}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/service", tags=["Repairs & Scheduling Services"])
|
||||||
|
|
||||||
|
job_service = ServiceJobService()
|
||||||
|
allocation_service = SlotAllocationService()
|
||||||
|
inspection_service = InspectionService()
|
||||||
|
quote_service = QuoteService()
|
||||||
|
payment_service = ServicePaymentService()
|
||||||
|
service_repo = ServiceRepository()
|
||||||
|
|
||||||
|
@router.get("/catalog", response_model=List[ServiceCatalogResponse])
|
||||||
|
def get_catalog_services(db: Session = Depends(get_db)):
|
||||||
|
"""Get active repair & diagnostics catalog options."""
|
||||||
|
return service_repo.get_all_catalog_services(db)
|
||||||
|
|
||||||
|
@router.get("/slots/available")
|
||||||
|
def get_available_appointment_slots(
|
||||||
|
service_id: str,
|
||||||
|
target_date: date = Query(...),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Dynamically fetch schedule slots based on technician availability and duration."""
|
||||||
|
return allocation_service.get_available_slots(db, target_date, service_id)
|
||||||
|
|
||||||
|
@router.post("/booking/create")
|
||||||
|
def create_online_service_booking(
|
||||||
|
payload: ServiceJobCreate,
|
||||||
|
actor: dict = Depends(get_current_actor),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Book a repair job slot and lock capacity (starts 10-minute slot hold)."""
|
||||||
|
if actor["type"] == "customer":
|
||||||
|
customer_id = actor["obj"].customer_id
|
||||||
|
else:
|
||||||
|
# Admin flow
|
||||||
|
if payload.customer_id:
|
||||||
|
customer_id = payload.customer_id
|
||||||
|
elif payload.customer_name:
|
||||||
|
import ulid
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
parts = payload.customer_name.strip().split(maxsplit=1)
|
||||||
|
first_name = parts[0]
|
||||||
|
last_name = parts[1] if len(parts) > 1 else ""
|
||||||
|
|
||||||
|
u_str = str(ulid.ULID())
|
||||||
|
raw_phone = (payload.customer_phone or "").strip()
|
||||||
|
digits_only = "".join(c for c in raw_phone if c.isdigit())
|
||||||
|
phone_suffix = digits_only[-10:] if len(digits_only) >= 10 else digits_only
|
||||||
|
|
||||||
|
email = (payload.customer_email or f"walkin_{u_str}@ifixkart.com").strip()
|
||||||
|
phone = raw_phone if raw_phone else f"W{u_str[-15:]}"
|
||||||
|
|
||||||
|
existing = None
|
||||||
|
if phone_suffix:
|
||||||
|
existing = db.query(EcomCustomer).filter(
|
||||||
|
(EcomCustomer.phone.like(f"%{phone_suffix}%")) | (EcomCustomer.email == email)
|
||||||
|
).first()
|
||||||
|
elif payload.customer_email:
|
||||||
|
existing = db.query(EcomCustomer).filter(EcomCustomer.email == email).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
customer_id = existing.customer_id
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
new_cust = EcomCustomer(
|
||||||
|
customer_id=str(ulid.ULID()),
|
||||||
|
first_name=first_name,
|
||||||
|
last_name=last_name,
|
||||||
|
email=email,
|
||||||
|
phone=phone,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
db.add(new_cust)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(new_cust)
|
||||||
|
customer_id = new_cust.customer_id
|
||||||
|
except IntegrityError:
|
||||||
|
db.rollback()
|
||||||
|
found = db.query(EcomCustomer).filter(
|
||||||
|
(EcomCustomer.phone.like(f"%{phone_suffix}%")) if phone_suffix else (EcomCustomer.email == email)
|
||||||
|
).first()
|
||||||
|
if found:
|
||||||
|
customer_id = found.customer_id
|
||||||
|
else:
|
||||||
|
customer_id = str(ulid.ULID())
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=400, detail="customer_id or customer_name required for admin booking")
|
||||||
|
|
||||||
|
return job_service.create_online_booking(db, customer_id, payload)
|
||||||
|
|
||||||
|
@router.post("/jobs/{job_id}/intake", response_model=ServiceJobIntakeResponse)
|
||||||
|
def record_walk_in_device_intake(
|
||||||
|
job_id: str,
|
||||||
|
payload: ServiceJobIntakeCreate,
|
||||||
|
current_user: User = Depends(RoleChecker(["Super Admin", "Admin", "Manager"])),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Record physical checklists (SIM tray, scratches, power status) during device receipt."""
|
||||||
|
return inspection_service.create_device_intake(db, job_id, current_user.user_id, payload)
|
||||||
|
|
||||||
|
@router.post("/jobs/{job_id}/inspect", response_model=ServiceJobInspectionResponse)
|
||||||
|
def submit_technician_diagnostic_findings(
|
||||||
|
job_id: str,
|
||||||
|
payload: ServiceJobInspectionCreate,
|
||||||
|
current_user: User = Depends(RoleChecker(["Super Admin", "Admin", "Manager", "Technician"])),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Submit technician diagnostic findings and inspection status."""
|
||||||
|
return inspection_service.submit_inspection(db, job_id, current_user.user_id, payload)
|
||||||
|
|
||||||
|
@router.post("/jobs/{job_id}/quotes")
|
||||||
|
@router.post("/jobs/{job_id}/quotes/create")
|
||||||
|
def create_or_revise_repair_quote(
|
||||||
|
job_id: str,
|
||||||
|
payload: ServiceJobQuoteCreate,
|
||||||
|
current_user: User = Depends(RoleChecker(["Super Admin", "Admin", "Manager", "Technician"])),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Generate or revise estimate quote for customer approval."""
|
||||||
|
return quote_service.create_or_revise_quote(db, job_id, current_user.user_id, payload)
|
||||||
|
|
||||||
|
@router.post("/jobs/{job_id}/quotes/{quote_id}/respond")
|
||||||
|
def respond_to_quote_estimate(
|
||||||
|
job_id: str,
|
||||||
|
quote_id: str,
|
||||||
|
action: str = Query(..., regex="^(ACCEPT|REJECT)$"),
|
||||||
|
actor: dict = Depends(get_current_actor),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Customer approves or declines line items in revised estimate quote."""
|
||||||
|
customer_id = actor["obj"].customer_id if actor["type"] == "customer" else "SYSTEM"
|
||||||
|
return quote_service.respond_to_quote(db, quote_id, customer_id, action)
|
||||||
|
|
||||||
|
@router.post("/jobs/{job_id}/payments/initiate")
|
||||||
|
def initiate_milestone_payment(
|
||||||
|
job_id: str,
|
||||||
|
payload: ServicePaymentCreate,
|
||||||
|
actor: dict = Depends(get_current_actor),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Initiate Razorpay order for advance deposit or final balance payment."""
|
||||||
|
customer_id = actor["obj"].customer_id if actor["type"] == "customer" else None
|
||||||
|
return payment_service.initiate_payment(db, job_id, payload, customer_id)
|
||||||
|
|
||||||
|
@router.post("/payments/verify")
|
||||||
|
@router.post("/jobs/{job_id}/payments/verify")
|
||||||
|
def verify_milestone_payment(
|
||||||
|
payload: RazorpayVerificationRequest,
|
||||||
|
job_id: Optional[str] = None,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Verify Razorpay payment signature & update job/payment ledger."""
|
||||||
|
return payment_service.verify_payment(db, job_id or "", payload)
|
||||||
|
|
||||||
|
@router.post("/jobs/{job_id}/reschedule")
|
||||||
|
def reschedule_active_appointment(
|
||||||
|
job_id: str,
|
||||||
|
payload: ServiceJobRescheduleRequest,
|
||||||
|
actor: dict = Depends(get_current_actor),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Customer or staff reschedules repair appointment time slot."""
|
||||||
|
customer_id = actor["obj"].customer_id if actor["type"] == "customer" else None
|
||||||
|
return job_service.reschedule_appointment(db, job_id, customer_id, payload)
|
||||||
|
|
||||||
|
@router.get("/jobs/{job_id}")
|
||||||
|
def get_service_job_details(
|
||||||
|
job_id: str,
|
||||||
|
actor: Optional[dict] = Depends(get_optional_actor),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Retrieve service job, current appointment, timeline events, active quotes, and media proof."""
|
||||||
|
job = db.get(ServiceJob, job_id) or db.query(ServiceJob).filter(ServiceJob.job_no == job_id).first()
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(status_code=404, detail="Service job not found")
|
||||||
|
|
||||||
|
if actor and actor["type"] == "customer" and job.customer_id and job.customer_id != actor["obj"].customer_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized to view this job")
|
||||||
|
|
||||||
|
from app.models.ServiceModel import ServiceAppointment, ServiceJobEvent
|
||||||
|
from app.models.ServiceQuoteModel import ServiceJobQuote
|
||||||
|
from app.models.ServicePaymentModel import ServicePayment
|
||||||
|
|
||||||
|
# Active appointment
|
||||||
|
app_stmt = select(ServiceAppointment).where(
|
||||||
|
ServiceAppointment.service_job_id == job.job_id,
|
||||||
|
ServiceAppointment.status.in_(["HELD", "CONFIRMED"])
|
||||||
|
)
|
||||||
|
appt = db.execute(app_stmt).scalar_one_or_none()
|
||||||
|
|
||||||
|
# Timeline events
|
||||||
|
event_stmt = select(ServiceJobEvent).where(ServiceJobEvent.job_id == job.job_id).order_by(ServiceJobEvent.timestamp.asc())
|
||||||
|
events = list(db.execute(event_stmt).scalars().all())
|
||||||
|
|
||||||
|
# Latest quote
|
||||||
|
quote_stmt = select(ServiceJobQuote).where(
|
||||||
|
ServiceJobQuote.service_job_id == job.job_id,
|
||||||
|
ServiceJobQuote.status.in_(["PENDING_CUSTOMER", "ACCEPTED", "REJECTED"])
|
||||||
|
).order_by(ServiceJobQuote.version.desc())
|
||||||
|
quote = db.execute(quote_stmt).scalars().first()
|
||||||
|
|
||||||
|
# Payments
|
||||||
|
pay_stmt = select(ServicePayment).where(ServicePayment.service_job_id == job.job_id)
|
||||||
|
payments = list(db.execute(pay_stmt).scalars().all())
|
||||||
|
|
||||||
|
# Latest inspection (for damage description)
|
||||||
|
from app.models.ServiceModel import ServiceJobInspection
|
||||||
|
insp_stmt = select(ServiceJobInspection).where(
|
||||||
|
ServiceJobInspection.service_job_id == job.job_id
|
||||||
|
).order_by(ServiceJobInspection.created_at.desc())
|
||||||
|
inspection = db.execute(insp_stmt).scalars().first()
|
||||||
|
|
||||||
|
from app.models.EcomCustomerModel import EcomCustomer
|
||||||
|
cust = db.get(EcomCustomer, job.customer_id) if job.customer_id else None
|
||||||
|
|
||||||
|
# Fetch job media items
|
||||||
|
media_list = inspection_service.get_job_media(db, job.job_id)
|
||||||
|
|
||||||
|
# Build response payload
|
||||||
|
return {
|
||||||
|
"job_id": job.job_id,
|
||||||
|
"job_no": job.job_no,
|
||||||
|
"status": job.status,
|
||||||
|
"customer": {
|
||||||
|
"customer_id": job.customer_id,
|
||||||
|
"name": f"{cust.first_name} {cust.last_name}".strip() if cust else "Guest Customer",
|
||||||
|
"email": cust.email if cust else "N/A",
|
||||||
|
"phone": cust.phone if cust else "N/A"
|
||||||
|
},
|
||||||
|
"service_name": job.service_name_snapshot or (job.service.name if job.service else "Repair Service"),
|
||||||
|
"base_price": float(job.base_price_snapshot if job.base_price_snapshot is not None else (job.service.base_price if job.service else 0.0)),
|
||||||
|
"device_brand": job.device.brand if job.device else (job.brand_id or "Generic"),
|
||||||
|
"device_model": job.device.model if job.device else (job.model_id or "Device"),
|
||||||
|
"fulfillment_type": job.fulfillment_type or "WALK_IN",
|
||||||
|
"fulfillment_fee": float(job.fulfillment_fee or (250.0 if job.fulfillment_type == "DOORSTEP_PICKUP" else 0.0)),
|
||||||
|
"courier_name": getattr(job, "courier_name", None),
|
||||||
|
"awb_number": getattr(job, "awb_number", None),
|
||||||
|
"pickup_status": getattr(job, "pickup_status", None),
|
||||||
|
"delivery_address": job.delivery_address,
|
||||||
|
"media": media_list,
|
||||||
|
"appointment": {
|
||||||
|
"appointment_id": appt.appointment_id,
|
||||||
|
"scheduled_start": appt.scheduled_start.isoformat(),
|
||||||
|
"scheduled_end": appt.scheduled_end.isoformat(),
|
||||||
|
"status": appt.status
|
||||||
|
} if appt else None,
|
||||||
|
"quote": {
|
||||||
|
"quote_id": quote.quote_id,
|
||||||
|
"version": quote.version,
|
||||||
|
"subtotal": float(quote.subtotal),
|
||||||
|
"tax": float(quote.tax),
|
||||||
|
"additional_damage_amount": float(quote.additional_damage_amount),
|
||||||
|
"total": float(quote.total),
|
||||||
|
"status": quote.status,
|
||||||
|
"reason": quote.reason,
|
||||||
|
"expires_at": quote.expires_at.isoformat() if quote.expires_at else None,
|
||||||
|
"additional_damage_description": inspection.additional_damage if inspection else None,
|
||||||
|
} if quote else None,
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"event_type": ev.event_type,
|
||||||
|
"timestamp": ev.timestamp.isoformat(),
|
||||||
|
"notes": ev.notes
|
||||||
|
} for ev in events
|
||||||
|
],
|
||||||
|
"payments": [
|
||||||
|
{
|
||||||
|
"payment_type": p.payment_type,
|
||||||
|
"amount": float(p.amount),
|
||||||
|
"status": p.status,
|
||||||
|
"paid_at": p.paid_at.isoformat() if p.paid_at else None
|
||||||
|
} for p in payments
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.get("/jobs")
|
||||||
|
def list_service_jobs_admin(
|
||||||
|
current_user: User = Depends(RoleChecker(["Super Admin", "Admin", "Manager", "Technician"])),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""List all repair & diagnostics jobs for dashboard queues."""
|
||||||
|
from app.models.ServiceModel import ServiceJob
|
||||||
|
from app.models.EcomCustomerModel import EcomCustomer
|
||||||
|
stmt = select(ServiceJob).order_by(ServiceJob.created_at.desc())
|
||||||
|
jobs = db.execute(stmt).scalars().all()
|
||||||
|
|
||||||
|
cust_ids = {j.customer_id for j in jobs if j.customer_id}
|
||||||
|
cust_map = {}
|
||||||
|
if cust_ids:
|
||||||
|
custs = db.execute(select(EcomCustomer).where(EcomCustomer.customer_id.in_(cust_ids))).scalars().all()
|
||||||
|
for c in custs:
|
||||||
|
cust_map[c.customer_id] = {
|
||||||
|
"name": f"{c.first_name} {c.last_name}".strip(),
|
||||||
|
"email": c.email,
|
||||||
|
"phone": c.phone or "N/A"
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"job_id": j.job_id,
|
||||||
|
"job_no": j.job_no,
|
||||||
|
"customer_id": j.customer_id,
|
||||||
|
"customer_name": cust_map.get(j.customer_id, {}).get("name") or (f"Customer ({j.customer_id[:8]})" if j.customer_id else "Guest Customer"),
|
||||||
|
"customer_email": cust_map.get(j.customer_id, {}).get("email") or "N/A",
|
||||||
|
"customer_phone": cust_map.get(j.customer_id, {}).get("phone") or "N/A",
|
||||||
|
"status": j.status,
|
||||||
|
"service_name": j.service_name_snapshot or (j.custom_service_name if j.custom_service_name else (j.service.name if j.service else "Custom Repair")),
|
||||||
|
"base_price": float(j.base_price_snapshot if j.base_price_snapshot is not None else (j.service.base_price if j.service else 0.0)),
|
||||||
|
"device_brand": j.device.brand if j.device else (j.brand_id or "Generic"),
|
||||||
|
"device_model": j.device.model if j.device else (j.model_id or "Device"),
|
||||||
|
"fulfillment_type": j.fulfillment_type or "WALK_IN",
|
||||||
|
"fulfillment_fee": float(j.fulfillment_fee or (250.0 if j.fulfillment_type == "DOORSTEP_PICKUP" else 0.0)),
|
||||||
|
"courier_name": getattr(j, "courier_name", None),
|
||||||
|
"awb_number": getattr(j, "awb_number", None),
|
||||||
|
"pickup_status": getattr(j, "pickup_status", None),
|
||||||
|
"delivery_address": j.delivery_address,
|
||||||
|
"created_at": j.created_at.isoformat()
|
||||||
|
} for j in jobs
|
||||||
|
]
|
||||||
|
|
||||||
|
@router.post("/jobs/{job_id}/logistics")
|
||||||
|
def update_service_job_logistics(
|
||||||
|
job_id: str,
|
||||||
|
payload: dict,
|
||||||
|
current_user: User = Depends(RoleChecker(["Super Admin", "Admin", "Manager", "Technician"])),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Update courier name, AWB number, and pickup status for doorstep pickup / courier jobs."""
|
||||||
|
from app.models.ServiceModel import ServiceJob, ServiceJobEvent
|
||||||
|
import ulid
|
||||||
|
|
||||||
|
job = db.get(ServiceJob, job_id)
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(status_code=404, detail="Job not found")
|
||||||
|
|
||||||
|
courier_name = payload.get("courier_name")
|
||||||
|
awb_number = payload.get("awb_number")
|
||||||
|
pickup_status = payload.get("pickup_status")
|
||||||
|
|
||||||
|
if courier_name is not None:
|
||||||
|
job.courier_name = courier_name
|
||||||
|
if awb_number is not None:
|
||||||
|
job.awb_number = awb_number
|
||||||
|
if pickup_status is not None:
|
||||||
|
job.pickup_status = pickup_status
|
||||||
|
|
||||||
|
note_parts = []
|
||||||
|
if courier_name: note_parts.append(f"Courier: {courier_name}")
|
||||||
|
if awb_number: note_parts.append(f"AWB: {awb_number}")
|
||||||
|
if pickup_status: note_parts.append(f"Pickup Status: {pickup_status}")
|
||||||
|
|
||||||
|
event = ServiceJobEvent(
|
||||||
|
event_id=str(ulid.ULID()),
|
||||||
|
job_id=job_id,
|
||||||
|
event_type="LOGISTICS_UPDATED",
|
||||||
|
performed_by=current_user.user_id,
|
||||||
|
notes="Logistics updated: " + ", ".join(note_parts) if note_parts else "Logistics details updated."
|
||||||
|
)
|
||||||
|
db.add(event)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(job)
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"job_id": job.job_id,
|
||||||
|
"courier_name": job.courier_name,
|
||||||
|
"awb_number": job.awb_number,
|
||||||
|
"pickup_status": job.pickup_status
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.post("/jobs/{job_id}/media")
|
||||||
|
def attach_service_job_media(
|
||||||
|
job_id: str,
|
||||||
|
payload: ServiceJobMediaBatchCreate,
|
||||||
|
current_user: User = Depends(RoleChecker(["Super Admin", "Admin", "Manager", "Technician"])),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Attach media files (video/photos) under category (INSPECTION_DONE, READY_FOR_DELIVERY, etc.)."""
|
||||||
|
return inspection_service.batch_upload_job_media(db, job_id, payload.category, payload.file_ids)
|
||||||
|
|
||||||
|
@router.get("/jobs/{job_id}/media")
|
||||||
|
def get_service_job_media(
|
||||||
|
job_id: str,
|
||||||
|
category: Optional[str] = Query(None),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get media proof uploaded for a service job."""
|
||||||
|
return inspection_service.get_job_media(db, job_id, category)
|
||||||
|
|
||||||
|
@router.post("/jobs/{job_id}/status")
|
||||||
|
def update_service_job_status_admin(
|
||||||
|
job_id: str,
|
||||||
|
status: str = Query(..., description="New status value"),
|
||||||
|
current_user: User = Depends(RoleChecker(["Super Admin", "Admin", "Manager", "Technician"])),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Admin/Technician manual status override for a service job."""
|
||||||
|
from app.models.ServiceModel import ServiceJob, ServiceJobEvent
|
||||||
|
import ulid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
job = db.get(ServiceJob, job_id)
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(status_code=404, detail="Job not found")
|
||||||
|
|
||||||
|
job.status = status
|
||||||
|
|
||||||
|
# Credential Lifecycle: Purge lock credentials upon repair completion / delivery
|
||||||
|
if status in ["DELIVERED", "COMPLETED", "CLOSED", "READY_FOR_DELIVERY"]:
|
||||||
|
if job.lock_credential_encrypted:
|
||||||
|
job.lock_credential_encrypted = None
|
||||||
|
job.lock_credential_deleted_at = datetime.utcnow()
|
||||||
|
|
||||||
|
event = ServiceJobEvent(
|
||||||
|
event_id=str(ulid.ULID()),
|
||||||
|
job_id=job_id,
|
||||||
|
event_type=status,
|
||||||
|
performed_by=current_user.user_id,
|
||||||
|
notes=f"Status updated to {status} by technician."
|
||||||
|
)
|
||||||
|
db.add(event)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(job)
|
||||||
|
return {"status": "success", "new_status": job.status}
|
||||||
|
|
||||||
|
@router.get("/jobs/{job_id}/credentials")
|
||||||
|
def get_service_job_credentials_admin(
|
||||||
|
job_id: str,
|
||||||
|
current_user: User = Depends(RoleChecker(["Super Admin", "Admin", "Manager", "Technician"])),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Retrieve and decrypt customer device lock credentials with access logging audit."""
|
||||||
|
from app.models.ServiceModel import ServiceJob
|
||||||
|
from app.core.security.LockValidator import decrypt_credential
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
job = db.get(ServiceJob, job_id)
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(status_code=404, detail="Job not found")
|
||||||
|
|
||||||
|
if not job.lock_credential_encrypted:
|
||||||
|
return {
|
||||||
|
"lock_type": job.lock_type,
|
||||||
|
"passcode": None,
|
||||||
|
"is_deleted": job.lock_credential_deleted_at is not None,
|
||||||
|
"deleted_at": job.lock_credential_deleted_at
|
||||||
|
}
|
||||||
|
|
||||||
|
# Record access audit metadata
|
||||||
|
now = datetime.utcnow()
|
||||||
|
job.lock_credential_accessed_at = now
|
||||||
|
job.lock_credential_accessed_by = current_user.user_id
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
decrypted = decrypt_credential(job.lock_credential_encrypted)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"lock_type": job.lock_type,
|
||||||
|
"passcode": decrypted,
|
||||||
|
"accessed_at": now,
|
||||||
|
"accessed_by": current_user.user_id
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.get("/jobs/{job_id}/private-video")
|
||||||
|
def stream_private_condition_video(
|
||||||
|
job_id: str,
|
||||||
|
actor: Optional[dict] = Depends(get_optional_actor),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Access control endpoint for viewing private pre-courier device condition video."""
|
||||||
|
from app.models.ServiceModel import ServiceJob
|
||||||
|
from app.models.FileModel import FileUpload
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
job = db.get(ServiceJob, job_id)
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(status_code=404, detail="Job not found")
|
||||||
|
|
||||||
|
if not job.pre_dispatch_video_id:
|
||||||
|
raise HTTPException(status_code=404, detail="No pre-dispatch video attached to this job")
|
||||||
|
|
||||||
|
# Authorization Check: Actor must be admin/technician or the customer owning the job
|
||||||
|
is_authorized = False
|
||||||
|
if actor:
|
||||||
|
if actor["type"] == "admin":
|
||||||
|
is_authorized = True
|
||||||
|
elif actor["type"] == "customer" and actor["obj"].customer_id == job.customer_id:
|
||||||
|
is_authorized = True
|
||||||
|
|
||||||
|
if not is_authorized:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized to access this private video")
|
||||||
|
|
||||||
|
# Fetch file record
|
||||||
|
file_record = db.query(FileUpload).filter(FileUpload.file_id == job.pre_dispatch_video_id).first()
|
||||||
|
if not file_record:
|
||||||
|
raise HTTPException(status_code=404, detail="Video file record not found")
|
||||||
|
|
||||||
|
file_path = Path(file_record.storage_path.lstrip("/"))
|
||||||
|
if not file_path.is_absolute():
|
||||||
|
from app.core.config import BACKEND_ROOT
|
||||||
|
file_path = BACKEND_ROOT / file_path
|
||||||
|
|
||||||
|
if not file_path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Video media file missing on server disk")
|
||||||
|
|
||||||
|
return FileResponse(file_path, media_type=file_record.mime_type or "video/mp4")
|
||||||
107
app/api/v1/routers/SettingsRouter.py
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List
|
||||||
|
import ulid
|
||||||
|
|
||||||
|
from app.core.database.db_session import get_db
|
||||||
|
from app.repositories.setting_repository import setting_repository
|
||||||
|
from app.models.SettingModel import Setting
|
||||||
|
from app.schemas.Settings import SettingCreate, SettingUpdate, SettingResponse
|
||||||
|
from app.models.UserModel import User
|
||||||
|
from app.core.permissions.RoleChecker import get_current_user, PermissionChecker
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/settings", tags=["Configuration Settings"])
|
||||||
|
|
||||||
|
@router.post("/create", response_model=SettingResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_setting(
|
||||||
|
data: SettingCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("settings.create"))
|
||||||
|
):
|
||||||
|
existing = setting_repository.get_by_key(db, data.setting_key)
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail="Setting key already exists.")
|
||||||
|
|
||||||
|
setting_id = str(ulid.ULID())
|
||||||
|
new_setting = Setting(
|
||||||
|
setting_id=setting_id,
|
||||||
|
setting_key=data.setting_key,
|
||||||
|
setting_value=data.setting_value,
|
||||||
|
description=data.description,
|
||||||
|
is_public=data.is_public
|
||||||
|
)
|
||||||
|
|
||||||
|
setting_repository.create(db, new_setting)
|
||||||
|
return new_setting
|
||||||
|
|
||||||
|
@router.get("/key/{key_name}", response_model=SettingResponse)
|
||||||
|
def get_setting_by_key(
|
||||||
|
key_name: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
setting = setting_repository.get_by_key(db, key_name)
|
||||||
|
if not setting:
|
||||||
|
raise HTTPException(status_code=404, detail="Setting not found")
|
||||||
|
|
||||||
|
# Restrict private settings to admin/system users
|
||||||
|
if not setting.is_public and current_user.role.role_name.lower() not in ["admin", "super admin"] and current_user.user_id != "internal-mcp":
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied to private settings")
|
||||||
|
|
||||||
|
return setting
|
||||||
|
|
||||||
|
@router.get("/public", response_model=List[SettingResponse])
|
||||||
|
def get_public_settings(
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
return setting_repository.get_public_settings(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/update/{key_name}", response_model=SettingResponse)
|
||||||
|
def update_setting(
|
||||||
|
key_name: str,
|
||||||
|
payload: SettingUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("settings.update"))
|
||||||
|
):
|
||||||
|
setting = setting_repository.get_by_key(db, key_name)
|
||||||
|
if not setting:
|
||||||
|
raise HTTPException(status_code=404, detail="Setting not found")
|
||||||
|
|
||||||
|
setting.setting_value = payload.setting_value
|
||||||
|
if payload.description is not None:
|
||||||
|
setting.description = payload.description
|
||||||
|
if payload.is_public is not None:
|
||||||
|
setting.is_public = payload.is_public
|
||||||
|
|
||||||
|
setting_repository.update(db, setting)
|
||||||
|
return setting
|
||||||
|
|
||||||
|
@router.post("/save/{key_name}", response_model=SettingResponse)
|
||||||
|
def save_setting(
|
||||||
|
key_name: str,
|
||||||
|
payload: SettingUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
setting = setting_repository.get_by_key(db, key_name)
|
||||||
|
if not setting:
|
||||||
|
setting_id = str(ulid.ULID())
|
||||||
|
setting = Setting(
|
||||||
|
setting_id=setting_id,
|
||||||
|
setting_key=key_name,
|
||||||
|
group="invoice_branding" if "branding" in key_name else "storefront",
|
||||||
|
setting_value=payload.setting_value,
|
||||||
|
description=payload.description or f"Setting for {key_name}",
|
||||||
|
is_public=payload.is_public if payload.is_public is not None else True
|
||||||
|
)
|
||||||
|
setting_repository.create(db, setting)
|
||||||
|
else:
|
||||||
|
setting.setting_value = payload.setting_value
|
||||||
|
if payload.description is not None:
|
||||||
|
setting.description = payload.description
|
||||||
|
if payload.is_public is not None:
|
||||||
|
setting.is_public = payload.is_public
|
||||||
|
setting_repository.update(db, setting)
|
||||||
|
|
||||||
|
return setting
|
||||||
116
app/api/v1/routers/StorefrontRouter.py
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
"""
|
||||||
|
@router StorefrontRouter (Backend/app/api/v1/routers/StorefrontRouter.py)
|
||||||
|
@purpose Read-only public endpoints for footer info, store settings, mega menu, catalog filters,
|
||||||
|
trust badges, blog posts, and product reviews — all backed by StorefrontCmsService.
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from app.core.database.db_session import get_db
|
||||||
|
from app.services.StorefrontCmsService import StorefrontCmsService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/storefront", tags=["Storefront Dynamic Services"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/footer-info")
|
||||||
|
def get_footer_info(db: Session = Depends(get_db)) -> Any:
|
||||||
|
"""Returns dynamic footer contact info, social links, navigation columns, and payment icons."""
|
||||||
|
svc = StorefrontCmsService(db)
|
||||||
|
return svc.get_footer_info()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/settings")
|
||||||
|
def get_storefront_settings(db: Session = Depends(get_db)) -> Any:
|
||||||
|
"""Returns store branding settings (logo, wordmark, support phone, advance_percent, etc.)."""
|
||||||
|
svc = StorefrontCmsService(db)
|
||||||
|
return svc.get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/mega-menu")
|
||||||
|
def get_mega_menu(
|
||||||
|
nav_key: Optional[str] = Query(None, pattern="^(shop|deals|products)$"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
Returns mega-menu configuration.
|
||||||
|
If nav_key is supplied returns single menu config.
|
||||||
|
If omitted returns all three (shop, deals, products) in one payload.
|
||||||
|
"""
|
||||||
|
svc = StorefrontCmsService(db)
|
||||||
|
return svc.get_mega_menu(nav_key)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/catalog-filters")
|
||||||
|
def get_catalog_filters(db: Session = Depends(get_db)) -> Any:
|
||||||
|
"""Returns highlight filter tabs and price range buckets for the shop catalog page."""
|
||||||
|
svc = StorefrontCmsService(db)
|
||||||
|
return svc.get_catalog_filters()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/reviews/{product_id}")
|
||||||
|
def get_product_reviews(product_id: str, db: Session = Depends(get_db)) -> List[Any]:
|
||||||
|
"""Returns only approved customer reviews for a given product (live from DB)."""
|
||||||
|
from app.services.StorefrontService import StorefrontService
|
||||||
|
svc = StorefrontService(db)
|
||||||
|
return svc.get_reviews(product_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/trust-badges")
|
||||||
|
def get_trust_badges() -> List[Dict[str, Any]]:
|
||||||
|
"""Returns dynamic storefront trust badges."""
|
||||||
|
return [
|
||||||
|
{"id": "tb1", "title": "Free Shipping", "subtitle": "On order over ₹1,000", "icon": "truck"},
|
||||||
|
{"id": "tb2", "title": "Flexible & Easy Return", "subtitle": "Return within 14 days", "icon": "refresh"},
|
||||||
|
{"id": "tb3", "title": "24/7 Support Services", "subtitle": "Any Time Customer Support", "icon": "headset"},
|
||||||
|
{"id": "tb4", "title": "Secure payment", "subtitle": "100% Fast & Secure Payment", "icon": "shield"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/blog-posts")
|
||||||
|
def get_blog_posts() -> List[Dict[str, Any]]:
|
||||||
|
"""Returns dynamic tech blog articles."""
|
||||||
|
return [
|
||||||
|
{"post_id": "bp1", "title": "How to Write a Blog Post Your Readers Will Love in 5 Steps", "excerpt": "Why the world would end without travel coupons. The 16 worst...", "author": "Admin", "date": "July 24, 2026", "image_url": ""},
|
||||||
|
{"post_id": "bp2", "title": "9 Content Marketing Trends and Ideas to Increase Traffic", "excerpt": "Why do people think wholesale accessories are a good idea? Unbelievable...", "author": "Technician", "date": "July 22, 2026", "image_url": ""},
|
||||||
|
{"post_id": "bp3", "title": "The Ultimate Guide to Marketing Strategies to Improve Sales", "excerpt": "Many things about electronic devices your kids don't want you to...", "author": "Store Manager", "date": "July 19, 2026", "image_url": ""},
|
||||||
|
{"post_id": "bp4", "title": "50 Best Sales Questions to Determine Your Customer's Needs", "excerpt": "The unconventional guide to the software applications...", "author": "Support Desk", "date": "July 15, 2026", "image_url": ""},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/banners")
|
||||||
|
def get_storefront_banners(db: Session = Depends(get_db)) -> List[Dict[str, Any]]:
|
||||||
|
"""Returns hero banners for the storefront homepage (BUG-04 fix)."""
|
||||||
|
svc = StorefrontCmsService(db)
|
||||||
|
banners = svc.get_content_by_region(page="home", region="hero", content_type="hero_banner")
|
||||||
|
if banners:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"banner_id": b.content_id,
|
||||||
|
"title": b.title,
|
||||||
|
"subtitle": b.subtitle or "",
|
||||||
|
"image_url": b.image_url or "",
|
||||||
|
"button_text": b.button_text or "Shop Now",
|
||||||
|
"button_url": b.button_url or "/shop"
|
||||||
|
}
|
||||||
|
for b in banners
|
||||||
|
]
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"banner_id": "b1",
|
||||||
|
"title": "Smartphone Repair & Spare Parts",
|
||||||
|
"subtitle": "100% Genuine OEM Displays, Batteries & Accessories",
|
||||||
|
"image_url": "/assets/hero-banner-1.webp",
|
||||||
|
"button_text": "Explore Catalog",
|
||||||
|
"button_url": "/shop"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"banner_id": "b2",
|
||||||
|
"title": "Fast Doorstep Repair Service",
|
||||||
|
"subtitle": "Certified Engineers & 6-Month iFixKart Warranty",
|
||||||
|
"image_url": "/assets/hero-banner-2.webp",
|
||||||
|
"button_text": "Book Repair",
|
||||||
|
"button_url": "/services"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
166
app/api/v1/routers/UserCreationRouter.py
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import ulid
|
||||||
|
|
||||||
|
from app.core.database.db_session import get_db
|
||||||
|
from app.repositories.user_repository import user_repository
|
||||||
|
from app.models.UserModel import User
|
||||||
|
from app.models.RoleModel import Role
|
||||||
|
from app.schemas.User import UserCreate, UserUpdate, UserResponse
|
||||||
|
from app.core.permissions.RoleChecker import RoleChecker, PermissionChecker
|
||||||
|
from app.core.validators.password_validator import validate_password_complexity
|
||||||
|
from app.utils.Hash_util import hash_password
|
||||||
|
from app.services.code_generator_service import code_generator_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/users", tags=["User Creation"])
|
||||||
|
|
||||||
|
@router.post("/create", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_user(
|
||||||
|
data: UserCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("user.create"))
|
||||||
|
):
|
||||||
|
# 1. Check duplicate identity parameters
|
||||||
|
duplicate = user_repository.check_duplicate_identity(db, data.email, data.phone)
|
||||||
|
if duplicate:
|
||||||
|
raise HTTPException(status_code=400, detail="User with this email or phone number already exists.")
|
||||||
|
|
||||||
|
# 2. Check Role existence and fetch prefix
|
||||||
|
role = db.get(Role, data.role_id)
|
||||||
|
if not role or role.deleted_at is not None:
|
||||||
|
raise HTTPException(status_code=400, detail="Specified role ID is invalid or inactive.")
|
||||||
|
|
||||||
|
# 3. Validate password complexity
|
||||||
|
validate_password_complexity(data.password)
|
||||||
|
|
||||||
|
# 4. Generate sequential employee code
|
||||||
|
prefix = role.role_prefix
|
||||||
|
employee_code = code_generator_service.generate_next_code(db, "User", prefix)
|
||||||
|
|
||||||
|
# 5. Build user record
|
||||||
|
user_id = str(ulid.ULID())
|
||||||
|
new_user = User(
|
||||||
|
user_id=user_id,
|
||||||
|
employee_code=employee_code,
|
||||||
|
first_name=data.first_name,
|
||||||
|
last_name=data.last_name,
|
||||||
|
display_name=data.display_name or f"{data.first_name} {data.last_name}",
|
||||||
|
email=data.email,
|
||||||
|
phone=data.phone,
|
||||||
|
password_hash=hash_password(data.password),
|
||||||
|
gender=data.gender,
|
||||||
|
dob=data.dob,
|
||||||
|
department_id=data.department_id,
|
||||||
|
designation_id=data.designation_id,
|
||||||
|
role_id=data.role_id,
|
||||||
|
manager_id=data.manager_id,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
|
||||||
|
user_repository.create(db, new_user)
|
||||||
|
return new_user
|
||||||
|
|
||||||
|
@router.get("/all", response_model=List[UserResponse])
|
||||||
|
def get_all_users(
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("user.view"))
|
||||||
|
):
|
||||||
|
users = user_repository.get_active_users(db, skip, limit)
|
||||||
|
from app.models.AuditLogModel import AuditLog
|
||||||
|
for u in users:
|
||||||
|
latest = (
|
||||||
|
db.query(AuditLog)
|
||||||
|
.filter(AuditLog.user_id == u.user_id)
|
||||||
|
.order_by(AuditLog.created_at.desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if latest:
|
||||||
|
u.last_activity = f"{latest.action.replace('_', ' ').title()} {latest.entity_type}"
|
||||||
|
else:
|
||||||
|
u.last_activity = "No recent activity"
|
||||||
|
return users
|
||||||
|
|
||||||
|
@router.get("/profile/{user_id}", response_model=UserResponse)
|
||||||
|
def get_user_profile(
|
||||||
|
user_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("user.view"))
|
||||||
|
):
|
||||||
|
user = user_repository.get_by_id(db, user_id)
|
||||||
|
if not user or user.deleted_at is not None:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
return user
|
||||||
|
|
||||||
|
@router.put("/update/{user_id}", response_model=UserResponse)
|
||||||
|
def update_user_profile(
|
||||||
|
user_id: str,
|
||||||
|
payload: UserUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("user.update"))
|
||||||
|
):
|
||||||
|
user = user_repository.get_by_id(db, user_id)
|
||||||
|
if not user or user.deleted_at is not None:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
# Check email/phone uniqueness if being updated
|
||||||
|
if payload.email and payload.email != user.email:
|
||||||
|
if user_repository.get_by_email(db, payload.email):
|
||||||
|
raise HTTPException(status_code=400, detail="Email is already registered.")
|
||||||
|
user.email = payload.email
|
||||||
|
|
||||||
|
if payload.phone and payload.phone != user.phone:
|
||||||
|
if user_repository.get_by_phone(db, payload.phone):
|
||||||
|
raise HTTPException(status_code=400, detail="Phone number is already registered.")
|
||||||
|
user.phone = payload.phone
|
||||||
|
|
||||||
|
# Update strings
|
||||||
|
if payload.first_name:
|
||||||
|
user.first_name = payload.first_name
|
||||||
|
if payload.last_name:
|
||||||
|
user.last_name = payload.last_name
|
||||||
|
if payload.display_name:
|
||||||
|
user.display_name = payload.display_name
|
||||||
|
if payload.gender:
|
||||||
|
user.gender = payload.gender
|
||||||
|
if payload.dob:
|
||||||
|
user.dob = payload.dob
|
||||||
|
if payload.profile_image_id:
|
||||||
|
user.profile_image_id = payload.profile_image_id
|
||||||
|
|
||||||
|
# Update relationships
|
||||||
|
if payload.department_id:
|
||||||
|
user.department_id = payload.department_id
|
||||||
|
if payload.designation_id:
|
||||||
|
user.designation_id = payload.designation_id
|
||||||
|
if payload.role_id:
|
||||||
|
user.role_id = payload.role_id
|
||||||
|
if payload.manager_id:
|
||||||
|
user.manager_id = payload.manager_id
|
||||||
|
|
||||||
|
# If resetting password
|
||||||
|
if payload.password:
|
||||||
|
validate_password_complexity(payload.password)
|
||||||
|
user.password_hash = hash_password(payload.password)
|
||||||
|
|
||||||
|
user_repository.update(db, user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
@router.delete("/delete/{user_id}")
|
||||||
|
def delete_user_profile(
|
||||||
|
user_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("user.delete"))
|
||||||
|
):
|
||||||
|
user = user_repository.get_by_id(db, user_id)
|
||||||
|
if not user or user.deleted_at is not None:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
# Safe soft delete
|
||||||
|
user.deleted_at = datetime.now(timezone.utc)
|
||||||
|
user.is_active = False
|
||||||
|
db.commit()
|
||||||
|
return {"detail": "User soft deleted successfully"}
|
||||||
26
app/api/v1/routers/WishlistRouter.py
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
"""
|
||||||
|
@router WishlistRouter (Backend/app/api/v1/routers/WishlistRouter.py)
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/wishlist", tags=["Customer Wishlist"])
|
||||||
|
|
||||||
|
class WishlistAddRequest(BaseModel):
|
||||||
|
product_id: str
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def get_wishlist():
|
||||||
|
return {
|
||||||
|
"items": [
|
||||||
|
{"product_id": "prd_01J8X9A", "name": "Display Assembly OLED", "price": 149.99}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.post("/add")
|
||||||
|
def add_to_wishlist(payload: WishlistAddRequest):
|
||||||
|
return {"message": "Product added to wishlist", "product_id": payload.product_id}
|
||||||
|
|
||||||
|
@router.post("/remove")
|
||||||
|
def remove_from_wishlist(payload: WishlistAddRequest):
|
||||||
|
return {"message": "Product removed from wishlist", "product_id": payload.product_id}
|
||||||
0
app/api/v1/routers/__init__.py
Normal file
363
app/api/v1/routers/admin_storefront.py
Normal file
|
|
@ -0,0 +1,363 @@
|
||||||
|
"""
|
||||||
|
@router Admin Storefront Router (Backend/app/api/v1/routers/admin_storefront.py)
|
||||||
|
@purpose Admin CRM endpoints for creating, editing, reordering, bulk saving, and publishing StorefrontContent items.
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
import ulid
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
from app.core.database.db_session import get_db
|
||||||
|
from app.models.StorefrontContentModel import StorefrontContent, ContentStatusEnum
|
||||||
|
from app.services.StorefrontService import StorefrontService
|
||||||
|
|
||||||
|
from app.core.permissions.RoleChecker import get_current_user
|
||||||
|
from app.models.UserModel import User
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/admin/storefront", tags=["Admin Storefront CMS Management"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload-image")
|
||||||
|
async def upload_storefront_image(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Accepts an uploaded image, converts it to WebP format asynchronously,
|
||||||
|
generates optimized sizes (thumbnail, medium, large), and returns URLs.
|
||||||
|
"""
|
||||||
|
ext = os.path.splitext(file.filename or "")[1].lower()
|
||||||
|
if ext not in [".jpg", ".jpeg", ".png", ".webp"]:
|
||||||
|
raise HTTPException(status_code=400, detail="Only image files (.jpg, .jpeg, .png, .webp) are allowed.")
|
||||||
|
|
||||||
|
file_bytes = await file.read()
|
||||||
|
max_bytes = 20 * 1024 * 1024 # 20MB
|
||||||
|
if len(file_bytes) > max_bytes:
|
||||||
|
raise HTTPException(status_code=400, detail="File size exceeds the 20MB limit.")
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
BACKEND_ROOT = Path(__file__).resolve().parents[4]
|
||||||
|
storefront_uploads_dir = BACKEND_ROOT / "uploads" / "storefront"
|
||||||
|
storefront_uploads_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
base_id = str(ulid.ULID())
|
||||||
|
|
||||||
|
def process_and_save():
|
||||||
|
from PIL import Image as PILImage
|
||||||
|
import io
|
||||||
|
|
||||||
|
original_img = PILImage.open(io.BytesIO(file_bytes))
|
||||||
|
|
||||||
|
# Convert RGBA / P mode to RGB if necessary for JPEG/WEBP compatibility
|
||||||
|
if original_img.mode in ("RGBA", "P"):
|
||||||
|
original_img = original_img.convert("RGBA")
|
||||||
|
elif original_img.mode != "RGB":
|
||||||
|
original_img = original_img.convert("RGB")
|
||||||
|
|
||||||
|
# Cap max dimension to 2560px for high performance and pristine 4K quality
|
||||||
|
max_dim = 2560
|
||||||
|
if original_img.size[0] > max_dim or original_img.size[1] > max_dim:
|
||||||
|
original_img.thumbnail((max_dim, max_dim), PILImage.Resampling.BILINEAR)
|
||||||
|
|
||||||
|
# 1. Save main image as WebP
|
||||||
|
orig_io = io.BytesIO()
|
||||||
|
original_img.save(orig_io, format="WEBP", quality=88)
|
||||||
|
with open(storefront_uploads_dir / f"{base_id}.webp", "wb") as f:
|
||||||
|
f.write(orig_io.getvalue())
|
||||||
|
|
||||||
|
# Helper for resizing
|
||||||
|
def save_resized(target_width: int, suffix: str):
|
||||||
|
if original_img.size[0] > target_width:
|
||||||
|
w_percent = (target_width / float(original_img.size[0]))
|
||||||
|
h_size = int((float(original_img.size[1]) * float(w_percent)))
|
||||||
|
resized_img = original_img.resize((target_width, h_size), PILImage.Resampling.BILINEAR)
|
||||||
|
else:
|
||||||
|
resized_img = original_img
|
||||||
|
|
||||||
|
res_io = io.BytesIO()
|
||||||
|
resized_img.save(res_io, format="WEBP", quality=80)
|
||||||
|
with open(storefront_uploads_dir / f"{base_id}_{suffix}.webp", "wb") as f:
|
||||||
|
f.write(res_io.getvalue())
|
||||||
|
|
||||||
|
# 2. Save size variants
|
||||||
|
save_resized(300, "thumbnail")
|
||||||
|
save_resized(800, "medium")
|
||||||
|
save_resized(1500, "large")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await run_in_threadpool(process_and_save)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to process and compress image: {str(e)}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"image_url": f"/uploads/storefront/{base_id}.webp",
|
||||||
|
"thumbnail_url": f"/uploads/storefront/{base_id}_thumbnail.webp",
|
||||||
|
"medium_url": f"/uploads/storefront/{base_id}_medium.webp",
|
||||||
|
"large_url": f"/uploads/storefront/{base_id}_large.webp"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class ContentCreatePayload(BaseModel):
|
||||||
|
|
||||||
|
content_id: Optional[str] = None
|
||||||
|
page: str = "home"
|
||||||
|
region: str = "hero"
|
||||||
|
type: str = "hero_banner"
|
||||||
|
title: str
|
||||||
|
subtitle: Optional[str] = None
|
||||||
|
image_url: Optional[str] = None
|
||||||
|
button_text: Optional[str] = None
|
||||||
|
button_url: Optional[str] = None
|
||||||
|
display_order: int = 0
|
||||||
|
metadata_json: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
class BulkSavePayload(BaseModel):
|
||||||
|
items: List[ContentCreatePayload]
|
||||||
|
|
||||||
|
@router.get("/content/all")
|
||||||
|
def get_all_cms_content(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||||
|
contents = db.query(StorefrontContent).order_by(StorefrontContent.display_order.asc()).all()
|
||||||
|
return contents
|
||||||
|
|
||||||
|
@router.post("/content/create", status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_cms_content(data: ContentCreatePayload, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||||
|
|
||||||
|
content_id = data.content_id or str(ulid.ULID())
|
||||||
|
slug = f"{data.page}-{data.region}-{data.type}-{content_id[:8]}".lower()
|
||||||
|
|
||||||
|
existing = db.query(StorefrontContent).filter(StorefrontContent.content_id == content_id).first()
|
||||||
|
if existing:
|
||||||
|
existing.page = data.page
|
||||||
|
existing.region = data.region
|
||||||
|
existing.type = data.type
|
||||||
|
existing.title = data.title
|
||||||
|
existing.subtitle = data.subtitle
|
||||||
|
existing.image_url = data.image_url
|
||||||
|
existing.button_text = data.button_text
|
||||||
|
existing.button_url = data.button_url
|
||||||
|
existing.display_order = data.display_order
|
||||||
|
existing.metadata_json = data.metadata_json or {}
|
||||||
|
db.commit()
|
||||||
|
db.refresh(existing)
|
||||||
|
content = existing
|
||||||
|
else:
|
||||||
|
content = StorefrontContent(
|
||||||
|
content_id=content_id,
|
||||||
|
page=data.page,
|
||||||
|
region=data.region,
|
||||||
|
type=data.type,
|
||||||
|
slug=slug,
|
||||||
|
title=data.title,
|
||||||
|
subtitle=data.subtitle,
|
||||||
|
image_url=data.image_url,
|
||||||
|
button_text=data.button_text,
|
||||||
|
button_url=data.button_url,
|
||||||
|
status=ContentStatusEnum.PUBLISHED,
|
||||||
|
display_order=data.display_order,
|
||||||
|
metadata_json=data.metadata_json or {}
|
||||||
|
)
|
||||||
|
db.add(content)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(content)
|
||||||
|
|
||||||
|
# Invalidate storefront cache for instant live update
|
||||||
|
service = StorefrontService(db)
|
||||||
|
service.invalidate_cache()
|
||||||
|
|
||||||
|
return content
|
||||||
|
|
||||||
|
@router.post("/content/bulk-save")
|
||||||
|
def bulk_save_cms_content(payload: BulkSavePayload, db: Session = Depends(get_db)):
|
||||||
|
saved_ids = []
|
||||||
|
for data in payload.items:
|
||||||
|
content_id = data.content_id or str(ulid.ULID())
|
||||||
|
saved_ids.append(content_id)
|
||||||
|
slug = f"{data.page}-{data.region}-{data.type}-{content_id[:8]}".lower()
|
||||||
|
|
||||||
|
existing = db.query(StorefrontContent).filter(StorefrontContent.content_id == content_id).first()
|
||||||
|
if existing:
|
||||||
|
existing.page = data.page
|
||||||
|
existing.region = data.region
|
||||||
|
existing.type = data.type
|
||||||
|
existing.title = data.title
|
||||||
|
existing.subtitle = data.subtitle
|
||||||
|
existing.image_url = data.image_url
|
||||||
|
existing.button_text = data.button_text
|
||||||
|
existing.button_url = data.button_url
|
||||||
|
existing.display_order = data.display_order
|
||||||
|
existing.status = ContentStatusEnum.PUBLISHED
|
||||||
|
existing.metadata_json = data.metadata_json or {}
|
||||||
|
else:
|
||||||
|
content = StorefrontContent(
|
||||||
|
content_id=content_id,
|
||||||
|
page=data.page,
|
||||||
|
region=data.region,
|
||||||
|
type=data.type,
|
||||||
|
slug=slug,
|
||||||
|
title=data.title,
|
||||||
|
subtitle=data.subtitle,
|
||||||
|
image_url=data.image_url,
|
||||||
|
button_text=data.button_text,
|
||||||
|
button_url=data.button_url,
|
||||||
|
status=ContentStatusEnum.PUBLISHED,
|
||||||
|
display_order=data.display_order,
|
||||||
|
metadata_json=data.metadata_json or {}
|
||||||
|
)
|
||||||
|
db.add(content)
|
||||||
|
|
||||||
|
# Clean up duplicate/obsolete records for the saved regions
|
||||||
|
saved_regions = list(set([data.region for data in payload.items]))
|
||||||
|
if saved_regions:
|
||||||
|
db.query(StorefrontContent).filter(
|
||||||
|
StorefrontContent.region.in_(saved_regions),
|
||||||
|
~StorefrontContent.content_id.in_(saved_ids)
|
||||||
|
).delete(synchronize_session=False)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Invalidate cache
|
||||||
|
service = StorefrontService(db)
|
||||||
|
service.invalidate_cache()
|
||||||
|
|
||||||
|
return {"message": "Bulk CMS content saved and storefront cache invalidated", "count": len(payload.items)}
|
||||||
|
|
||||||
|
@router.delete("/content/{content_id}")
|
||||||
|
def delete_cms_content(content_id: str, db: Session = Depends(get_db)):
|
||||||
|
item = db.query(StorefrontContent).filter(StorefrontContent.content_id == content_id).first()
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="Content not found")
|
||||||
|
|
||||||
|
db.delete(item)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
service = StorefrontService(db)
|
||||||
|
service.invalidate_cache()
|
||||||
|
|
||||||
|
return {"message": "CMS content deleted", "content_id": content_id}
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# NEW: Storefront CMS Management Endpoints
|
||||||
|
# ==========================================================================
|
||||||
|
from app.services.StorefrontCmsService import StorefrontCmsService
|
||||||
|
from app.schemas.StorefrontCmsSchema import (
|
||||||
|
FooterInfoUpdate,
|
||||||
|
StorefrontSettingsUpdate,
|
||||||
|
MegaMenuUpdate,
|
||||||
|
CatalogFiltersUpdate,
|
||||||
|
CategoryCmsUpdate,
|
||||||
|
ReviewApprovalPayload,
|
||||||
|
)
|
||||||
|
from typing import Any as _Any
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/cms/footer-info")
|
||||||
|
def admin_get_footer_info(db: Session = Depends(get_db)):
|
||||||
|
"""Admin: Fetch current footer info."""
|
||||||
|
return StorefrontCmsService(db).get_footer_info()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/cms/footer-info")
|
||||||
|
def admin_update_footer_info(
|
||||||
|
payload: FooterInfoUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Admin: Update footer contact info, social links, navigation columns, payment method icons."""
|
||||||
|
return StorefrontCmsService(db).update_footer_info(payload, updated_by=current_user.user_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/cms/settings")
|
||||||
|
def admin_get_settings(db: Session = Depends(get_db)):
|
||||||
|
"""Admin: Fetch all storefront settings (branding, advance_percent, etc.)."""
|
||||||
|
return StorefrontCmsService(db).get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/cms/settings")
|
||||||
|
def admin_update_settings(
|
||||||
|
payload: StorefrontSettingsUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Admin: Update store branding and repair advance percentage."""
|
||||||
|
return StorefrontCmsService(db).update_settings(payload, updated_by=current_user.user_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/cms/mega-menu")
|
||||||
|
def admin_get_mega_menu(db: Session = Depends(get_db)):
|
||||||
|
"""Admin: Fetch all three mega menu configs (shop, deals, products)."""
|
||||||
|
return StorefrontCmsService(db).get_mega_menu()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/cms/mega-menu")
|
||||||
|
def admin_update_mega_menu(
|
||||||
|
payload: MegaMenuUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Admin: Update a single nav menu (shop | deals | products)."""
|
||||||
|
return StorefrontCmsService(db).update_mega_menu(payload, updated_by=current_user.user_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/cms/catalog-filters")
|
||||||
|
def admin_get_catalog_filters(db: Session = Depends(get_db)):
|
||||||
|
"""Admin: Fetch catalog filter configuration."""
|
||||||
|
return StorefrontCmsService(db).get_catalog_filters()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/cms/catalog-filters")
|
||||||
|
def admin_update_catalog_filters(
|
||||||
|
payload: CatalogFiltersUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Admin: Update highlight tabs and price range buckets."""
|
||||||
|
return StorefrontCmsService(db).update_catalog_filters(payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/cms/categories/{category_id}")
|
||||||
|
def admin_update_category_cms(
|
||||||
|
category_id: str,
|
||||||
|
payload: CategoryCmsUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Admin: Update CMS display metadata for a category (sidebar, mega_group, badge, etc.)."""
|
||||||
|
return StorefrontCmsService(db).update_category_cms(category_id, payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/cms/reviews")
|
||||||
|
def admin_list_reviews(
|
||||||
|
approved: Optional[bool] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Admin: List all product reviews for moderation, optionally filtered by approval status."""
|
||||||
|
return StorefrontCmsService(db).list_reviews_for_moderation(approved)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/cms/reviews/{review_id}/approve")
|
||||||
|
def admin_approve_review(
|
||||||
|
review_id: str,
|
||||||
|
payload: ReviewApprovalPayload = ReviewApprovalPayload(),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Admin: Approve a product review and optionally attach an admin reply."""
|
||||||
|
return StorefrontCmsService(db).approve_review(review_id, payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/cms/reviews/{review_id}/reject")
|
||||||
|
def admin_reject_review(
|
||||||
|
review_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Admin: Reject (unpublish) a product review."""
|
||||||
|
return StorefrontCmsService(db).reject_review(review_id)
|
||||||
|
|
||||||
226
app/api/v1/routers/storefront.py
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
"""
|
||||||
|
@router Public Storefront Router (Backend/app/api/v1/routers/storefront.py)
|
||||||
|
@purpose Read-only public endpoints for layout widgets, reviews, and settings powered by StorefrontService.
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, Query, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session, selectinload
|
||||||
|
from sqlalchemy import select
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
|
||||||
|
from app.core.database.db_session import get_db
|
||||||
|
from app.services.StorefrontService import StorefrontService
|
||||||
|
from app.services.CatalogSearchService import apply_product_search
|
||||||
|
from app.models.ProductModel import Product, ProductVariant
|
||||||
|
from app.models.CategoryModel import Category
|
||||||
|
from app.models.BrandModel import Brand
|
||||||
|
from app.models.DeviceCatalogModel import ServiceType
|
||||||
|
from app.schemas.Catalog import ProductResponse
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/storefront", tags=["Public Storefront Dynamic Services"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/live-search")
|
||||||
|
def live_search(
|
||||||
|
q: str = Query(..., min_length=2, max_length=200),
|
||||||
|
category: Optional[str] = Query(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Autocomplete search for the storefront header. Caps results so 500k catalogs stay cheap.
|
||||||
|
"""
|
||||||
|
from app.core.database.cache_manager import cache
|
||||||
|
|
||||||
|
cache_key = f"storefront:live-search:{q.strip().lower()}:{category or 'all'}"
|
||||||
|
cached = cache.get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(Product)
|
||||||
|
.options(
|
||||||
|
selectinload(Product.images),
|
||||||
|
selectinload(Product.variants).selectinload(ProductVariant.images),
|
||||||
|
)
|
||||||
|
.where(Product.status == "active")
|
||||||
|
)
|
||||||
|
if category and category != "all":
|
||||||
|
stmt = stmt.join(Category, Product.category_id == Category.category_id).where(
|
||||||
|
(Category.slug == category) | (Category.category_id == category)
|
||||||
|
)
|
||||||
|
stmt = apply_product_search(stmt, q).order_by(Product.created_at.desc()).limit(8)
|
||||||
|
products = db.execute(stmt).scalars().all()
|
||||||
|
|
||||||
|
term = f"%{q.strip()}%"
|
||||||
|
categories = db.execute(
|
||||||
|
select(Category).where(Category.is_active == True, Category.name.ilike(term)).limit(5)
|
||||||
|
).scalars().all()
|
||||||
|
brands = db.execute(
|
||||||
|
select(Brand).where(Brand.is_active == True, Brand.name.ilike(term)).limit(5)
|
||||||
|
).scalars().all()
|
||||||
|
services = db.execute(
|
||||||
|
select(ServiceType).where(ServiceType.is_active == True, ServiceType.name.ilike(term)).limit(5)
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"products": [ProductResponse.model_validate(p).model_dump(mode="json") for p in products],
|
||||||
|
"categories": [
|
||||||
|
{
|
||||||
|
"category_id": c.category_id,
|
||||||
|
"name": c.name,
|
||||||
|
"slug": c.slug,
|
||||||
|
"parent_category_id": c.parent_category_id,
|
||||||
|
"description": c.description,
|
||||||
|
"image_url": c.image_url,
|
||||||
|
"is_active": c.is_active,
|
||||||
|
}
|
||||||
|
for c in categories
|
||||||
|
],
|
||||||
|
"brands": [
|
||||||
|
{
|
||||||
|
"brand_id": b.brand_id,
|
||||||
|
"name": b.name,
|
||||||
|
"slug": b.slug,
|
||||||
|
"logo_url": b.logo_url,
|
||||||
|
"is_active": b.is_active,
|
||||||
|
}
|
||||||
|
for b in brands
|
||||||
|
],
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"service_type_id": s.service_type_id if hasattr(s, "service_type_id") else getattr(s, "type_id", None),
|
||||||
|
"name": s.name,
|
||||||
|
"slug": getattr(s, "slug", None),
|
||||||
|
}
|
||||||
|
for s in services
|
||||||
|
],
|
||||||
|
}
|
||||||
|
cache.set(cache_key, payload, ttl_seconds=15)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/layout/{page}")
|
||||||
|
def get_storefront_layout(page: str, region: Optional[str] = Query(None), db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Returns active storefront layout widgets ordered by display_order.
|
||||||
|
"""
|
||||||
|
service = StorefrontService(db)
|
||||||
|
return service.get_layout(page, region)
|
||||||
|
|
||||||
|
@router.get("/reviews/{product_id}")
|
||||||
|
def get_product_reviews(product_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Returns approved customer reviews for a given product.
|
||||||
|
"""
|
||||||
|
service = StorefrontService(db)
|
||||||
|
return service.get_reviews(product_id)
|
||||||
|
|
||||||
|
@router.get("/settings/public")
|
||||||
|
def get_public_settings(group: Optional[str] = Query(None), db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Returns public key-value store settings.
|
||||||
|
"""
|
||||||
|
service = StorefrontService(db)
|
||||||
|
return service.get_public_settings(group)
|
||||||
|
|
||||||
|
@router.get("/catalog/categories/{category_id}/parent-hierarchy")
|
||||||
|
def get_category_parent_hierarchy(category_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Returns the product-derived Brand → Series → Model hierarchy for a category.
|
||||||
|
Only includes brands/series/models that have active products in this category.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import select, distinct
|
||||||
|
from app.models.CategoryModel import Category
|
||||||
|
from app.models.ProductModel import Product
|
||||||
|
from app.models.BrandModel import Brand
|
||||||
|
from app.models.DeviceCatalogModel import DeviceSeries, DeviceModel
|
||||||
|
from app.schemas.Catalog import (
|
||||||
|
CategoryParentHierarchyResponse, HierarchyBrandItem,
|
||||||
|
HierarchySeriesItem, HierarchyModelItem,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify category exists
|
||||||
|
category = db.execute(
|
||||||
|
select(Category).where(Category.category_id == category_id)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if not category:
|
||||||
|
raise HTTPException(status_code=404, detail="Category not found")
|
||||||
|
|
||||||
|
# Fetch all active products in this category that have brand + series + model set
|
||||||
|
rows = db.execute(
|
||||||
|
select(
|
||||||
|
Brand.brand_id, Brand.name, Brand.slug, Brand.logo_url,
|
||||||
|
DeviceSeries.series_id, DeviceSeries.name.label("series_name"), DeviceSeries.slug.label("series_slug"),
|
||||||
|
DeviceModel.model_id, DeviceModel.name.label("model_name"), DeviceModel.slug.label("model_slug"), DeviceModel.image_url.label("model_image")
|
||||||
|
)
|
||||||
|
.select_from(Product)
|
||||||
|
.join(Brand, Product.brand_id == Brand.brand_id)
|
||||||
|
.outerjoin(DeviceModel, Product.device_model_id == DeviceModel.model_id)
|
||||||
|
.outerjoin(DeviceSeries, DeviceModel.series_id == DeviceSeries.series_id)
|
||||||
|
.where(
|
||||||
|
Product.category_id == category_id,
|
||||||
|
Product.status == "active",
|
||||||
|
Brand.is_active == True,
|
||||||
|
(DeviceSeries.series_id == None) | (DeviceSeries.is_active == True),
|
||||||
|
(DeviceModel.model_id == None) | (DeviceModel.is_active == True),
|
||||||
|
)
|
||||||
|
.distinct()
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Build nested hierarchy in memory
|
||||||
|
brands_map: dict = {}
|
||||||
|
for row in rows:
|
||||||
|
bid = row.brand_id
|
||||||
|
if bid not in brands_map:
|
||||||
|
brands_map[bid] = {
|
||||||
|
"brand_id": bid,
|
||||||
|
"name": row.name,
|
||||||
|
"slug": row.slug,
|
||||||
|
"logo_url": row.logo_url,
|
||||||
|
"series": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
sid = row.series_id
|
||||||
|
series_name = row.series_name
|
||||||
|
series_slug = row.series_slug
|
||||||
|
|
||||||
|
# If a model exists but series is missing, group it under a virtual "General Models" series
|
||||||
|
if not sid and row.model_id:
|
||||||
|
sid = f"no-series-{bid}"
|
||||||
|
series_name = "General Models"
|
||||||
|
series_slug = "general"
|
||||||
|
|
||||||
|
if sid:
|
||||||
|
if sid not in brands_map[bid]["series"]:
|
||||||
|
brands_map[bid]["series"][sid] = {
|
||||||
|
"series_id": sid,
|
||||||
|
"name": series_name,
|
||||||
|
"slug": series_slug,
|
||||||
|
"models": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
mid = row.model_id
|
||||||
|
if mid:
|
||||||
|
if mid not in brands_map[bid]["series"][sid]["models"]:
|
||||||
|
brands_map[bid]["series"][sid]["models"][mid] = {
|
||||||
|
"model_id": mid,
|
||||||
|
"name": row.model_name,
|
||||||
|
"slug": row.model_slug,
|
||||||
|
"image_url": row.model_image,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Convert maps to sorted lists
|
||||||
|
brands_list = []
|
||||||
|
for b in brands_map.values():
|
||||||
|
series_list = []
|
||||||
|
for s in b["series"].values():
|
||||||
|
models_list = list(s["models"].values())
|
||||||
|
series_list.append(HierarchySeriesItem(
|
||||||
|
series_id=s["series_id"], name=s["name"], slug=s["slug"],
|
||||||
|
models=[HierarchyModelItem(**m) for m in models_list]
|
||||||
|
))
|
||||||
|
brands_list.append(HierarchyBrandItem(
|
||||||
|
brand_id=b["brand_id"], name=b["name"], slug=b["slug"], logo_url=b["logo_url"],
|
||||||
|
series=series_list
|
||||||
|
))
|
||||||
|
|
||||||
|
return CategoryParentHierarchyResponse(category_id=category_id, brands=brands_list)
|
||||||
5
app/core/Exception.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
class AppException(Exception):
|
||||||
|
def __init__(self, detail: str, status_code: int = 400):
|
||||||
|
self.detail = detail
|
||||||
|
self.status_code = status_code
|
||||||
|
super().__init__(self.detail)
|
||||||
35
app/core/Token.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from jose import jwt, JWTError, ExpiredSignatureError
|
||||||
|
from app.core.config.Config import settings, load_private_key_bytes, load_public_key_bytes
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
|
def create_access_token(user_id: str, email: str, role: str = "") -> str:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
expire = now + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MIN)
|
||||||
|
payload = {
|
||||||
|
"sub": user_id,
|
||||||
|
"email": email,
|
||||||
|
"role": role,
|
||||||
|
"iat": now,
|
||||||
|
"exp": expire
|
||||||
|
}
|
||||||
|
private_key = load_private_key_bytes()
|
||||||
|
return jwt.encode(payload, private_key, algorithm=settings.ALGORITHM)
|
||||||
|
|
||||||
|
def verify_access_token(token: str) -> dict:
|
||||||
|
try:
|
||||||
|
public_key = load_public_key_bytes()
|
||||||
|
payload = jwt.decode(token, public_key, algorithms=[settings.ALGORITHM])
|
||||||
|
return payload
|
||||||
|
except ExpiredSignatureError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Token has expired",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"}
|
||||||
|
)
|
||||||
|
except JWTError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid signature or payload",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"}
|
||||||
|
)
|
||||||
0
app/core/__init__.py
Normal file
55
app/core/config/Config.py
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
RUNTIME_ENV: str = "development"
|
||||||
|
ENVIRONMENT: str = "development"
|
||||||
|
DATABASE_URL: str = "mysql+pymysql://Adithiyan:Adithiyan$2025Mysql@127.0.0.1:3306/ifixkartecommerceadmin"
|
||||||
|
CORE_DATABASE_URL: str = "mysql+pymysql://Adithiyan:Adithiyan$2025Mysql@127.0.0.1:3306/ifixkartecommerceadmin"
|
||||||
|
CRM_DATABASE_URL: str = "mysql+pymysql://Adithiyan:Adithiyan$2025Mysql@127.0.0.1:3306/ifixkartcrm"
|
||||||
|
COMMERCE_DATABASE_URL: str = "mysql+pymysql://Adithiyan:Adithiyan$2025Mysql@127.0.0.1:3306/ifixkartecommerce"
|
||||||
|
JWT_PRIVATE_KEY_PATH: str = "jwt_private.pem"
|
||||||
|
JWT_PUBLIC_KEY_PATH: str = "jwt_public.pem"
|
||||||
|
ALGORITHM: str = "RS512"
|
||||||
|
ACCESS_TOKEN_EXPIRE_MIN: int = 1440
|
||||||
|
REFRESH_TOKEN_EXPIRE_DAYS: int = 120
|
||||||
|
PROJECT_NAME: str = "iFixKart"
|
||||||
|
MAX_FAILED_LOGIN: int = 5
|
||||||
|
LOCKOUT_MINUTES: int = 15
|
||||||
|
SECRET_KEY: str
|
||||||
|
PUBLIC_API_KEY: str
|
||||||
|
GOOGLE_CLIENT_ID: str = ""
|
||||||
|
GOOGLE_CLIENT_SECRET: str = ""
|
||||||
|
GOOGLE_REDIRECT_URI: str = ""
|
||||||
|
RAZORPAY_KEY_ID: str = "rzp_test_TTUzPFYF0hRV89"
|
||||||
|
RAZORPAY_KEY_SECRET: str = "74tmlSkS4qK7zQaH1zclQfeH"
|
||||||
|
RAZORPAY_WEBHOOK_SECRET: str = "6TjjXgErPG3@ZpM"
|
||||||
|
RAZORPAY_ENABLED: bool = True
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = ".env"
|
||||||
|
extra = "ignore"
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
|
||||||
|
def load_private_key_bytes(path=None):
|
||||||
|
p = Path(path or settings.JWT_PRIVATE_KEY_PATH)
|
||||||
|
if not p.is_absolute():
|
||||||
|
# resolve relative to project root (parent of app directory)
|
||||||
|
project_root = Path(__file__).resolve().parents[3]
|
||||||
|
p = project_root / p
|
||||||
|
if not p.exists():
|
||||||
|
raise FileNotFoundError(f"Private key not found at {p}")
|
||||||
|
return p.read_bytes()
|
||||||
|
|
||||||
|
def load_public_key_bytes(path=None):
|
||||||
|
p = Path(path or settings.JWT_PUBLIC_KEY_PATH)
|
||||||
|
if not p.is_absolute():
|
||||||
|
# resolve relative to project root (parent of app directory)
|
||||||
|
project_root = Path(__file__).resolve().parents[3]
|
||||||
|
p = project_root / p
|
||||||
|
if not p.exists():
|
||||||
|
raise FileNotFoundError(f"Public key not found at {p}")
|
||||||
|
return p.read_bytes()
|
||||||
0
app/core/config/__init__.py
Normal file
0
app/core/database/__init__.py
Normal file
121
app/core/database/cache_manager.py
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger("ifixkart.cache")
|
||||||
|
|
||||||
|
class MemoryCache:
|
||||||
|
"""Thread-safe in-process memory cache with TTL (Time To Live)."""
|
||||||
|
def __init__(self):
|
||||||
|
self._cache: Dict[str, Dict[str, Any]] = {}
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def get(self, key: str) -> Optional[Any]:
|
||||||
|
with self._lock:
|
||||||
|
item = self._cache.get(key)
|
||||||
|
if not item:
|
||||||
|
return None
|
||||||
|
if item["expire_at"] is not None and time.time() > item["expire_at"]:
|
||||||
|
del self._cache[key]
|
||||||
|
return None
|
||||||
|
return item["value"]
|
||||||
|
|
||||||
|
def set(self, key: str, value: Any, ttl_seconds: Optional[int] = None) -> None:
|
||||||
|
expire_at = (time.time() + ttl_seconds) if ttl_seconds is not None else None
|
||||||
|
with self._lock:
|
||||||
|
self._cache[key] = {
|
||||||
|
"value": value,
|
||||||
|
"expire_at": expire_at
|
||||||
|
}
|
||||||
|
|
||||||
|
def delete(self, key: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
if key in self._cache:
|
||||||
|
del self._cache[key]
|
||||||
|
|
||||||
|
def invalidate_prefix(self, prefix: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
keys_to_del = [k for k in self._cache.keys() if k.startswith(prefix)]
|
||||||
|
for k in keys_to_del:
|
||||||
|
del self._cache[k]
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._cache.clear()
|
||||||
|
|
||||||
|
class CacheManager:
|
||||||
|
"""Consolidated cache interface with Redis connection fallback."""
|
||||||
|
def __init__(self):
|
||||||
|
self.memory_cache = MemoryCache()
|
||||||
|
self.redis_client = None
|
||||||
|
self._try_init_redis()
|
||||||
|
|
||||||
|
def _try_init_redis(self):
|
||||||
|
try:
|
||||||
|
import redis
|
||||||
|
import os
|
||||||
|
redis_host = os.getenv("REDIS_HOST", "127.0.0.1")
|
||||||
|
redis_port = int(os.getenv("REDIS_PORT", 6379))
|
||||||
|
self.redis_client = redis.Redis(
|
||||||
|
host=redis_host,
|
||||||
|
port=redis_port,
|
||||||
|
db=0,
|
||||||
|
socket_connect_timeout=1,
|
||||||
|
decode_responses=False
|
||||||
|
)
|
||||||
|
self.redis_client.ping()
|
||||||
|
logger.info("CacheManager: Redis cache connected successfully.")
|
||||||
|
except Exception:
|
||||||
|
self.redis_client = None
|
||||||
|
logger.warning("CacheManager: Redis is offline or not installed. Falling back to In-Memory cache.")
|
||||||
|
|
||||||
|
def get(self, key: str) -> Optional[Any]:
|
||||||
|
if self.redis_client:
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
data = self.redis_client.get(key)
|
||||||
|
if data:
|
||||||
|
return json.loads(data.decode("utf-8"))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Redis get failed: {e}")
|
||||||
|
return self.memory_cache.get(key)
|
||||||
|
|
||||||
|
def set(self, key: str, value: Any, ttl_seconds: Optional[int] = None) -> None:
|
||||||
|
if self.redis_client:
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
serialized = json.dumps(value)
|
||||||
|
self.redis_client.set(key, serialized, ex=ttl_seconds)
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Redis set failed: {e}")
|
||||||
|
self.memory_cache.set(key, value, ttl_seconds)
|
||||||
|
|
||||||
|
def delete(self, key: str) -> None:
|
||||||
|
if self.redis_client:
|
||||||
|
try:
|
||||||
|
self.redis_client.delete(key)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Redis delete failed: {e}")
|
||||||
|
self.memory_cache.delete(key)
|
||||||
|
|
||||||
|
def invalidate_prefix(self, prefix: str) -> None:
|
||||||
|
if self.redis_client:
|
||||||
|
try:
|
||||||
|
keys = self.redis_client.keys(f"{prefix}*")
|
||||||
|
if keys:
|
||||||
|
self.redis_client.delete(*keys)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Redis prefix invalidation failed: {e}")
|
||||||
|
self.memory_cache.invalidate_prefix(prefix)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
if self.redis_client:
|
||||||
|
try:
|
||||||
|
self.redis_client.flushdb()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Redis flushdb failed: {e}")
|
||||||
|
self.memory_cache.clear()
|
||||||
|
|
||||||
|
cache = CacheManager()
|
||||||
295
app/core/database/db_session.py
Normal file
|
|
@ -0,0 +1,295 @@
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker, declarative_base, Session
|
||||||
|
from app.core.config.Config import settings
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
# 1. Fallback connection URLs
|
||||||
|
core_url = settings.CORE_DATABASE_URL or settings.DATABASE_URL
|
||||||
|
crm_url = settings.CRM_DATABASE_URL or settings.DATABASE_URL
|
||||||
|
commerce_url = settings.COMMERCE_DATABASE_URL or settings.DATABASE_URL
|
||||||
|
|
||||||
|
# 2. Engines configuration with connection pooling
|
||||||
|
engine_core = create_engine(
|
||||||
|
core_url,
|
||||||
|
future=True,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
connect_args={"init_command": "SET time_zone='+00:00'"}
|
||||||
|
)
|
||||||
|
|
||||||
|
engine_crm = create_engine(
|
||||||
|
crm_url,
|
||||||
|
future=True,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
connect_args={"init_command": "SET time_zone='+00:00'"}
|
||||||
|
)
|
||||||
|
|
||||||
|
engine_commerce = create_engine(
|
||||||
|
commerce_url,
|
||||||
|
future=True,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
connect_args={"init_command": "SET time_zone='+00:00'"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Maintain default engine alias for backwards compatibility
|
||||||
|
engine = engine_core
|
||||||
|
|
||||||
|
# 3. Dynamic Routing Session
|
||||||
|
class RoutingSession(Session):
|
||||||
|
def get_bind(self, mapper=None, clause=None):
|
||||||
|
table_name = None
|
||||||
|
if mapper:
|
||||||
|
table_name = getattr(mapper.persist_selectable, "name", None)
|
||||||
|
elif clause is not None:
|
||||||
|
if hasattr(clause, "table"):
|
||||||
|
table_name = getattr(clause.table, "name", None)
|
||||||
|
elif hasattr(clause, "froms") and clause.froms:
|
||||||
|
table_name = getattr(clause.froms[0], "name", None)
|
||||||
|
|
||||||
|
if table_name:
|
||||||
|
# CRM Workshop database routing
|
||||||
|
if table_name in [
|
||||||
|
"service_types", "repair_services",
|
||||||
|
"repair_variants", "repair_variant_images", "parts", "part_device_compatibility",
|
||||||
|
"repair_variant_parts", "stock_movements", "purchase_orders", "purchase_order_items",
|
||||||
|
"contacts", "contact_addresses"
|
||||||
|
]:
|
||||||
|
return engine_crm
|
||||||
|
|
||||||
|
# Core Identity & Platform database routing
|
||||||
|
if table_name in [
|
||||||
|
"departments", "designations", "roles", "permissions",
|
||||||
|
"users", "user_sessions", "audit_logs",
|
||||||
|
"countries", "states", "cities", "settings", "file_uploads",
|
||||||
|
"role_permissions"
|
||||||
|
]:
|
||||||
|
return engine_core
|
||||||
|
|
||||||
|
# Storefront Commerce database routing
|
||||||
|
if table_name in [
|
||||||
|
"device_series", "device_models",
|
||||||
|
"products", "product_variants", "product_images", "variant_attributes",
|
||||||
|
"variant_images", "attribute_types", "categories", "brands", "tags",
|
||||||
|
"collections", "storefront_contents", "product_reviews", "product_review_images",
|
||||||
|
"seo_metadata", "migration_jobs", "migration_batches", "migration_errors",
|
||||||
|
"migration_snapshots", "migration_job_checkpoints", "migration_media_items",
|
||||||
|
"media_groups", "media_library", "mapping_configs"
|
||||||
|
]:
|
||||||
|
return engine_commerce
|
||||||
|
|
||||||
|
# Default engine fallback
|
||||||
|
return engine_commerce
|
||||||
|
|
||||||
|
SessionLocal = sessionmaker(
|
||||||
|
class_=RoutingSession,
|
||||||
|
autocommit=False,
|
||||||
|
autoflush=False
|
||||||
|
)
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
def get_db(request: Request):
|
||||||
|
db = SessionLocal()
|
||||||
|
|
||||||
|
# Resolve Request ID
|
||||||
|
req_id = getattr(request.state, "request_id", None)
|
||||||
|
if not req_id:
|
||||||
|
import ulid
|
||||||
|
req_id = str(ulid.ULID())
|
||||||
|
request.state.request_id = req_id
|
||||||
|
|
||||||
|
db.info["request_id"] = req_id
|
||||||
|
db.info["user_id"] = None
|
||||||
|
|
||||||
|
# Resolve IP Address
|
||||||
|
x_forwarded_for = request.headers.get("x-forwarded-for")
|
||||||
|
db.info["ip_address"] = x_forwarded_for.split(",")[0].strip() if x_forwarded_for else (request.client.host if request.client else "127.0.0.1")
|
||||||
|
|
||||||
|
# Resolve User Agent
|
||||||
|
db.info["user_agent"] = request.headers.get("user-agent", "")
|
||||||
|
|
||||||
|
# Save session reference in request state so auth logic can inject user_id back to it
|
||||||
|
request.state.db_session = db
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
# --- Automated Audit Log Event Listener ---
|
||||||
|
from sqlalchemy import event
|
||||||
|
|
||||||
|
@event.listens_for(SessionLocal, "before_flush")
|
||||||
|
def receive_before_flush(session, flush_context, instances):
|
||||||
|
req_id = session.info.get("request_id")
|
||||||
|
if not req_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
from app.models.AuditLogModel import AuditLog
|
||||||
|
import json
|
||||||
|
import ulid
|
||||||
|
|
||||||
|
def serialize_val(val):
|
||||||
|
if val is None:
|
||||||
|
return None
|
||||||
|
if hasattr(val, "isoformat"):
|
||||||
|
return val.isoformat()
|
||||||
|
if hasattr(val, "to_eng_string"):
|
||||||
|
return str(val)
|
||||||
|
if isinstance(val, (dict, list)):
|
||||||
|
return val
|
||||||
|
try:
|
||||||
|
json.dumps(val)
|
||||||
|
return val
|
||||||
|
except Exception:
|
||||||
|
return str(val)
|
||||||
|
|
||||||
|
def get_model_dict(obj):
|
||||||
|
mapper = obj.__class__.__mapper__
|
||||||
|
data = {}
|
||||||
|
for col in mapper.column_attrs:
|
||||||
|
val = getattr(obj, col.key)
|
||||||
|
data[col.key] = serialize_val(val)
|
||||||
|
return data
|
||||||
|
|
||||||
|
logs_to_add = []
|
||||||
|
|
||||||
|
# 1. New objects (Insert)
|
||||||
|
for obj in session.new:
|
||||||
|
if isinstance(obj, AuditLog):
|
||||||
|
continue
|
||||||
|
entity_type = obj.__class__.__name__
|
||||||
|
mapper = obj.__class__.__mapper__
|
||||||
|
pk_keys = [col.key for col in mapper.primary_key]
|
||||||
|
entity_id = "-".join([str(getattr(obj, k)) for k in pk_keys]) if pk_keys else "transient"
|
||||||
|
if not entity_id or entity_id == "None" or entity_id == "transient":
|
||||||
|
# Primary Key might not be flushed yet. Resolve via common ID attributes:
|
||||||
|
if hasattr(obj, "user_id") and obj.user_id:
|
||||||
|
entity_id = str(obj.user_id)
|
||||||
|
elif hasattr(obj, "product_id") and obj.product_id:
|
||||||
|
entity_id = str(obj.product_id)
|
||||||
|
elif hasattr(obj, "category_id") and obj.category_id:
|
||||||
|
entity_id = str(obj.category_id)
|
||||||
|
elif hasattr(obj, "brand_id") and obj.brand_id:
|
||||||
|
entity_id = str(obj.brand_id)
|
||||||
|
elif hasattr(obj, "order_id") and obj.order_id:
|
||||||
|
entity_id = str(obj.order_id)
|
||||||
|
else:
|
||||||
|
entity_id = "transient"
|
||||||
|
|
||||||
|
action = "create"
|
||||||
|
if entity_type == "UserSession":
|
||||||
|
action = "login"
|
||||||
|
|
||||||
|
new_val = get_model_dict(obj)
|
||||||
|
|
||||||
|
log_entry = AuditLog(
|
||||||
|
audit_id=str(ulid.ULID()),
|
||||||
|
request_id=req_id,
|
||||||
|
user_id=session.info.get("user_id"),
|
||||||
|
entity_type=entity_type,
|
||||||
|
entity_id=entity_id,
|
||||||
|
action=action,
|
||||||
|
old_value=None,
|
||||||
|
new_value=new_val,
|
||||||
|
ip_address=session.info.get("ip_address") or "127.0.0.1",
|
||||||
|
user_agent=session.info.get("user_agent")
|
||||||
|
)
|
||||||
|
logs_to_add.append(log_entry)
|
||||||
|
|
||||||
|
# 2. Dirty objects (Update)
|
||||||
|
for obj in session.dirty:
|
||||||
|
if isinstance(obj, AuditLog):
|
||||||
|
continue
|
||||||
|
if not session.is_modified(obj):
|
||||||
|
continue
|
||||||
|
|
||||||
|
entity_type = obj.__class__.__name__
|
||||||
|
mapper = obj.__class__.__mapper__
|
||||||
|
pk_keys = [col.key for col in mapper.primary_key]
|
||||||
|
entity_id = "-".join([str(getattr(obj, k)) for k in pk_keys]) if pk_keys else "transient"
|
||||||
|
if not entity_id or entity_id == "None" or entity_id == "transient":
|
||||||
|
if hasattr(obj, "user_id") and obj.user_id:
|
||||||
|
entity_id = str(obj.user_id)
|
||||||
|
elif hasattr(obj, "product_id") and obj.product_id:
|
||||||
|
entity_id = str(obj.product_id)
|
||||||
|
elif hasattr(obj, "category_id") and obj.category_id:
|
||||||
|
entity_id = str(obj.category_id)
|
||||||
|
elif hasattr(obj, "brand_id") and obj.brand_id:
|
||||||
|
entity_id = str(obj.brand_id)
|
||||||
|
elif hasattr(obj, "order_id") and obj.order_id:
|
||||||
|
entity_id = str(obj.order_id)
|
||||||
|
|
||||||
|
old_val_dict = {}
|
||||||
|
new_val_dict = {}
|
||||||
|
|
||||||
|
from sqlalchemy.orm import attributes
|
||||||
|
for col in mapper.column_attrs:
|
||||||
|
hist = attributes.get_history(obj, col.key)
|
||||||
|
if hist.has_changes():
|
||||||
|
old_v = hist.deleted[0] if hist.deleted else None
|
||||||
|
new_v = hist.added[0] if hist.added else None
|
||||||
|
old_val_dict[col.key] = serialize_val(old_v)
|
||||||
|
new_val_dict[col.key] = serialize_val(new_v)
|
||||||
|
|
||||||
|
if not old_val_dict and not new_val_dict:
|
||||||
|
continue
|
||||||
|
|
||||||
|
action = "update"
|
||||||
|
if entity_type == "Order" and "status" in new_val_dict:
|
||||||
|
action = f"order_status_{new_val_dict['status'].lower()}"
|
||||||
|
elif entity_type == "UserSession" and "is_active" in new_val_dict and not new_val_dict["is_active"]:
|
||||||
|
action = "logout"
|
||||||
|
|
||||||
|
log_entry = AuditLog(
|
||||||
|
audit_id=str(ulid.ULID()),
|
||||||
|
request_id=req_id,
|
||||||
|
user_id=session.info.get("user_id"),
|
||||||
|
entity_type=entity_type,
|
||||||
|
entity_id=entity_id,
|
||||||
|
action=action,
|
||||||
|
old_value=old_val_dict,
|
||||||
|
new_value=new_val_dict,
|
||||||
|
ip_address=session.info.get("ip_address") or "127.0.0.1",
|
||||||
|
user_agent=session.info.get("user_agent")
|
||||||
|
)
|
||||||
|
logs_to_add.append(log_entry)
|
||||||
|
|
||||||
|
# 3. Deleted objects (Delete)
|
||||||
|
for obj in session.deleted:
|
||||||
|
if isinstance(obj, AuditLog):
|
||||||
|
continue
|
||||||
|
entity_type = obj.__class__.__name__
|
||||||
|
mapper = obj.__class__.__mapper__
|
||||||
|
pk_keys = [col.key for col in mapper.primary_key]
|
||||||
|
entity_id = "-".join([str(getattr(obj, k)) for k in pk_keys]) if pk_keys else "transient"
|
||||||
|
if not entity_id or entity_id == "None" or entity_id == "transient":
|
||||||
|
if hasattr(obj, "user_id") and obj.user_id:
|
||||||
|
entity_id = str(obj.user_id)
|
||||||
|
elif hasattr(obj, "product_id") and obj.product_id:
|
||||||
|
entity_id = str(obj.product_id)
|
||||||
|
elif hasattr(obj, "category_id") and obj.category_id:
|
||||||
|
entity_id = str(obj.category_id)
|
||||||
|
elif hasattr(obj, "brand_id") and obj.brand_id:
|
||||||
|
entity_id = str(obj.brand_id)
|
||||||
|
elif hasattr(obj, "order_id") and obj.order_id:
|
||||||
|
entity_id = str(obj.order_id)
|
||||||
|
|
||||||
|
old_val = get_model_dict(obj)
|
||||||
|
|
||||||
|
log_entry = AuditLog(
|
||||||
|
audit_id=str(ulid.ULID()),
|
||||||
|
request_id=req_id,
|
||||||
|
user_id=session.info.get("user_id"),
|
||||||
|
entity_type=entity_type,
|
||||||
|
entity_id=entity_id,
|
||||||
|
action="delete",
|
||||||
|
old_value=old_val,
|
||||||
|
new_value=None,
|
||||||
|
ip_address=session.info.get("ip_address") or "127.0.0.1",
|
||||||
|
user_agent=session.info.get("user_agent")
|
||||||
|
)
|
||||||
|
logs_to_add.append(log_entry)
|
||||||
|
|
||||||
|
for log in logs_to_add:
|
||||||
|
session.add(log)
|
||||||
472
app/core/database/init_db.py
Normal file
|
|
@ -0,0 +1,472 @@
|
||||||
|
import pymysql
|
||||||
|
from sqlalchemy import create_engine, select, text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
import ulid
|
||||||
|
|
||||||
|
from app.core.config.Config import settings
|
||||||
|
from app.core.database.db_session import (
|
||||||
|
Base, engine_core, engine_crm, engine_commerce, SessionLocal
|
||||||
|
)
|
||||||
|
import app.models.db_base
|
||||||
|
# Ensure new CMS models are imported so their tables are included in metadata
|
||||||
|
import app.models.StorefrontCmsModel # noqa: F401
|
||||||
|
from app.models.RoleModel import Role
|
||||||
|
from app.models.DepartmentModel import Department
|
||||||
|
from app.models.DesignationModel import Designation
|
||||||
|
from app.models.UserModel import User
|
||||||
|
from app.models.SettingModel import Setting
|
||||||
|
from app.models.StorefrontCmsModel import StorefrontSettings, StorefrontFooterInfo
|
||||||
|
from app.utils.Hash_util import hash_password
|
||||||
|
|
||||||
|
def verify_or_create_database(db_url: str):
|
||||||
|
try:
|
||||||
|
parsed = urlparse(db_url)
|
||||||
|
db_name = parsed.path.lstrip("/")
|
||||||
|
host = parsed.hostname or "127.0.0.1"
|
||||||
|
port = parsed.port or 3306
|
||||||
|
user = parsed.username or "root"
|
||||||
|
password = parsed.password or ""
|
||||||
|
|
||||||
|
print(f"Verifying/creating database '{db_name}' on MySQL host {host}:{port}...")
|
||||||
|
connection = pymysql.connect(
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
user=user,
|
||||||
|
password=password
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute(f"CREATE DATABASE IF NOT EXISTS `{db_name}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;")
|
||||||
|
connection.commit()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
except Exception as err:
|
||||||
|
print(f"Database verification notice (continuing safely): {err}")
|
||||||
|
|
||||||
|
def initialize_database():
|
||||||
|
# 1. Verify/create all three database schemas
|
||||||
|
verify_or_create_database(settings.CORE_DATABASE_URL or settings.DATABASE_URL)
|
||||||
|
verify_or_create_database(settings.CRM_DATABASE_URL or settings.DATABASE_URL)
|
||||||
|
verify_or_create_database(settings.COMMERCE_DATABASE_URL or settings.DATABASE_URL)
|
||||||
|
|
||||||
|
# Safely recreate settings table if schema was updated
|
||||||
|
with engine_core.connect() as conn:
|
||||||
|
try:
|
||||||
|
conn.execute(text("SELECT created_at FROM settings LIMIT 1"))
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
conn.execute(text("DROP TABLE IF EXISTS settings"))
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 2. Extract tables belonging to each DB in dependency-sorted order
|
||||||
|
crm_tables = [
|
||||||
|
"service_types", "repair_services", "repair_variants", "repair_variant_images",
|
||||||
|
"parts", "part_device_compatibility", "repair_variant_parts", "stock_movements",
|
||||||
|
"purchase_orders", "purchase_order_items", "contacts", "contact_addresses"
|
||||||
|
]
|
||||||
|
core_tables = [
|
||||||
|
"departments", "designations", "roles", "permissions",
|
||||||
|
"users", "user_sessions", "audit_logs",
|
||||||
|
"countries", "states", "cities", "settings", "file_uploads",
|
||||||
|
"role_permissions"
|
||||||
|
]
|
||||||
|
|
||||||
|
core_metadata_tables = [t for t in Base.metadata.sorted_tables if t.name in core_tables]
|
||||||
|
crm_metadata_tables = [t for t in Base.metadata.sorted_tables if t.name in crm_tables]
|
||||||
|
commerce_metadata_tables = [
|
||||||
|
t for t in Base.metadata.sorted_tables
|
||||||
|
if t.name not in core_tables and t.name not in crm_tables
|
||||||
|
]
|
||||||
|
|
||||||
|
print("Creating tables in Core Database...")
|
||||||
|
Base.metadata.create_all(bind=engine_core, tables=core_metadata_tables)
|
||||||
|
|
||||||
|
print("Creating tables in CRM Database...")
|
||||||
|
Base.metadata.create_all(bind=engine_crm, tables=crm_metadata_tables)
|
||||||
|
|
||||||
|
print("Creating tables in Commerce Database...")
|
||||||
|
Base.metadata.create_all(bind=engine_commerce, tables=commerce_metadata_tables)
|
||||||
|
|
||||||
|
# 2.5 Run auto-migrations for new columns on existing tables in Commerce DB
|
||||||
|
from sqlalchemy import text
|
||||||
|
with engine_commerce.connect() as conn:
|
||||||
|
# device_series
|
||||||
|
try:
|
||||||
|
conn.execute(text("SELECT device_type FROM device_series LIMIT 1"))
|
||||||
|
except Exception:
|
||||||
|
print("Auto-Migration: Adding 'device_type' column to 'device_series' table...")
|
||||||
|
try:
|
||||||
|
conn.execute(text("ALTER TABLE device_series ADD COLUMN device_type VARCHAR(50) NULL"))
|
||||||
|
conn.commit()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to alter device_series: {e}")
|
||||||
|
|
||||||
|
# device_models
|
||||||
|
try:
|
||||||
|
conn.execute(text("SELECT device_type FROM device_models LIMIT 1"))
|
||||||
|
except Exception:
|
||||||
|
print("Auto-Migration: Adding 'device_type' column to 'device_models' table...")
|
||||||
|
try:
|
||||||
|
conn.execute(text("ALTER TABLE device_models ADD COLUMN device_type VARCHAR(50) NULL"))
|
||||||
|
conn.commit()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to alter device_models: {e}")
|
||||||
|
|
||||||
|
# device_models series_id nullable migration
|
||||||
|
try:
|
||||||
|
print("Auto-Migration: Modifying 'device_models.series_id' to be NULLable...")
|
||||||
|
conn.execute(text("ALTER TABLE device_models MODIFY COLUMN series_id VARCHAR(26) NULL"))
|
||||||
|
conn.commit()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to modify device_models.series_id: {e}")
|
||||||
|
|
||||||
|
# products
|
||||||
|
try:
|
||||||
|
conn.execute(text("SELECT device_type FROM products LIMIT 1"))
|
||||||
|
except Exception:
|
||||||
|
print("Auto-Migration: Adding 'device_type' column to 'products' table...")
|
||||||
|
try:
|
||||||
|
conn.execute(text("ALTER TABLE products ADD COLUMN device_type VARCHAR(50) NULL"))
|
||||||
|
conn.commit()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to alter products: {e}")
|
||||||
|
|
||||||
|
# products category_id NULLable
|
||||||
|
try:
|
||||||
|
conn.execute(text("SET FOREIGN_KEY_CHECKS=0;"))
|
||||||
|
conn.execute(text("ALTER TABLE products MODIFY COLUMN category_id VARCHAR(26) NULL"))
|
||||||
|
conn.execute(text("ALTER TABLE device_series MODIFY COLUMN brand_id VARCHAR(26) NULL"))
|
||||||
|
conn.execute(text("ALTER TABLE device_models MODIFY COLUMN brand_id VARCHAR(26) NULL"))
|
||||||
|
conn.execute(text("SET FOREIGN_KEY_CHECKS=1;"))
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# service_jobs logistics columns
|
||||||
|
try:
|
||||||
|
conn.execute(text("SELECT courier_name FROM service_jobs LIMIT 1"))
|
||||||
|
except Exception:
|
||||||
|
print("Auto-Migration: Adding logistics columns to 'service_jobs' table...")
|
||||||
|
try:
|
||||||
|
conn.execute(text("ALTER TABLE service_jobs ADD COLUMN courier_name VARCHAR(100) NULL"))
|
||||||
|
conn.execute(text("ALTER TABLE service_jobs ADD COLUMN awb_number VARCHAR(100) NULL"))
|
||||||
|
conn.execute(text("ALTER TABLE service_jobs ADD COLUMN pickup_status VARCHAR(50) NULL"))
|
||||||
|
conn.commit()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to alter service_jobs: {e}")
|
||||||
|
|
||||||
|
# attribute_types (preset_values)
|
||||||
|
try:
|
||||||
|
conn.execute(text("SELECT preset_values FROM attribute_types LIMIT 1"))
|
||||||
|
except Exception:
|
||||||
|
print("Auto-Migration: Adding 'preset_values' column to 'attribute_types' table...")
|
||||||
|
try:
|
||||||
|
conn.execute(text("ALTER TABLE attribute_types ADD COLUMN preset_values JSON NULL"))
|
||||||
|
conn.commit()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to alter attribute_types: {e}")
|
||||||
|
|
||||||
|
# customer_devices (storage_capacity)
|
||||||
|
try:
|
||||||
|
conn.execute(text("SELECT storage_capacity FROM customer_devices LIMIT 1"))
|
||||||
|
except Exception:
|
||||||
|
print("Auto-Migration: Adding 'storage_capacity' column to 'customer_devices' table...")
|
||||||
|
try:
|
||||||
|
conn.execute(text("ALTER TABLE customer_devices ADD COLUMN storage_capacity VARCHAR(100) NULL"))
|
||||||
|
conn.commit()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to alter customer_devices: {e}")
|
||||||
|
|
||||||
|
# service_jobs (custom_service_name)
|
||||||
|
try:
|
||||||
|
conn.execute(text("SELECT custom_service_name FROM service_jobs LIMIT 1"))
|
||||||
|
except Exception:
|
||||||
|
print("Auto-Migration: Adding 'custom_service_name' column to 'service_jobs' table...")
|
||||||
|
try:
|
||||||
|
conn.execute(text("ALTER TABLE service_jobs ADD COLUMN custom_service_name VARCHAR(255) NULL"))
|
||||||
|
conn.commit()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to alter service_jobs: {e}")
|
||||||
|
|
||||||
|
# migration_jobs table schema parity auto-migration
|
||||||
|
try:
|
||||||
|
conn.execute(text("SELECT current_phase FROM migration_jobs LIMIT 1"))
|
||||||
|
except Exception:
|
||||||
|
print("Auto-Migration: Adding missing worker & progress columns to 'migration_jobs' table...")
|
||||||
|
migration_jobs_alters = [
|
||||||
|
"ALTER TABLE migration_jobs ADD COLUMN current_phase VARCHAR(50) NOT NULL DEFAULT 'UPLOAD'",
|
||||||
|
"ALTER TABLE migration_jobs ADD COLUMN worker_id VARCHAR(64) NULL",
|
||||||
|
"ALTER TABLE migration_jobs ADD COLUMN locked_at DATETIME NULL",
|
||||||
|
"ALTER TABLE migration_jobs ADD COLUMN heartbeat_at DATETIME NULL",
|
||||||
|
"ALTER TABLE migration_jobs ADD COLUMN lease_version BIGINT NOT NULL DEFAULT 1",
|
||||||
|
"ALTER TABLE migration_jobs ADD COLUMN cancel_requested_at DATETIME NULL",
|
||||||
|
"ALTER TABLE migration_jobs ADD COLUMN current_batch INT NOT NULL DEFAULT 0",
|
||||||
|
"ALTER TABLE migration_jobs ADD COLUMN total_batches INT NOT NULL DEFAULT 0",
|
||||||
|
"ALTER TABLE migration_jobs ADD COLUMN last_successful_batch INT NOT NULL DEFAULT 0",
|
||||||
|
"ALTER TABLE migration_jobs ADD COLUMN warning_records INT NOT NULL DEFAULT 0",
|
||||||
|
"ALTER TABLE migration_jobs ADD COLUMN retry_count INT NOT NULL DEFAULT 0",
|
||||||
|
"ALTER TABLE migration_jobs ADD COLUMN expected_products INT NOT NULL DEFAULT 0",
|
||||||
|
"ALTER TABLE migration_jobs ADD COLUMN expected_variants INT NOT NULL DEFAULT 0",
|
||||||
|
"ALTER TABLE migration_jobs ADD COLUMN expected_media_items INT NOT NULL DEFAULT 0",
|
||||||
|
"ALTER TABLE migration_jobs ADD COLUMN expected_media_links INT NOT NULL DEFAULT 0",
|
||||||
|
"ALTER TABLE migration_jobs ADD COLUMN started_at DATETIME NULL",
|
||||||
|
"ALTER TABLE migration_jobs ADD COLUMN completed_at DATETIME NULL",
|
||||||
|
"ALTER TABLE migration_jobs ADD COLUMN failed_at DATETIME NULL",
|
||||||
|
"ALTER TABLE migration_jobs ADD COLUMN finished_at DATETIME NULL",
|
||||||
|
"ALTER TABLE migration_jobs ADD COLUMN error_message TEXT NULL",
|
||||||
|
]
|
||||||
|
for alter_sql in migration_jobs_alters:
|
||||||
|
try:
|
||||||
|
conn.execute(text(alter_sql))
|
||||||
|
conn.commit()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Migration column alter note: {e}")
|
||||||
|
|
||||||
|
# media_library table schema parity auto-migration
|
||||||
|
try:
|
||||||
|
conn.execute(text("SELECT storage_path FROM media_library LIMIT 1"))
|
||||||
|
except Exception:
|
||||||
|
print("Auto-Migration: Adding missing columns to 'media_library' table...")
|
||||||
|
media_library_alters = [
|
||||||
|
"ALTER TABLE media_library ADD COLUMN storage_path VARCHAR(512) NULL",
|
||||||
|
"ALTER TABLE media_library ADD COLUMN exif_metadata JSON NULL",
|
||||||
|
"ALTER TABLE media_library ADD COLUMN cdn_url VARCHAR(512) NULL",
|
||||||
|
"ALTER TABLE media_library ADD COLUMN thumbnail_url VARCHAR(512) NULL",
|
||||||
|
]
|
||||||
|
for alter_sql in media_library_alters:
|
||||||
|
try:
|
||||||
|
conn.execute(text(alter_sql))
|
||||||
|
conn.commit()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Media library column alter note: {e}")
|
||||||
|
|
||||||
|
# categories — CMS columns (show_in_sidebar, mega_group, badge)
|
||||||
|
try:
|
||||||
|
conn.execute(text("SELECT show_in_sidebar FROM categories LIMIT 1"))
|
||||||
|
except Exception:
|
||||||
|
print("Auto-Migration: Adding CMS columns to 'categories' table...")
|
||||||
|
for alter_sql in [
|
||||||
|
"ALTER TABLE categories ADD COLUMN show_in_sidebar TINYINT(1) NOT NULL DEFAULT 1",
|
||||||
|
"ALTER TABLE categories ADD COLUMN mega_group VARCHAR(64) NULL",
|
||||||
|
"ALTER TABLE categories ADD COLUMN badge VARCHAR(32) NULL",
|
||||||
|
]:
|
||||||
|
try:
|
||||||
|
conn.execute(text(alter_sql))
|
||||||
|
conn.commit()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Category CMS column alter note: {e}")
|
||||||
|
|
||||||
|
print("All tables compiled and created successfully in their respective databases.")
|
||||||
|
|
||||||
|
# 3. Seed only the minimum bootstrap data needed to start the platform
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
seed_bootstrap(db)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
def seed_bootstrap(db: Session):
|
||||||
|
"""
|
||||||
|
Seeds only the absolute minimum data required to boot the platform:
|
||||||
|
- System settings (media / store controls)
|
||||||
|
- Super Admin role, Administration department, General Administrator
|
||||||
|
designation — required as FK dependencies for the admin user
|
||||||
|
- One Super Admin user account
|
||||||
|
|
||||||
|
All business data (brands, categories, products, extra roles /
|
||||||
|
departments / designations, etc.) must be entered via the Admin UI
|
||||||
|
after first login.
|
||||||
|
"""
|
||||||
|
print("Seeding bootstrap system settings...")
|
||||||
|
|
||||||
|
# ── System Settings ────────────────────────────────────────────────────
|
||||||
|
settings_data = [
|
||||||
|
{
|
||||||
|
"setting_key": "GLOBAL_DISABLE",
|
||||||
|
"setting_value": {"enabled": False},
|
||||||
|
"description": "Global Kill Switch Controls",
|
||||||
|
"is_public": False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"setting_key": "STORE_CLOSED",
|
||||||
|
"setting_value": {"closed": False, "reason": ""},
|
||||||
|
"description": "Storefront Operations Controls",
|
||||||
|
"is_public": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"setting_key": "media_store_original",
|
||||||
|
"setting_value": {"value": True},
|
||||||
|
"description": "Store raw original image file alongside WebP",
|
||||||
|
"is_public": False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"setting_key": "media_max_size_mb",
|
||||||
|
"setting_value": {"value": 20},
|
||||||
|
"description": "Maximum upload file size in MB",
|
||||||
|
"is_public": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"setting_key": "media_webp_quality",
|
||||||
|
"setting_value": {"value": 88},
|
||||||
|
"description": "WebP compression quality (80-100)",
|
||||||
|
"is_public": False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"setting_key": "media_cleanup_enabled",
|
||||||
|
"setting_value": {"value": True},
|
||||||
|
"description": "Async media garbage collection kill switch",
|
||||||
|
"is_public": False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"setting_key": "media_cleanup_grace_hours",
|
||||||
|
"setting_value": {"value": 24},
|
||||||
|
"description": "Safety grace period before physical file deletion in hours",
|
||||||
|
"is_public": False,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for s in settings_data:
|
||||||
|
existing = db.execute(
|
||||||
|
select(Setting).where(Setting.setting_key == s["setting_key"])
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if not existing:
|
||||||
|
db.add(Setting(
|
||||||
|
setting_id=str(ulid.ULID()),
|
||||||
|
setting_key=s["setting_key"],
|
||||||
|
group="system",
|
||||||
|
type="json",
|
||||||
|
setting_value=s["setting_value"],
|
||||||
|
description=s["description"],
|
||||||
|
is_public=s["is_public"],
|
||||||
|
))
|
||||||
|
print(f" + Setting: {s['setting_key']}")
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# ── Super Admin Role (FK dependency for admin user) ────────────────────
|
||||||
|
super_admin_role = db.execute(
|
||||||
|
select(Role).where(Role.role_name == "Super Admin")
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if not super_admin_role:
|
||||||
|
super_admin_role = Role(
|
||||||
|
role_id=str(ulid.ULID()),
|
||||||
|
role_name="Super Admin",
|
||||||
|
role_prefix="ADM",
|
||||||
|
description="System Super Administrator",
|
||||||
|
is_system=True,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(super_admin_role)
|
||||||
|
db.commit()
|
||||||
|
print(" + Role: Super Admin")
|
||||||
|
|
||||||
|
# ── Administration Department (FK dependency for admin user) ───────────
|
||||||
|
admin_dept = db.execute(
|
||||||
|
select(Department).where(Department.name == "Administration")
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if not admin_dept:
|
||||||
|
admin_dept = Department(
|
||||||
|
department_id=str(ulid.ULID()),
|
||||||
|
name="Administration",
|
||||||
|
description="Global Admin Department",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(admin_dept)
|
||||||
|
db.commit()
|
||||||
|
print(" + Department: Administration")
|
||||||
|
|
||||||
|
# ── General Administrator Designation (FK dependency for admin user) ───
|
||||||
|
admin_desig = db.execute(
|
||||||
|
select(Designation).where(Designation.name == "General Administrator")
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if not admin_desig:
|
||||||
|
admin_desig = Designation(
|
||||||
|
designation_id=str(ulid.ULID()),
|
||||||
|
name="General Administrator",
|
||||||
|
description="Platform Operations Manager",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(admin_desig)
|
||||||
|
db.commit()
|
||||||
|
print(" + Designation: General Administrator")
|
||||||
|
|
||||||
|
# ── Super Admin User ───────────────────────────────────────────────────
|
||||||
|
super_admin_email = "admin@ifixkart.com"
|
||||||
|
existing_admin = db.execute(
|
||||||
|
select(User).where(User.email == super_admin_email)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if not existing_admin:
|
||||||
|
db.add(User(
|
||||||
|
user_id=str(ulid.ULID()),
|
||||||
|
employee_code="ADM0001",
|
||||||
|
first_name="iFixKart",
|
||||||
|
last_name="Administrator",
|
||||||
|
display_name="iFixKart Admin",
|
||||||
|
email=super_admin_email,
|
||||||
|
phone="1000000000",
|
||||||
|
password_hash=hash_password("Admin$2026Setup"),
|
||||||
|
department_id=admin_dept.department_id,
|
||||||
|
designation_id=admin_desig.designation_id,
|
||||||
|
role_id=super_admin_role.role_id,
|
||||||
|
is_active=True,
|
||||||
|
email_verified=True,
|
||||||
|
phone_verified=True,
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
print(f" + Super Admin: {super_admin_email} (password: Admin$2026Setup)")
|
||||||
|
|
||||||
|
# ── Storefront Footer Info default ────────────────────────────────────
|
||||||
|
existing_footer = db.get(StorefrontFooterInfo, "default")
|
||||||
|
if not existing_footer:
|
||||||
|
db.add(StorefrontFooterInfo(
|
||||||
|
id="default",
|
||||||
|
phone="+91 99999 99999",
|
||||||
|
email="support@ifixkart.com",
|
||||||
|
address="iFixKart Service Center, MG Road, Bengaluru, Karnataka - 560001",
|
||||||
|
copyright="© 2026 iFixKart. All rights reserved.",
|
||||||
|
social_links=[
|
||||||
|
{"platform": "Facebook", "url": "https://facebook.com/ifixkart", "icon": "facebook"},
|
||||||
|
{"platform": "Instagram", "url": "https://instagram.com/ifixkart", "icon": "instagram"},
|
||||||
|
{"platform": "Twitter", "url": "https://twitter.com/ifixkart", "icon": "twitter"},
|
||||||
|
],
|
||||||
|
columns=[
|
||||||
|
{"title": "Get to Know Us", "links": [{"label": "About Us", "href": "/about"}, {"label": "Term & Policy", "href": "/terms"}, {"label": "Careers", "href": "/careers"}, {"label": "News & Blog", "href": "/blog"}, {"label": "Contact Us", "href": "/contact"}]},
|
||||||
|
{"title": "Information", "links": [{"label": "Help Center", "href": "/help"}, {"label": "Feedback", "href": "/feedback"},{"label": "FAQs", "href": "/faqs"}, {"label": "Payments", "href": "/payments"}]},
|
||||||
|
{"title": "Orders & Returns","links": [{"label": "Track Order","href": "/account"},{"label": "Delivery", "href": "/delivery"},{"label": "Services", "href": "/services"},{"label": "Returns", "href": "/returns"}]},
|
||||||
|
{"title": "Our Store", "links": [{"label": "Best Seller", "href": "/products?sort=best-sellers"},{"label": "New Products","href": "/products?sort=newest"},{"label": "On Sale","href": "/products?on_sale=true"},{"label": "Featured","href": "/products?featured=true"}]},
|
||||||
|
],
|
||||||
|
payment_methods=[
|
||||||
|
{"name": "Visa", "icon_url": "/images/payments/visa.svg"},
|
||||||
|
{"name": "Mastercard", "icon_url": "/images/payments/mastercard.svg"},
|
||||||
|
{"name": "UPI", "icon_url": "/images/payments/upi.svg"},
|
||||||
|
{"name": "Razorpay", "icon_url": "/images/payments/razorpay.svg"},
|
||||||
|
],
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
print(" + Storefront Footer Info: default record seeded")
|
||||||
|
|
||||||
|
# ── Storefront Settings defaults ──────────────────────────────────────
|
||||||
|
default_cms_settings = [
|
||||||
|
("store_name", "iFixKart"),
|
||||||
|
("logo_url", "/images/logo/ifixkart-logo.webp"),
|
||||||
|
("primary_wordmark_url", "/images/logo/ifixkart-wordmark-primary.webp"),
|
||||||
|
("secondary_wordmark_url", "/images/logo/ifixkart-wordmark-secondary.webp"),
|
||||||
|
("favicon_url", "/favicon.ico"),
|
||||||
|
("support_phone", "+91 99999 99999"),
|
||||||
|
("currency_code", "INR"),
|
||||||
|
("advance_percent", 20.0),
|
||||||
|
("theme_color", "#6D28D9"),
|
||||||
|
]
|
||||||
|
for key, value in default_cms_settings:
|
||||||
|
existing = db.get(StorefrontSettings, key)
|
||||||
|
if not existing:
|
||||||
|
db.add(StorefrontSettings(key=key, value=value, updated_by="system"))
|
||||||
|
print(f" + StorefrontSettings: {key}")
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
print("Bootstrap complete. All business data must be added via the Admin UI.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
initialize_database()
|
||||||
187
app/core/database/seed_catalog.py
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
import ulid
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from app.core.database.db_session import SessionLocal
|
||||||
|
from app.models.BrandModel import Brand
|
||||||
|
from app.models.CategoryModel import Category
|
||||||
|
from app.models.DeviceCatalogModel import DeviceSeries, DeviceModel, ServiceType, RepairService, RepairVariant
|
||||||
|
from app.models.PartsModel import Part, RepairVariantPart
|
||||||
|
from app.models.StockMovementModel import StockMovement
|
||||||
|
|
||||||
|
def seed_catalog():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
print("Seeding catalog started...")
|
||||||
|
|
||||||
|
# 1. Brands
|
||||||
|
brands_data = [
|
||||||
|
{"name": "Apple", "slug": "apple"},
|
||||||
|
{"name": "Samsung", "slug": "samsung"},
|
||||||
|
{"name": "OnePlus", "slug": "oneplus"},
|
||||||
|
{"name": "Vivo", "slug": "vivo"}
|
||||||
|
]
|
||||||
|
seeded_brands = {}
|
||||||
|
for b in brands_data:
|
||||||
|
existing = db.execute(select(Brand).where(Brand.slug == b["slug"])).scalar_one_or_none()
|
||||||
|
if not existing:
|
||||||
|
brand = Brand(
|
||||||
|
brand_id=str(ulid.ULID()),
|
||||||
|
name=b["name"],
|
||||||
|
slug=b["slug"],
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
db.add(brand)
|
||||||
|
seeded_brands[b["slug"]] = brand
|
||||||
|
print(f"Seeded Brand: {b['name']}")
|
||||||
|
else:
|
||||||
|
seeded_brands[b["slug"]] = existing
|
||||||
|
|
||||||
|
# 2. Categories
|
||||||
|
cats_data = [
|
||||||
|
{"name": "Accessories", "slug": "accessories"},
|
||||||
|
{"name": "Screen Protectors", "slug": "screen-protectors"},
|
||||||
|
{"name": "Back Covers", "slug": "back-covers"}
|
||||||
|
]
|
||||||
|
seeded_cats = {}
|
||||||
|
for c in cats_data:
|
||||||
|
existing = db.execute(select(Category).where(Category.slug == c["slug"])).scalar_one_or_none()
|
||||||
|
if not existing:
|
||||||
|
cat = Category(
|
||||||
|
category_id=str(ulid.ULID()),
|
||||||
|
name=c["name"],
|
||||||
|
slug=c["slug"],
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
db.add(cat)
|
||||||
|
seeded_cats[c["slug"]] = cat
|
||||||
|
print(f"Seeded Category: {c['name']}")
|
||||||
|
else:
|
||||||
|
seeded_cats[c["slug"]] = existing
|
||||||
|
|
||||||
|
# 3. Device Series
|
||||||
|
series_data = [
|
||||||
|
{"name": "iPhone Series", "slug": "iphone-series", "brand_slug": "apple"},
|
||||||
|
{"name": "Galaxy S Series", "slug": "galaxy-s-series", "brand_slug": "samsung"}
|
||||||
|
]
|
||||||
|
seeded_series = {}
|
||||||
|
for s in series_data:
|
||||||
|
existing = db.execute(select(DeviceSeries).where(DeviceSeries.slug == s["slug"])).scalar_one_or_none()
|
||||||
|
if not existing:
|
||||||
|
brand = seeded_brands[s["brand_slug"]]
|
||||||
|
series = DeviceSeries(
|
||||||
|
series_id=str(ulid.ULID()),
|
||||||
|
brand_id=brand.brand_id,
|
||||||
|
name=s["name"],
|
||||||
|
slug=s["slug"],
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
db.add(series)
|
||||||
|
seeded_series[s["slug"]] = series
|
||||||
|
print(f"Seeded Series: {s['name']}")
|
||||||
|
else:
|
||||||
|
seeded_series[s["slug"]] = existing
|
||||||
|
|
||||||
|
# 4. Device Models
|
||||||
|
models_data = [
|
||||||
|
{"name": "iPhone 16 Pro Max", "slug": "iphone-16-pro-max", "series_slug": "iphone-series", "brand_slug": "apple"},
|
||||||
|
{"name": "Galaxy S24 Ultra", "slug": "galaxy-s-24-ultra", "series_slug": "galaxy-s-series", "brand_slug": "samsung"}
|
||||||
|
]
|
||||||
|
seeded_models = {}
|
||||||
|
for m in models_data:
|
||||||
|
existing = db.execute(select(DeviceModel).where(DeviceModel.slug == m["slug"])).scalar_one_or_none()
|
||||||
|
if not existing:
|
||||||
|
series = seeded_series[m["series_slug"]]
|
||||||
|
brand = seeded_brands[m["brand_slug"]]
|
||||||
|
model = DeviceModel(
|
||||||
|
model_id=str(ulid.ULID()),
|
||||||
|
series_id=series.series_id,
|
||||||
|
brand_id=brand.brand_id,
|
||||||
|
name=m["name"],
|
||||||
|
slug=m["slug"],
|
||||||
|
full_path=f"/repair/{brand.slug}/{series.slug}/{m['slug']}",
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
db.add(model)
|
||||||
|
seeded_models[m["slug"]] = model
|
||||||
|
print(f"Seeded Model: {m['name']}")
|
||||||
|
else:
|
||||||
|
seeded_models[m["slug"]] = existing
|
||||||
|
|
||||||
|
# 5. Service Types
|
||||||
|
service_types_data = [
|
||||||
|
{"name": "Screen Replacement", "slug": "screen-replacement"},
|
||||||
|
{"name": "Battery Replacement", "slug": "battery-replacement"},
|
||||||
|
{"name": "Back Glass Repair", "slug": "back-glass-repair"}
|
||||||
|
]
|
||||||
|
seeded_types = {}
|
||||||
|
for t in service_types_data:
|
||||||
|
existing = db.execute(select(ServiceType).where(ServiceType.slug == t["slug"])).scalar_one_or_none()
|
||||||
|
if not existing:
|
||||||
|
stype = ServiceType(
|
||||||
|
service_type_id=str(ulid.ULID()),
|
||||||
|
name=t["name"],
|
||||||
|
slug=t["slug"],
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
db.add(stype)
|
||||||
|
seeded_types[t["slug"]] = stype
|
||||||
|
print(f"Seeded Service Type: {t['name']}")
|
||||||
|
else:
|
||||||
|
seeded_types[t["slug"]] = existing
|
||||||
|
|
||||||
|
# 6. Physical Parts
|
||||||
|
parts_data = [
|
||||||
|
{"sku": "PART-IP16PM-SCR-ORG", "name": "iPhone 16 Pro Max Original Screen", "cost": 15000.0},
|
||||||
|
{"sku": "PART-IP16PM-SCR-COMP", "name": "iPhone 16 Pro Max Compatible Screen", "cost": 8000.0},
|
||||||
|
{"sku": "PART-GS24U-SCR-ORG", "name": "Galaxy S24 Ultra Original Screen", "cost": 13000.0},
|
||||||
|
{"sku": "PART-GLUE-T8000", "name": "Zhanlida T-8000 Adhesive Glue", "cost": 120.0},
|
||||||
|
{"sku": "PART-TAPE-SCR", "name": "Universal Screen Mounting Tape", "cost": 40.0}
|
||||||
|
]
|
||||||
|
seeded_parts = {}
|
||||||
|
for p in parts_data:
|
||||||
|
existing = db.execute(select(Part).where(Part.sku == p["sku"])).scalar_one_or_none()
|
||||||
|
if not existing:
|
||||||
|
part = Part(
|
||||||
|
part_id=str(ulid.ULID()),
|
||||||
|
sku=p["sku"],
|
||||||
|
name=p["name"],
|
||||||
|
cost_price=Decimal(p["cost"]),
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
db.add(part)
|
||||||
|
seeded_parts[p["sku"]] = part
|
||||||
|
print(f"Seeded Part SKU: {p['sku']}")
|
||||||
|
else:
|
||||||
|
seeded_parts[p["sku"]] = existing
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# 7. Add initial stock movements (Purchase / Adjustments) to simulate stock
|
||||||
|
for sku, part in seeded_parts.items():
|
||||||
|
# Check existing movements
|
||||||
|
exist_mov = db.execute(select(StockMovement).where(StockMovement.entity_id == part.part_id)).scalars().first()
|
||||||
|
if not exist_mov:
|
||||||
|
movement = StockMovement(
|
||||||
|
movement_id=str(ulid.ULID()),
|
||||||
|
entity_type="part",
|
||||||
|
entity_id=part.part_id,
|
||||||
|
movement_type="Adjustment",
|
||||||
|
quantity=20, # start with 20 items in stock
|
||||||
|
reference_type="ManualAdjustment",
|
||||||
|
reference_id="SEED-001"
|
||||||
|
)
|
||||||
|
db.add(movement)
|
||||||
|
print(f"Added initial 20 stock movement for part: {sku}")
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
print("Catalog seeding completed successfully.")
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
print(f"Error seeding catalog: {e}")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
seed_catalog()
|
||||||
418
app/core/invoice_generator.py
Normal file
|
|
@ -0,0 +1,418 @@
|
||||||
|
"""
|
||||||
|
@helper InvoiceGenerator (Backend/app/core/invoice_generator.py)
|
||||||
|
@purpose Utilities to compile standard Letter-sized PDF invoices and compact 80mm thermal roll receipts on-the-fly using ReportLab.
|
||||||
|
"""
|
||||||
|
from io import BytesIO
|
||||||
|
from reportlab.lib.pagesizes import letter
|
||||||
|
from reportlab.lib import colors
|
||||||
|
from reportlab.lib.units import inch
|
||||||
|
from PIL import Image as PILImage
|
||||||
|
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak, Image as RLImage
|
||||||
|
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||||
|
from reportlab.lib.enums import TA_CENTER, TA_RIGHT, TA_LEFT
|
||||||
|
from datetime import datetime
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
def get_invoice_logo_element(max_w=2.0 * inch, max_h=0.8 * inch):
|
||||||
|
"""
|
||||||
|
Finds the latest uploaded invoice branding logo in uploads/invoice_branding/logo/
|
||||||
|
and returns a ReportLab RLImage element.
|
||||||
|
"""
|
||||||
|
backend_root = Path(__file__).resolve().parents[2]
|
||||||
|
logo_dir = backend_root / "uploads" / "invoice_branding" / "logo"
|
||||||
|
|
||||||
|
if logo_dir.exists():
|
||||||
|
# Get active non-variant files sorted by modification time
|
||||||
|
files = [f for f in logo_dir.glob("*.*") if not any(s in f.name for s in ["_thumbnail", "_medium", "_large", "_raw"])]
|
||||||
|
if not files:
|
||||||
|
files = list(logo_dir.glob("*.*"))
|
||||||
|
|
||||||
|
files = sorted(files, key=os.path.getmtime, reverse=True)
|
||||||
|
for logo_file in files:
|
||||||
|
try:
|
||||||
|
pil_img = PILImage.open(logo_file)
|
||||||
|
bio = BytesIO()
|
||||||
|
if pil_img.mode in ("RGBA", "P"):
|
||||||
|
pil_img = pil_img.convert("RGBA")
|
||||||
|
else:
|
||||||
|
pil_img = pil_img.convert("RGB")
|
||||||
|
|
||||||
|
pil_img.save(bio, format="PNG")
|
||||||
|
bio.seek(0)
|
||||||
|
|
||||||
|
w, h = pil_img.size
|
||||||
|
if w <= 0 or h <= 0:
|
||||||
|
continue
|
||||||
|
aspect = h / float(w)
|
||||||
|
render_w = max_w
|
||||||
|
render_h = max_w * aspect
|
||||||
|
if render_h > max_h:
|
||||||
|
render_h = max_h
|
||||||
|
render_w = max_h / aspect
|
||||||
|
|
||||||
|
return RLImage(bio, width=render_w, height=render_h)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error loading logo {logo_file}: {e}")
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_invoice_branding_data(db=None):
|
||||||
|
company_name = "iFixKart"
|
||||||
|
gstin = "33AAAAA0000A1Z5"
|
||||||
|
address = "Offline Main Store Counter, Chennai | +91 9876543210"
|
||||||
|
gst_rate = 18.0
|
||||||
|
|
||||||
|
if db:
|
||||||
|
try:
|
||||||
|
from app.models.SettingModel import Setting
|
||||||
|
setting = db.query(Setting).filter(Setting.setting_key == "invoice_branding").first()
|
||||||
|
if setting and setting.setting_value:
|
||||||
|
val = setting.setting_value
|
||||||
|
if isinstance(val, dict):
|
||||||
|
company_name = val.get("companyName") or company_name
|
||||||
|
gstin = val.get("gstin") or gstin
|
||||||
|
address = val.get("storeAddress") or address
|
||||||
|
if "gstRate" in val and val["gstRate"] is not None:
|
||||||
|
try:
|
||||||
|
gst_rate = float(val["gstRate"])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"companyName": company_name,
|
||||||
|
"gstin": gstin,
|
||||||
|
"storeAddress": address,
|
||||||
|
"gstRate": gst_rate
|
||||||
|
}
|
||||||
|
|
||||||
|
def generate_invoice_pdf(order, customer, order_items, db=None) -> bytes:
|
||||||
|
"""
|
||||||
|
Generate a professional standard Letter-size GST Invoice on-the-fly.
|
||||||
|
"""
|
||||||
|
buffer = BytesIO()
|
||||||
|
doc = SimpleDocTemplate(
|
||||||
|
buffer,
|
||||||
|
pagesize=letter,
|
||||||
|
rightMargin=36,
|
||||||
|
leftMargin=36,
|
||||||
|
topMargin=36,
|
||||||
|
bottomMargin=36
|
||||||
|
)
|
||||||
|
|
||||||
|
styles = getSampleStyleSheet()
|
||||||
|
|
||||||
|
# Custom styles
|
||||||
|
title_style = ParagraphStyle(
|
||||||
|
'InvoiceTitle',
|
||||||
|
parent=styles['Heading1'],
|
||||||
|
fontName='Helvetica-Bold',
|
||||||
|
fontSize=22,
|
||||||
|
textColor=colors.HexColor('#1b2559'),
|
||||||
|
spaceAfter=4
|
||||||
|
)
|
||||||
|
|
||||||
|
subtitle_style = ParagraphStyle(
|
||||||
|
'InvoiceSubtitle',
|
||||||
|
parent=styles['Normal'],
|
||||||
|
fontName='Helvetica-Bold',
|
||||||
|
fontSize=10,
|
||||||
|
textColor=colors.HexColor('#e4382f'),
|
||||||
|
spaceAfter=10
|
||||||
|
)
|
||||||
|
|
||||||
|
label_style = ParagraphStyle(
|
||||||
|
'MetaLabel',
|
||||||
|
parent=styles['Normal'],
|
||||||
|
fontName='Helvetica-Bold',
|
||||||
|
fontSize=9,
|
||||||
|
textColor=colors.HexColor('#94a3b8'),
|
||||||
|
spaceAfter=3
|
||||||
|
)
|
||||||
|
|
||||||
|
text_style = ParagraphStyle(
|
||||||
|
'MetaText',
|
||||||
|
parent=styles['Normal'],
|
||||||
|
fontName='Helvetica',
|
||||||
|
fontSize=9,
|
||||||
|
textColor=colors.HexColor('#1e293b'),
|
||||||
|
spaceAfter=3
|
||||||
|
)
|
||||||
|
|
||||||
|
bold_text_style = ParagraphStyle(
|
||||||
|
'MetaTextBold',
|
||||||
|
parent=styles['Normal'],
|
||||||
|
fontName='Helvetica-Bold',
|
||||||
|
fontSize=9,
|
||||||
|
textColor=colors.HexColor('#1e293b'),
|
||||||
|
spaceAfter=3
|
||||||
|
)
|
||||||
|
|
||||||
|
story = []
|
||||||
|
|
||||||
|
# 1. Header (Logo/Title & Metadata)
|
||||||
|
logo_element = get_invoice_logo_element(max_w=2.2 * inch, max_h=0.85 * inch)
|
||||||
|
|
||||||
|
left_cell = []
|
||||||
|
if logo_element:
|
||||||
|
left_cell.append(logo_element)
|
||||||
|
else:
|
||||||
|
left_cell.append(Paragraph("GST INVOICE", title_style))
|
||||||
|
left_cell.append(Paragraph("iFixKart Solutions Platform", subtitle_style))
|
||||||
|
|
||||||
|
header_data = [
|
||||||
|
[
|
||||||
|
left_cell,
|
||||||
|
Paragraph(f"<b>GST INVOICE</b><br/><b>Invoice No:</b> INV-{order.order_no.split('-')[-1]}<br/><b>Date:</b> {order.created_at.strftime('%d-%b-%Y')}<br/><b>Status:</b> {order.status}", ParagraphStyle('RightText', parent=text_style, alignment=TA_RIGHT))
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
header_table = Table(header_data, colWidths=[3.5 * inch, 4.0 * inch])
|
||||||
|
header_table.setStyle(TableStyle([
|
||||||
|
('VALIGN', (0,0), (-1,-1), 'TOP'),
|
||||||
|
('BOTTOMPADDING', (0,0), (-1,-1), 0),
|
||||||
|
('TOPPADDING', (0,0), (-1,-1), 0),
|
||||||
|
]))
|
||||||
|
story.append(header_table)
|
||||||
|
story.append(Spacer(1, 15))
|
||||||
|
|
||||||
|
# 2. Billing & Store Coordinates
|
||||||
|
branding = get_invoice_branding_data(db)
|
||||||
|
seller_html = f"<b>{branding['companyName']}</b><br/>{branding['storeAddress']}<br/><b>GSTIN:</b> {branding['gstin']}"
|
||||||
|
|
||||||
|
# Parse shipping address
|
||||||
|
cust_addr_str = "Customer Billing Details"
|
||||||
|
if order.shipping_address_json:
|
||||||
|
try:
|
||||||
|
addr = json.loads(order.shipping_address_json)
|
||||||
|
cust_addr_str = f"<b>{addr.get('full_name')}</b><br/>{addr.get('street_address')}<br/>{addr.get('city')}, {addr.get('state')} - {addr.get('pincode')}<br/>Phone: {addr.get('phone')}"
|
||||||
|
except Exception:
|
||||||
|
cust_addr_str = order.shipping_address_json
|
||||||
|
|
||||||
|
details_data = [
|
||||||
|
[
|
||||||
|
Paragraph("SELLER (IFIXKART STORE)", label_style),
|
||||||
|
Paragraph("BILLED TO (CUSTOMER)", label_style)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
Paragraph(seller_html, text_style),
|
||||||
|
Paragraph(cust_addr_str, text_style)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
details_table = Table(details_data, colWidths=[3.75 * inch, 3.75 * inch])
|
||||||
|
details_table.setStyle(TableStyle([
|
||||||
|
('VALIGN', (0,0), (-1,-1), 'TOP'),
|
||||||
|
('BACKGROUND', (0,0), (-1,-1), colors.HexColor('#f8fafc')),
|
||||||
|
('PADDING', (0,0), (-1,-1), 10),
|
||||||
|
('BOX', (0,0), (-1,-1), 0.5, colors.HexColor('#e2e8f0')),
|
||||||
|
]))
|
||||||
|
story.append(details_table)
|
||||||
|
story.append(Spacer(1, 20))
|
||||||
|
|
||||||
|
# 3. Items Table
|
||||||
|
th_style = ParagraphStyle('TH', parent=styles['Normal'], fontName='Helvetica-Bold', fontSize=9, textColor=colors.white)
|
||||||
|
th_right = ParagraphStyle('THR', parent=styles['Normal'], fontName='Helvetica-Bold', fontSize=9, textColor=colors.white, alignment=TA_RIGHT)
|
||||||
|
|
||||||
|
td_style = ParagraphStyle('TD', parent=styles['Normal'], fontName='Helvetica', fontSize=9, textColor=colors.HexColor('#334155'))
|
||||||
|
td_right = ParagraphStyle('TDR', parent=styles['Normal'], fontName='Helvetica', fontSize=9, textColor=colors.HexColor('#334155'), alignment=TA_RIGHT)
|
||||||
|
|
||||||
|
table_data = [[
|
||||||
|
Paragraph("S.No", th_style),
|
||||||
|
Paragraph("Item SKU & Name", th_style),
|
||||||
|
Paragraph("Unit Price", th_right),
|
||||||
|
Paragraph("Qty", th_right),
|
||||||
|
Paragraph("Total Price", th_right)
|
||||||
|
]]
|
||||||
|
|
||||||
|
for idx, item in enumerate(order_items):
|
||||||
|
table_data.append([
|
||||||
|
Paragraph(str(idx + 1), td_style),
|
||||||
|
Paragraph(f"<b>{item.sku}</b> - {item.product_name}", td_style),
|
||||||
|
Paragraph(f"Rs. {float(item.unit_price):.2f}", td_right),
|
||||||
|
Paragraph(str(item.quantity), td_right),
|
||||||
|
Paragraph(f"Rs. {float(item.total_price):.2f}", td_right)
|
||||||
|
])
|
||||||
|
|
||||||
|
items_table = Table(table_data, colWidths=[0.5 * inch, 3.8 * inch, 1.1 * inch, 0.6 * inch, 1.5 * inch])
|
||||||
|
items_table.setStyle(TableStyle([
|
||||||
|
('BACKGROUND', (0,0), (-1,0), colors.HexColor('#1b2559')),
|
||||||
|
('ALIGN', (0,0), (-1,-1), 'LEFT'),
|
||||||
|
('VALIGN', (0,0), (-1,-1), 'MIDDLE'),
|
||||||
|
('BOTTOMPADDING', (0,0), (-1,-1), 8),
|
||||||
|
('TOPPADDING', (0,0), (-1,-1), 8),
|
||||||
|
('GRID', (0,0), (-1,-1), 0.5, colors.HexColor('#cbd5e1')),
|
||||||
|
]))
|
||||||
|
story.append(items_table)
|
||||||
|
story.append(Spacer(1, 15))
|
||||||
|
|
||||||
|
# 4. Totals & Tax Calculation Breakdown
|
||||||
|
subtotal = float(order.total_amount)
|
||||||
|
tax_amount = float(order.tax_amount)
|
||||||
|
gst_rate = float(branding.get("gstRate", 18.0))
|
||||||
|
half_rate = round(gst_rate / 2.0, 1)
|
||||||
|
cgst = round(tax_amount / 2, 2)
|
||||||
|
sgst = round(tax_amount / 2, 2)
|
||||||
|
igst = 0.0
|
||||||
|
|
||||||
|
totals_data = [
|
||||||
|
[Paragraph("", text_style), Paragraph("Taxable Value:", label_style), Paragraph(f"Rs. {subtotal:.2f}", td_right)],
|
||||||
|
[Paragraph("", text_style), Paragraph(f"CGST ({half_rate:.1f}%):", label_style), Paragraph(f"Rs. {cgst:.2f}", td_right)],
|
||||||
|
[Paragraph("", text_style), Paragraph(f"SGST ({half_rate:.1f}%):", label_style), Paragraph(f"Rs. {sgst:.2f}", td_right)],
|
||||||
|
[Paragraph("", text_style), Paragraph("IGST (0.0%):", label_style), Paragraph(f"Rs. {igst:.2f}", td_right)],
|
||||||
|
[Paragraph("", text_style), Paragraph("<b>Grand Total:</b>", ParagraphStyle('GrandLabel', parent=label_style, fontSize=11, textColor=colors.HexColor('#1b2559'))), Paragraph(f"<b>Rs. {float(order.final_amount):.2f}</b>", ParagraphStyle('GrandVal', parent=td_right, fontSize=11, fontName='Helvetica-Bold', textColor=colors.HexColor('#1b2559')))]
|
||||||
|
]
|
||||||
|
|
||||||
|
totals_table = Table(totals_data, colWidths=[4.2 * inch, 1.8 * inch, 1.5 * inch])
|
||||||
|
totals_table.setStyle(TableStyle([
|
||||||
|
('VALIGN', (0,0), (-1,-1), 'MIDDLE'),
|
||||||
|
('LINEBELOW', (1,0), (-1,-2), 0.5, colors.HexColor('#e2e8f0')),
|
||||||
|
('TOPPADDING', (0,0), (-1,-1), 4),
|
||||||
|
('BOTTOMPADDING', (0,0), (-1,-1), 4),
|
||||||
|
]))
|
||||||
|
story.append(totals_table)
|
||||||
|
story.append(Spacer(1, 50))
|
||||||
|
|
||||||
|
# 5. Legal Footer
|
||||||
|
footer_text = Paragraph(
|
||||||
|
"This is a computer-generated GST Tax Invoice. No signature is required. Thank you for choosing iFixKart!",
|
||||||
|
ParagraphStyle('Footer', parent=styles['Normal'], fontName='Helvetica-Oblique', fontSize=8, textColor=colors.HexColor('#94a3b8'), alignment=TA_CENTER)
|
||||||
|
)
|
||||||
|
story.append(footer_text)
|
||||||
|
|
||||||
|
doc.build(story)
|
||||||
|
pdf_bytes = buffer.getvalue()
|
||||||
|
buffer.close()
|
||||||
|
return pdf_bytes
|
||||||
|
|
||||||
|
def generate_thermal_invoice_pdf(order, customer, order_items) -> bytes:
|
||||||
|
"""
|
||||||
|
Generate an 80mm thermal roll print receipt (walking invoice format) on-the-fly.
|
||||||
|
Page width is exactly 80mm (approx 226 pt). Page length is dynamic/extended (e.g. 450 pt).
|
||||||
|
"""
|
||||||
|
buffer = BytesIO()
|
||||||
|
|
||||||
|
# 80mm roll size: 226pt wide, 450pt tall
|
||||||
|
doc = SimpleDocTemplate(
|
||||||
|
buffer,
|
||||||
|
pagesize=(226, 450),
|
||||||
|
rightMargin=10,
|
||||||
|
leftMargin=10,
|
||||||
|
topMargin=15,
|
||||||
|
bottomMargin=15
|
||||||
|
)
|
||||||
|
|
||||||
|
styles = getSampleStyleSheet()
|
||||||
|
|
||||||
|
title_style = ParagraphStyle(
|
||||||
|
'ThermalTitle',
|
||||||
|
parent=styles['Heading2'],
|
||||||
|
fontName='Helvetica-Bold',
|
||||||
|
fontSize=12,
|
||||||
|
textColor=colors.black,
|
||||||
|
alignment=TA_CENTER,
|
||||||
|
spaceAfter=2
|
||||||
|
)
|
||||||
|
|
||||||
|
subtitle_style = ParagraphStyle(
|
||||||
|
'ThermalSubtitle',
|
||||||
|
parent=styles['Normal'],
|
||||||
|
fontName='Helvetica-Bold',
|
||||||
|
fontSize=8,
|
||||||
|
textColor=colors.black,
|
||||||
|
alignment=TA_CENTER,
|
||||||
|
spaceAfter=10
|
||||||
|
)
|
||||||
|
|
||||||
|
text_style = ParagraphStyle(
|
||||||
|
'ThermalText',
|
||||||
|
parent=styles['Normal'],
|
||||||
|
fontName='Helvetica',
|
||||||
|
fontSize=7,
|
||||||
|
textColor=colors.black,
|
||||||
|
spaceAfter=2
|
||||||
|
)
|
||||||
|
|
||||||
|
text_right = ParagraphStyle(
|
||||||
|
'ThermalTextRight',
|
||||||
|
parent=text_style,
|
||||||
|
alignment=TA_RIGHT
|
||||||
|
)
|
||||||
|
|
||||||
|
story = []
|
||||||
|
|
||||||
|
# 1. Header
|
||||||
|
thermal_logo = get_invoice_logo_element(max_w=1.8 * inch, max_h=0.6 * inch)
|
||||||
|
if thermal_logo:
|
||||||
|
thermal_logo.hAlign = 'CENTER'
|
||||||
|
story.append(thermal_logo)
|
||||||
|
story.append(Spacer(1, 4))
|
||||||
|
|
||||||
|
story.append(Paragraph("iFixKart Retail POS", title_style))
|
||||||
|
story.append(Paragraph("Solutions Pvt Ltd - Store #1<br/>100 Tech Arcade Main Road, Chennai<br/>GSTIN: 33AAFCI8824J1ZP", subtitle_style))
|
||||||
|
story.append(Spacer(1, 5))
|
||||||
|
|
||||||
|
# 2. Transaction Meta Info
|
||||||
|
story.append(Paragraph(f"<b>Invoice:</b> walk_inv_{order.order_id[:8]}", text_style))
|
||||||
|
story.append(Paragraph(f"<b>Order No:</b> {order.order_no}", text_style))
|
||||||
|
story.append(Paragraph(f"<b>Date:</b> {order.created_at.strftime('%d-%b-%Y %H:%M')}", text_style))
|
||||||
|
payment_method = getattr(order, "payment_method", None) or "COD"
|
||||||
|
story.append(Paragraph(f"<b>Payment:</b> {payment_method} ({order.payment_status})", text_style))
|
||||||
|
story.append(Spacer(1, 10))
|
||||||
|
|
||||||
|
# 3. Item List Header
|
||||||
|
item_header = [
|
||||||
|
[Paragraph("<b>Item Description</b>", text_style), Paragraph("<b>Qty</b>", text_right), Paragraph("<b>Total</b>", text_right)]
|
||||||
|
]
|
||||||
|
|
||||||
|
# 4. Item List Rows
|
||||||
|
for item in order_items:
|
||||||
|
# Truncate long names to save receipt slip width
|
||||||
|
short_name = item.product_name[:24] + '..' if len(item.product_name) > 26 else item.product_name
|
||||||
|
item_header.append([
|
||||||
|
Paragraph(f"{item.sku}<br/>{short_name}", text_style),
|
||||||
|
Paragraph(str(item.quantity), text_right),
|
||||||
|
Paragraph(f"Rs. {float(item.total_price):.1f}", text_right)
|
||||||
|
])
|
||||||
|
|
||||||
|
# Table Widths: item=120pt, qty=30pt, total=56pt -> Total 206pt
|
||||||
|
items_table = Table(item_header, colWidths=[120, 30, 56])
|
||||||
|
items_table.setStyle(TableStyle([
|
||||||
|
('LINEBELOW', (0,0), (-1,0), 0.5, colors.black),
|
||||||
|
('LINEBELOW', (0,-1), (-1,-1), 0.5, colors.black),
|
||||||
|
('PADDING', (0,0), (-1,-1), 3),
|
||||||
|
('VALIGN', (0,0), (-1,-1), 'MIDDLE'),
|
||||||
|
]))
|
||||||
|
story.append(items_table)
|
||||||
|
story.append(Spacer(1, 8))
|
||||||
|
|
||||||
|
# 5. Taxes & Total Breakout
|
||||||
|
tax_amount = float(order.tax_amount)
|
||||||
|
cgst = round(tax_amount / 2, 2)
|
||||||
|
sgst = round(tax_amount / 2, 2)
|
||||||
|
|
||||||
|
totals_data = [
|
||||||
|
[Paragraph("Taxable Value:", text_style), Paragraph(f"Rs. {float(order.total_amount):.2f}", text_right)],
|
||||||
|
[Paragraph("CGST (9%):", text_style), Paragraph(f"Rs. {cgst:.2f}", text_right)],
|
||||||
|
[Paragraph("SGST (9%):", text_style), Paragraph(f"Rs. {sgst:.2f}", text_right)],
|
||||||
|
[Paragraph("<b>Grand Total:</b>", ParagraphStyle('GrandLabelThermal', parent=text_style, fontName='Helvetica-Bold', fontSize=9)), Paragraph(f"<b>Rs. {float(order.final_amount):.2f}</b>", ParagraphStyle('GrandValThermal', parent=text_right, fontName='Helvetica-Bold', fontSize=9))]
|
||||||
|
]
|
||||||
|
|
||||||
|
totals_table = Table(totals_data, colWidths=[110, 96])
|
||||||
|
totals_table.setStyle(TableStyle([
|
||||||
|
('LINEABOVE', (0,-1), (-1,-1), 0.5, colors.black),
|
||||||
|
('PADDING', (0,0), (-1,-1), 2),
|
||||||
|
]))
|
||||||
|
story.append(totals_table)
|
||||||
|
story.append(Spacer(1, 20))
|
||||||
|
|
||||||
|
# 6. Thermal Footer
|
||||||
|
story.append(Paragraph("Thank you for your purchase!", ParagraphStyle('F1', parent=text_style, fontName='Helvetica-Bold', alignment=TA_CENTER)))
|
||||||
|
story.append(Paragraph("For support: support@ifixkart.com", ParagraphStyle('F2', parent=text_style, alignment=TA_CENTER)))
|
||||||
|
|
||||||
|
doc.build(story)
|
||||||
|
pdf_bytes = buffer.getvalue()
|
||||||
|
buffer.close()
|
||||||
|
return pdf_bytes
|
||||||
378
app/core/media/media_garbage_collector.py
Normal file
|
|
@ -0,0 +1,378 @@
|
||||||
|
"""
|
||||||
|
@module media_garbage_collector (Backend/app/core/media/media_garbage_collector.py)
|
||||||
|
@purpose Hardened Production Media Management & Asynchronous Garbage Collector with state machine tracking, 2-step transactions, path traversal validation, and conservative 4-class reconciliation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
from threading import Lock
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import select, text
|
||||||
|
|
||||||
|
from app.models.FileUploadModel import FileUpload
|
||||||
|
from app.models.SettingModel import Setting
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Absolute Upload Root Path Safety Constraint
|
||||||
|
BACKEND_ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
UPLOAD_ROOT = (BACKEND_ROOT / "uploads").resolve()
|
||||||
|
|
||||||
|
# In-memory per-file mutex lock registry to coordinate cleanup and re-attachment
|
||||||
|
_PER_FILE_LOCKS: Dict[str, Lock] = {}
|
||||||
|
_LOCKS_MUTEX = Lock()
|
||||||
|
|
||||||
|
# Cache for untracked disk files seen during reconciliation
|
||||||
|
_UNTRACKED_DISK_FILES: Dict[str, datetime] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_per_file_lock(file_id: str) -> Lock:
|
||||||
|
"""Returns a thread-safe mutex lock for a specific file_id."""
|
||||||
|
with _LOCKS_MUTEX:
|
||||||
|
if file_id not in _PER_FILE_LOCKS:
|
||||||
|
_PER_FILE_LOCKS[file_id] = Lock()
|
||||||
|
return _PER_FILE_LOCKS[file_id]
|
||||||
|
|
||||||
|
|
||||||
|
def get_media_settings(db: Session) -> Dict[str, Any]:
|
||||||
|
"""Extracts media configuration settings from the database with default fallbacks."""
|
||||||
|
settings = {
|
||||||
|
"media_store_original": True,
|
||||||
|
"media_max_size_mb": 20,
|
||||||
|
"media_webp_quality": 88,
|
||||||
|
"media_cleanup_enabled": True,
|
||||||
|
"media_cleanup_grace_hours": 24,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
results = db.execute(
|
||||||
|
select(Setting).where(Setting.setting_key.in_(settings.keys()))
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
for s in results:
|
||||||
|
val = s.setting_value
|
||||||
|
if isinstance(val, dict) and "value" in val:
|
||||||
|
val = val["value"]
|
||||||
|
settings[s.setting_key] = val
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"Could not read media settings from DB, using defaults: {exc}")
|
||||||
|
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
def safe_resolve_path(rel_path: Optional[str]) -> Optional[Path]:
|
||||||
|
"""
|
||||||
|
Path Traversal Security Constraint:
|
||||||
|
Ensures that any file path resolves strictly inside UPLOAD_ROOT.
|
||||||
|
"""
|
||||||
|
if not rel_path or not rel_path.strip():
|
||||||
|
return None
|
||||||
|
|
||||||
|
clean = rel_path.lstrip("/")
|
||||||
|
resolved = (BACKEND_ROOT / clean).resolve()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if resolved.is_relative_to(UPLOAD_ROOT):
|
||||||
|
return resolved
|
||||||
|
except AttributeError:
|
||||||
|
# Python < 3.9 fallback
|
||||||
|
if str(resolved).startswith(str(UPLOAD_ROOT)):
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
logger.error(f"SECURITY ALERT: Path traversal attempt blocked for path: {rel_path}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def attach_file(db: Session, file_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
Synchronous Re-Attachment Recovery:
|
||||||
|
Flips an ORPHANED or PENDING_DELETE file back to ACTIVE when re-attached.
|
||||||
|
Re-attaching a DELETED file raises ValueError.
|
||||||
|
"""
|
||||||
|
lock = get_per_file_lock(file_id)
|
||||||
|
with lock:
|
||||||
|
record = db.execute(
|
||||||
|
select(FileUpload).where(FileUpload.file_id == file_id).with_for_update()
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
if not record:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if record.status == "DELETED":
|
||||||
|
raise ValueError(f"File {file_id} assets have been permanently deleted and cannot be re-attached.")
|
||||||
|
|
||||||
|
if record.status in ("ORPHANED", "PENDING_DELETE", "INCONSISTENT"):
|
||||||
|
record.status = "ACTIVE"
|
||||||
|
record.orphaned_at = None
|
||||||
|
record.cleanup_claimed_at = None
|
||||||
|
record.cleanup_started_at = None
|
||||||
|
record.last_cleanup_error = None
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"Re-attached file {file_id}: status restored to ACTIVE.")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def mark_images_orphaned(db: Session, removed_file_ids: List[str]):
|
||||||
|
"""
|
||||||
|
Update Trigger: Marks removed file_ids as ORPHANED candidates.
|
||||||
|
Does not reset orphaned_at if file is already ORPHANED.
|
||||||
|
"""
|
||||||
|
if not removed_file_ids:
|
||||||
|
return
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
records = db.execute(
|
||||||
|
select(FileUpload).where(FileUpload.file_id.in_(removed_file_ids))
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
for record in records:
|
||||||
|
if record.status == "ACTIVE":
|
||||||
|
record.status = "ORPHANED"
|
||||||
|
record.orphaned_at = now
|
||||||
|
logger.info(f"Marked file {record.file_id} as ORPHANED.")
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def count_db_references(db: Session, file_record: FileUpload) -> int:
|
||||||
|
"""
|
||||||
|
Authoritative DB Reference Counter:
|
||||||
|
Checks if a file_id or its paths are referenced in products, variants, categories, brands, or storefront sections.
|
||||||
|
"""
|
||||||
|
file_id = file_record.file_id
|
||||||
|
paths = [p for p in [file_record.webp_path, file_record.storage_path, file_record.raw_path] if p]
|
||||||
|
|
||||||
|
total_refs = 0
|
||||||
|
|
||||||
|
# 1. Product Images
|
||||||
|
try:
|
||||||
|
query = text("""
|
||||||
|
SELECT COUNT(*) FROM product_images
|
||||||
|
WHERE image_id = :fid OR image_url IN :paths
|
||||||
|
""")
|
||||||
|
total_refs += db.execute(query, {"fid": file_id, "paths": tuple(paths or ["__none__"])}).scalar() or 0
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 2. Product Variant Images
|
||||||
|
try:
|
||||||
|
query = text("""
|
||||||
|
SELECT COUNT(*) FROM product_variant_images
|
||||||
|
WHERE image_url IN :paths
|
||||||
|
""")
|
||||||
|
total_refs += db.execute(query, {"paths": tuple(paths or ["__none__"])}).scalar() or 0
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 3. Categories
|
||||||
|
try:
|
||||||
|
query = text("""
|
||||||
|
SELECT COUNT(*) FROM categories
|
||||||
|
WHERE image_url IN :paths OR icon_url IN :paths
|
||||||
|
""")
|
||||||
|
total_refs += db.execute(query, {"paths": tuple(paths or ["__none__"])}).scalar() or 0
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 4. Brands
|
||||||
|
try:
|
||||||
|
query = text("""
|
||||||
|
SELECT COUNT(*) FROM brands
|
||||||
|
WHERE logo_url IN :paths
|
||||||
|
""")
|
||||||
|
total_refs += db.execute(query, {"paths": tuple(paths or ["__none__"])}).scalar() or 0
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return total_refs
|
||||||
|
|
||||||
|
|
||||||
|
def process_async_media_cleanup(db: Session):
|
||||||
|
"""
|
||||||
|
Asynchronous Garbage Collector:
|
||||||
|
Transaction 1: Claims candidates -> PENDING_DELETE (SKIP LOCKED).
|
||||||
|
Per-File Lock -> FINAL Reference Check -> Idempotent Physical Deletion -> Transaction 2: DELETED.
|
||||||
|
"""
|
||||||
|
settings = get_media_settings(db)
|
||||||
|
if not settings.get("media_cleanup_enabled", True):
|
||||||
|
logger.info("Media cleanup is currently disabled by Admin kill switch.")
|
||||||
|
return
|
||||||
|
|
||||||
|
grace_hours = int(settings.get("media_cleanup_grace_hours", 24))
|
||||||
|
grace_threshold = datetime.utcnow() - timedelta(hours=grace_hours)
|
||||||
|
|
||||||
|
# 1. Transaction 1: Claim Candidates via FOR UPDATE SKIP LOCKED
|
||||||
|
candidates = []
|
||||||
|
try:
|
||||||
|
query = (
|
||||||
|
select(FileUpload)
|
||||||
|
.where(
|
||||||
|
FileUpload.status == "ORPHANED",
|
||||||
|
FileUpload.orphaned_at <= grace_threshold,
|
||||||
|
FileUpload.cleanup_attempts < 5
|
||||||
|
)
|
||||||
|
.with_for_update(skip_locked=True)
|
||||||
|
.limit(50)
|
||||||
|
)
|
||||||
|
candidates = db.execute(query).scalars().all()
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
for record in candidates:
|
||||||
|
record.status = "PENDING_DELETE"
|
||||||
|
record.cleanup_claimed_at = now
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
except Exception as exc:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"Error claiming candidate media records for cleanup: {exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2. Process each claimed candidate with per-file lock & final reference check
|
||||||
|
for record in candidates:
|
||||||
|
file_id = record.file_id
|
||||||
|
lock = get_per_file_lock(file_id)
|
||||||
|
|
||||||
|
with lock:
|
||||||
|
# Re-fetch record inside lock
|
||||||
|
rec = db.execute(select(FileUpload).where(FileUpload.file_id == file_id)).scalar_one_or_none()
|
||||||
|
if not rec or rec.status != "PENDING_DELETE":
|
||||||
|
continue
|
||||||
|
|
||||||
|
# FINAL PRE-DELETE REFERENCE CHECK
|
||||||
|
ref_count = count_db_references(db, rec)
|
||||||
|
if ref_count > 0:
|
||||||
|
logger.info(f"Final Pre-Delete Check: File {file_id} is referenced by {ref_count} entities. Aborting delete & reverting to ACTIVE.")
|
||||||
|
rec.status = "ACTIVE"
|
||||||
|
rec.orphaned_at = None
|
||||||
|
rec.cleanup_claimed_at = None
|
||||||
|
db.commit()
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Proceed with physical file deletion
|
||||||
|
rec.cleanup_started_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
targets = [
|
||||||
|
rec.raw_path,
|
||||||
|
rec.webp_path,
|
||||||
|
rec.thumbnail_path,
|
||||||
|
rec.medium_path,
|
||||||
|
rec.large_path,
|
||||||
|
rec.storage_path
|
||||||
|
]
|
||||||
|
|
||||||
|
deletion_errors = []
|
||||||
|
for rel in set(filter(None, targets)):
|
||||||
|
resolved = safe_resolve_path(rel)
|
||||||
|
if resolved:
|
||||||
|
try:
|
||||||
|
if resolved.exists():
|
||||||
|
resolved.unlink()
|
||||||
|
logger.info(f"Unlinked file asset: {resolved}")
|
||||||
|
except Exception as err:
|
||||||
|
error_msg = f"Failed to delete physical file {resolved}: {err}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
deletion_errors.append(error_msg)
|
||||||
|
|
||||||
|
if deletion_errors:
|
||||||
|
rec.cleanup_attempts += 1
|
||||||
|
rec.last_cleanup_error = "; ".join(deletion_errors)
|
||||||
|
rec.status = "ORPHANED"
|
||||||
|
rec.cleanup_claimed_at = None
|
||||||
|
rec.cleanup_started_at = None
|
||||||
|
db.commit()
|
||||||
|
else:
|
||||||
|
rec.status = "DELETED"
|
||||||
|
rec.deleted_at = datetime.utcnow()
|
||||||
|
rec.last_cleanup_error = None
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"Successfully cleaned up and marked file {file_id} as DELETED.")
|
||||||
|
|
||||||
|
|
||||||
|
def reconcile_media_database(db: Session):
|
||||||
|
"""
|
||||||
|
Conservative 4-Class Daily Media Reconciliation Worker:
|
||||||
|
Class 1 (Known + Referenced) -> ACTIVE
|
||||||
|
Class 2 (Known + Unreferenced) -> ORPHANED (preserves existing orphaned_at)
|
||||||
|
Class 3 (Known DB + Missing Disk) -> INCONSISTENT
|
||||||
|
Class 4 (Unknown Disk File) -> UNTRACKED (purged only if age > 48 hours)
|
||||||
|
"""
|
||||||
|
logger.info("Starting Conservative Media Reconciliation Worker...")
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
# 1. Scan DB file_uploads
|
||||||
|
uploads = db.execute(select(FileUpload)).scalars().all()
|
||||||
|
|
||||||
|
for rec in uploads:
|
||||||
|
if rec.status == "DELETED":
|
||||||
|
continue
|
||||||
|
|
||||||
|
ref_count = count_db_references(db, rec)
|
||||||
|
|
||||||
|
# Check disk existence
|
||||||
|
main_path = safe_resolve_path(rec.webp_path or rec.storage_path or rec.raw_path)
|
||||||
|
disk_exists = main_path and main_path.exists()
|
||||||
|
|
||||||
|
if disk_exists and ref_count > 0:
|
||||||
|
# Class 1: Known + Referenced
|
||||||
|
if rec.status != "ACTIVE":
|
||||||
|
rec.status = "ACTIVE"
|
||||||
|
rec.orphaned_at = None
|
||||||
|
elif disk_exists and ref_count == 0:
|
||||||
|
# Class 2: Known + Unreferenced
|
||||||
|
if rec.status == "ACTIVE":
|
||||||
|
rec.status = "ORPHANED"
|
||||||
|
rec.orphaned_at = now # Set clock
|
||||||
|
elif rec.status == "ORPHANED":
|
||||||
|
pass # Preserve existing orphaned_at timestamp!
|
||||||
|
elif not disk_exists and rec.status != "DELETED":
|
||||||
|
# Class 3: Known DB + Missing Disk
|
||||||
|
rec.status = "INCONSISTENT"
|
||||||
|
rec.last_cleanup_error = "Physical file asset missing from server disk storage."
|
||||||
|
|
||||||
|
rec.last_reconciled_at = now
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# 2. Class 4: Scan Unknown Disk Files (Constrained to UPLOAD_ROOT)
|
||||||
|
if UPLOAD_ROOT.exists():
|
||||||
|
for root, _, files in os.walk(UPLOAD_ROOT):
|
||||||
|
for fname in files:
|
||||||
|
fpath = Path(root) / fname
|
||||||
|
try:
|
||||||
|
rel_path = f"/uploads/{fpath.relative_to(UPLOAD_ROOT)}"
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if path is known in file_uploads
|
||||||
|
is_known = db.execute(
|
||||||
|
select(FileUpload).where(
|
||||||
|
(FileUpload.webp_path == rel_path) |
|
||||||
|
(FileUpload.raw_path == rel_path) |
|
||||||
|
(FileUpload.storage_path == rel_path)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
if not is_known:
|
||||||
|
if str(fpath) not in _UNTRACKED_DISK_FILES:
|
||||||
|
_UNTRACKED_DISK_FILES[str(fpath)] = now
|
||||||
|
else:
|
||||||
|
first_seen = _UNTRACKED_DISK_FILES[str(fpath)]
|
||||||
|
# Purge untracked file only if age > 48 hours
|
||||||
|
if (now - first_seen) > timedelta(hours=48):
|
||||||
|
try:
|
||||||
|
fpath.unlink()
|
||||||
|
logger.info(f"Class 4 Untracked Cleanup: Unlinked 48h+ old untracked file: {fpath}")
|
||||||
|
del _UNTRACKED_DISK_FILES[str(fpath)]
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to delete untracked file {fpath}: {e}")
|
||||||
|
|
||||||
|
logger.info("Media Reconciliation Worker completed successfully.")
|
||||||
0
app/core/middleware/__init__.py
Normal file
7
app/core/middleware/audit_context.py
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import contextvars
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
request_id_var: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar("request_id", default=None)
|
||||||
|
user_id_var: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar("user_id", default=None)
|
||||||
|
ip_address_var: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar("ip_address", default=None)
|
||||||
|
user_agent_var: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar("user_agent", default=None)
|
||||||
32
app/core/middleware/audit_middleware.py
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
from fastapi import Request
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from app.core.middleware.audit_context import request_id_var, user_id_var, ip_address_var, user_agent_var
|
||||||
|
import ulid
|
||||||
|
|
||||||
|
class AuditMiddleware(BaseHTTPMiddleware):
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
# 1. Set IP Address
|
||||||
|
x_forwarded_for = request.headers.get("x-forwarded-for")
|
||||||
|
ip = x_forwarded_for.split(",")[0].strip() if x_forwarded_for else (request.client.host if request.client else "127.0.0.1")
|
||||||
|
|
||||||
|
token_ip = ip_address_var.set(ip)
|
||||||
|
token_ua = user_agent_var.set(request.headers.get("user-agent", ""))
|
||||||
|
|
||||||
|
# 2. Set request ID
|
||||||
|
req_id = getattr(request.state, "request_id", None)
|
||||||
|
if not req_id:
|
||||||
|
req_id = str(ulid.ULID())
|
||||||
|
request.state.request_id = req_id
|
||||||
|
token_req = request_id_var.set(req_id)
|
||||||
|
|
||||||
|
# Default user_id to None
|
||||||
|
token_user = user_id_var.set(None)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await call_next(request)
|
||||||
|
return response
|
||||||
|
finally:
|
||||||
|
ip_address_var.reset(token_ip)
|
||||||
|
user_agent_var.reset(token_ua)
|
||||||
|
request_id_var.reset(token_req)
|
||||||
|
user_id_var.reset(token_user)
|
||||||
57
app/core/middleware/kill_switch_middleware.py
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
from fastapi import Request
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.core.database.db_session import SessionLocal
|
||||||
|
from app.models.SettingModel import Setting
|
||||||
|
from jose import jwt, JWTError
|
||||||
|
from app.core.config.Config import settings, load_public_key_bytes
|
||||||
|
|
||||||
|
MASTER_ADMIN_EMAIL = "adithiyan.elan@gmail.com"
|
||||||
|
|
||||||
|
class KillSwitchMiddleware(BaseHTTPMiddleware):
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
# Allow docs and OpenAPI schemas to always bypass
|
||||||
|
if request.url.path in ["/docs", "/redoc", "/openapi.json"]:
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
# Open db session safely
|
||||||
|
db: Session = SessionLocal()
|
||||||
|
global_disable = False
|
||||||
|
try:
|
||||||
|
# Query the GLOBAL_DISABLE setting key
|
||||||
|
item = db.query(Setting).filter(Setting.setting_key == "GLOBAL_DISABLE").first()
|
||||||
|
if item:
|
||||||
|
# Expecting value like {"enabled": true}
|
||||||
|
val = item.setting_value
|
||||||
|
if isinstance(val, dict) and val.get("enabled") is True:
|
||||||
|
global_disable = True
|
||||||
|
except Exception:
|
||||||
|
# If database lookup fails, fail secure or run normally?
|
||||||
|
# Let's run normally to prevent site breakdown during DB connection blips,
|
||||||
|
# but log the failure in real setups.
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
if not global_disable:
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
# Bypass check for MASTER ADMIN
|
||||||
|
auth_header = request.headers.get("Authorization")
|
||||||
|
if auth_header and auth_header.startswith("Bearer "):
|
||||||
|
token = auth_header.split(" ")[1]
|
||||||
|
try:
|
||||||
|
public_key = load_public_key_bytes()
|
||||||
|
payload = jwt.decode(token, public_key, algorithms=[settings.ALGORITHM])
|
||||||
|
email = payload.get("email")
|
||||||
|
if email == MASTER_ADMIN_EMAIL:
|
||||||
|
# Let master bypass the lock
|
||||||
|
return await call_next(request)
|
||||||
|
except JWTError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=403,
|
||||||
|
content={"detail": "SYSTEM DISABLED BY EMERGENCY KILL SWITCH"}
|
||||||
|
)
|
||||||
54
app/core/middleware/security_middleware.py
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
from fastapi import Request, HTTPException, status
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
from app.core.config.Config import settings
|
||||||
|
|
||||||
|
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
response = await call_next(request)
|
||||||
|
is_docs = request.url.path in ["/docs", "/redoc", "/openapi.json"]
|
||||||
|
|
||||||
|
# Enforce HSTS
|
||||||
|
if request.url.hostname not in ["localhost", "127.0.0.1"]:
|
||||||
|
response.headers["Strict-Transport-Security"] = "max-age=63072000; includeSubDomains; preload"
|
||||||
|
|
||||||
|
if not is_docs:
|
||||||
|
response.headers["X-Frame-Options"] = "DENY"
|
||||||
|
|
||||||
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||||
|
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||||
|
response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()"
|
||||||
|
|
||||||
|
# CSP policies: only apply to docs or frontend pages, NEVER block API connections
|
||||||
|
if is_docs:
|
||||||
|
response.headers["Content-Security-Policy"] = "default-src * 'unsafe-inline' 'unsafe-eval' data: blob:;"
|
||||||
|
elif not request.url.path.startswith("/api/"):
|
||||||
|
response.headers["Content-Security-Policy"] = (
|
||||||
|
"default-src 'self'; script-src 'self'; connect-src 'self'; "
|
||||||
|
"img-src 'self' data:; style-src 'self' 'unsafe-inline';"
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
class RequestSizeLimitMiddleware(BaseHTTPMiddleware):
|
||||||
|
def __init__(self, app, max_bytes: int = 10 * 1024 * 1024): # Default 10MB
|
||||||
|
super().__init__(app)
|
||||||
|
self.max_bytes = max_bytes
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
# Read content length header
|
||||||
|
content_length = request.headers.get("content-length")
|
||||||
|
if content_length:
|
||||||
|
try:
|
||||||
|
length = int(content_length)
|
||||||
|
if length > self.max_bytes:
|
||||||
|
max_mb = self.max_bytes // (1024 * 1024)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||||
|
content={"detail": f"Payload too large. Maximum size allowed is {max_mb}MB."}
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Also limit reading chunks if chunked transfer encoding is used
|
||||||
|
return await call_next(request)
|
||||||
30
app/core/middleware/trace_middleware.py
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import contextvars
|
||||||
|
from fastapi import Request
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
import ulid
|
||||||
|
|
||||||
|
# ContextVar to hold request_id globally in the thread/coroutine context
|
||||||
|
request_id_var = contextvars.ContextVar("request_id", default="")
|
||||||
|
|
||||||
|
def get_request_id() -> str:
|
||||||
|
return request_id_var.get()
|
||||||
|
|
||||||
|
class RequestTraceMiddleware(BaseHTTPMiddleware):
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
# Generate new trace ID
|
||||||
|
req_id = str(ulid.ULID())
|
||||||
|
|
||||||
|
# Set context variable
|
||||||
|
token = request_id_var.set(req_id)
|
||||||
|
|
||||||
|
# Store in state for easy route access
|
||||||
|
request.state.request_id = req_id
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await call_next(request)
|
||||||
|
# Append to response headers
|
||||||
|
response.headers["X-Request-ID"] = req_id
|
||||||
|
return response
|
||||||
|
finally:
|
||||||
|
# Reset context variable to prevent leakage
|
||||||
|
request_id_var.reset(token)
|
||||||
234
app/core/payment_orchestrator.py
Normal file
|
|
@ -0,0 +1,234 @@
|
||||||
|
import time
|
||||||
|
import hmac
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
import ulid
|
||||||
|
|
||||||
|
from app.core.config.Config import settings
|
||||||
|
from app.models.OrderModel import Order
|
||||||
|
from app.models.PaymentLedgerModel import PaymentLedger
|
||||||
|
from app.models.WebhookReceiptModel import WebhookReceipt
|
||||||
|
from app.core.razorpay import razorpay_service
|
||||||
|
from app.services.OrderService import OrderService
|
||||||
|
|
||||||
|
logger = logging.getLogger("app.payment_orchestrator")
|
||||||
|
|
||||||
|
class PaymentOrchestrator:
|
||||||
|
|
||||||
|
def initiate_payment(self, db: Session, order_id: str, client_ip: str = None, user_agent: str = None) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Initiates a new payment attempt for an e-commerce order.
|
||||||
|
Row-locks the order and verifies status.
|
||||||
|
"""
|
||||||
|
order = db.query(Order).filter(Order.order_id == order_id).with_for_update().first()
|
||||||
|
if not order:
|
||||||
|
return {"error": "Order not found", "status_code": 404}
|
||||||
|
|
||||||
|
if order.payment_status == "PAYMENT_CAPTURED":
|
||||||
|
return {"error": "Order is already paid", "status_code": 400}
|
||||||
|
|
||||||
|
attempt_count = db.query(PaymentLedger).filter(PaymentLedger.order_id == order_id).count()
|
||||||
|
if attempt_count >= 5:
|
||||||
|
return {"error": "Max payment attempts exceeded for this order", "status_code": 429}
|
||||||
|
|
||||||
|
amount_paise = int(round(float(order.final_amount) * 100))
|
||||||
|
|
||||||
|
# Handle 0-amount orders directly without invoking Razorpay
|
||||||
|
if amount_paise <= 0:
|
||||||
|
invoice = OrderService.confirm_ecommerce_payment(db, order, payment_method="ZERO_AMOUNT")
|
||||||
|
return {
|
||||||
|
"order_id": order_id,
|
||||||
|
"order_no": order.order_no,
|
||||||
|
"rzp_order_id": f"ZERO_{order_id}",
|
||||||
|
"rzp_key_id": settings.RAZORPAY_KEY_ID or "",
|
||||||
|
"amount": 0,
|
||||||
|
"currency": "INR",
|
||||||
|
"security_token": "ZERO_AMOUNT_SECURE",
|
||||||
|
"status": "SUCCESS",
|
||||||
|
"zero_amount": True,
|
||||||
|
"invoice_id": invoice.invoice_id if invoice else None,
|
||||||
|
"invoice_no": invoice.invoice_no if invoice else None,
|
||||||
|
"status_code": 200
|
||||||
|
}
|
||||||
|
|
||||||
|
receipt = f"order_{order.order_no}_{int(time.time())}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
rzp_order = razorpay_service.create_order(amount_paise, receipt)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Gateway Failure: {str(e)}")
|
||||||
|
return {"error": f"Payment gateway currently unavailable: {str(e)}", "status_code": 503}
|
||||||
|
|
||||||
|
token_payload = f"{order_id}|{amount_paise}|{int(time.time())}"
|
||||||
|
security_token = hmac.new(
|
||||||
|
settings.SECRET_KEY.encode('utf-8'),
|
||||||
|
token_payload.encode('utf-8'),
|
||||||
|
hashlib.sha256
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
payment_entry = PaymentLedger(
|
||||||
|
payment_id=str(ulid.ULID()),
|
||||||
|
order_id=order_id,
|
||||||
|
provider="RAZORPAY",
|
||||||
|
razorpay_order_id=rzp_order['id'],
|
||||||
|
amount=order.final_amount,
|
||||||
|
currency="INR",
|
||||||
|
status="PENDING",
|
||||||
|
raw_response_json=json.dumps(rzp_order)
|
||||||
|
)
|
||||||
|
db.add(payment_entry)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"order_id": order_id,
|
||||||
|
"rzp_order_id": rzp_order['id'],
|
||||||
|
"rzp_key_id": settings.RAZORPAY_KEY_ID,
|
||||||
|
"amount": amount_paise,
|
||||||
|
"currency": "INR",
|
||||||
|
"security_token": security_token,
|
||||||
|
"status_code": 200
|
||||||
|
}
|
||||||
|
|
||||||
|
def cancel_payment(self, db: Session, order_id: str, reason: str = "Payment cancelled by user") -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Cancels pending order payment and releases reserved stock.
|
||||||
|
"""
|
||||||
|
order = db.query(Order).filter(Order.order_id == order_id).with_for_update().first()
|
||||||
|
if not order:
|
||||||
|
return {"error": "Order not found", "status_code": 404}
|
||||||
|
|
||||||
|
cancelled_order = OrderService.cancel_ecommerce_order(db, order, reason=reason)
|
||||||
|
return {
|
||||||
|
"status": "SUCCESS",
|
||||||
|
"message": "Payment session cancelled and order status updated",
|
||||||
|
"order_id": cancelled_order.order_id,
|
||||||
|
"order_status": cancelled_order.status,
|
||||||
|
"payment_status": cancelled_order.payment_status
|
||||||
|
}
|
||||||
|
|
||||||
|
def verify_payment(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
order_id: str,
|
||||||
|
rzp_order_id: str,
|
||||||
|
rzp_payment_id: str,
|
||||||
|
rzp_signature: str
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Verifies client-side signature + executes secondary server-side fetch from Razorpay API.
|
||||||
|
"""
|
||||||
|
order = db.query(Order).filter(Order.order_id == order_id).with_for_update().first()
|
||||||
|
if not order:
|
||||||
|
return {"error": "Order not found", "status_code": 404}
|
||||||
|
|
||||||
|
if order.payment_status == "PAYMENT_CAPTURED":
|
||||||
|
return {"status": "SUCCESS", "message": "Payment already captured"}
|
||||||
|
|
||||||
|
payment_entry = (
|
||||||
|
db.query(PaymentLedger)
|
||||||
|
.filter(PaymentLedger.order_id == order_id, PaymentLedger.razorpay_order_id == rzp_order_id)
|
||||||
|
.with_for_update()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not payment_entry:
|
||||||
|
return {"error": "Payment session not found for this order", "status_code": 404}
|
||||||
|
|
||||||
|
# 1. Cryptographic Signature Verification
|
||||||
|
sig_valid = razorpay_service.verify_payment_signature(rzp_order_id, rzp_payment_id, rzp_signature)
|
||||||
|
if not sig_valid:
|
||||||
|
payment_entry.status = "FAILED"
|
||||||
|
db.commit()
|
||||||
|
return {"error": "Cryptographic payment signature mismatch", "status_code": 400}
|
||||||
|
|
||||||
|
# 2. Server-side fetch from Razorpay API (Replay Protection)
|
||||||
|
try:
|
||||||
|
rzp_payment = razorpay_service.fetch_payment(rzp_payment_id)
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"Failed to fetch payment details from provider: {str(e)}", "status_code": 502}
|
||||||
|
|
||||||
|
# Verify amount & order match
|
||||||
|
expected_paise = int(round(float(order.final_amount) * 100))
|
||||||
|
if int(rzp_payment.get("amount", 0)) != expected_paise:
|
||||||
|
logger.error(f"Amount mismatch! Expected {expected_paise}, got {rzp_payment.get('amount')}")
|
||||||
|
return {"error": "Transaction amount integrity failure", "status_code": 400}
|
||||||
|
|
||||||
|
if rzp_payment.get("order_id") != rzp_order_id:
|
||||||
|
logger.error("Order ID mismatch detected!")
|
||||||
|
return {"error": "Security violation: Cross-order replay attempt", "status_code": 403}
|
||||||
|
|
||||||
|
# 3. Finalize capture
|
||||||
|
payment_entry.transaction_ref = rzp_payment_id
|
||||||
|
payment_entry.razorpay_signature = rzp_signature
|
||||||
|
payment_entry.status = "CAPTURED"
|
||||||
|
payment_entry.raw_response_json = json.dumps(rzp_payment)
|
||||||
|
|
||||||
|
invoice = OrderService.confirm_ecommerce_payment(db, order, payment_method="RAZORPAY")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "SUCCESS",
|
||||||
|
"message": "Payment verified and order confirmed successfully",
|
||||||
|
"order_id": order.order_id,
|
||||||
|
"order_no": order.order_no,
|
||||||
|
"invoice_id": invoice.invoice_id if invoice else None,
|
||||||
|
"invoice_no": invoice.invoice_no if invoice else None
|
||||||
|
}
|
||||||
|
|
||||||
|
def process_webhook(self, db: Session, raw_body: bytes, signature: str) -> bool:
|
||||||
|
"""
|
||||||
|
Processes incoming Razorpay webhooks asynchronously with idempotency check.
|
||||||
|
"""
|
||||||
|
if not razorpay_service.verify_webhook_signature(raw_body, signature):
|
||||||
|
logger.warning("Invalid webhook signature received")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw_body.decode('utf-8'))
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
event_id = payload.get("id")
|
||||||
|
event_type = payload.get("event")
|
||||||
|
|
||||||
|
if not event_id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Idempotency Guard
|
||||||
|
existing = db.query(WebhookReceipt).filter(WebhookReceipt.event_key == event_id).first()
|
||||||
|
if existing:
|
||||||
|
logger.info(f"Webhook event {event_id} already processed. Skipping.")
|
||||||
|
return True
|
||||||
|
|
||||||
|
receipt = WebhookReceipt(
|
||||||
|
receipt_id=str(ulid.ULID()),
|
||||||
|
provider="RAZORPAY",
|
||||||
|
event_key=event_id,
|
||||||
|
signature_valid=True,
|
||||||
|
payload_json=raw_body.decode('utf-8')
|
||||||
|
)
|
||||||
|
db.add(receipt)
|
||||||
|
|
||||||
|
if event_type in ["payment.captured", "order.paid"]:
|
||||||
|
entity = payload.get("payload", {}).get("payment", {}).get("entity", {})
|
||||||
|
rzp_order_id = entity.get("order_id")
|
||||||
|
rzp_payment_id = entity.get("id")
|
||||||
|
|
||||||
|
if rzp_order_id:
|
||||||
|
payment_entry = db.query(PaymentLedger).filter(PaymentLedger.razorpay_order_id == rzp_order_id).first()
|
||||||
|
if payment_entry and payment_entry.status != "CAPTURED":
|
||||||
|
order = db.query(Order).filter(Order.order_id == payment_entry.order_id).first()
|
||||||
|
if order:
|
||||||
|
payment_entry.transaction_ref = rzp_payment_id
|
||||||
|
payment_entry.status = "CAPTURED"
|
||||||
|
payment_entry.raw_response_json = json.dumps(entity)
|
||||||
|
OrderService.confirm_ecommerce_payment(db, order, payment_method="RAZORPAY")
|
||||||
|
receipt.processing_result = "CAPTURED_CONFIRMED"
|
||||||
|
|
||||||
|
receipt.processed_at = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
payment_orchestrator = PaymentOrchestrator()
|
||||||
117
app/core/permissions/RoleChecker.py
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
from fastapi import Depends, HTTPException, Request, status
|
||||||
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.core.database.db_session import get_db
|
||||||
|
from app.core.Token import verify_access_token
|
||||||
|
from app.models.UserModel import User
|
||||||
|
from app.models.RoleModel import Role
|
||||||
|
from app.models.PermissionModel import Permission, RolePermission
|
||||||
|
from app.core.config.Config import settings
|
||||||
|
|
||||||
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/token", auto_error=False)
|
||||||
|
|
||||||
|
def get_current_user(
|
||||||
|
request: Request,
|
||||||
|
token: str = Depends(oauth2_scheme),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
) -> User:
|
||||||
|
# 🛡️ MCP INTERNAL BYPASS
|
||||||
|
client_key = request.headers.get("X-API-KEY")
|
||||||
|
if client_key and client_key == settings.PUBLIC_API_KEY:
|
||||||
|
# Create virtual Role and User object
|
||||||
|
virtual_role = Role(role_id="virtual-admin-role", role_name="Admin", role_prefix="ADM")
|
||||||
|
virtual_user = User(
|
||||||
|
user_id="internal-mcp",
|
||||||
|
email="mcp@steelonix.in",
|
||||||
|
first_name="MCP",
|
||||||
|
last_name="Platform",
|
||||||
|
display_name="MCP Platform",
|
||||||
|
role_id="virtual-admin-role",
|
||||||
|
role=virtual_role,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
if hasattr(request.state, "db_session"):
|
||||||
|
request.state.db_session.info["user_id"] = "internal-mcp"
|
||||||
|
from app.core.middleware.audit_context import user_id_var
|
||||||
|
user_id_var.set("internal-mcp")
|
||||||
|
return virtual_user
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Missing Authorization token",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"}
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = verify_access_token(token)
|
||||||
|
user_id = payload.get("sub")
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid token claims",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Query user with active state check
|
||||||
|
stmt = select(User).where(User.user_id == user_id, User.deleted_at.is_(None))
|
||||||
|
user = db.execute(stmt).scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="User not found",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"}
|
||||||
|
)
|
||||||
|
if not user.is_active or user.is_locked:
|
||||||
|
raise HTTPException(status_code=403, detail="User account is inactive or locked")
|
||||||
|
|
||||||
|
if hasattr(request.state, "db_session"):
|
||||||
|
request.state.db_session.info["user_id"] = user.user_id
|
||||||
|
from app.core.middleware.audit_context import user_id_var
|
||||||
|
user_id_var.set(user.user_id)
|
||||||
|
return user
|
||||||
|
|
||||||
|
class RoleChecker:
|
||||||
|
def __init__(self, allowed_roles: list[str]):
|
||||||
|
self.allowed_roles = [r.lower() for r in allowed_roles]
|
||||||
|
|
||||||
|
def __call__(self, current_user: User = Depends(get_current_user)) -> User:
|
||||||
|
user_role = current_user.role.role_name.lower()
|
||||||
|
if user_role not in self.allowed_roles:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="You do not have permission to access this resource"
|
||||||
|
)
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
class PermissionChecker:
|
||||||
|
def __init__(self, permission_code: str):
|
||||||
|
self.permission_code = permission_code
|
||||||
|
|
||||||
|
def __call__(
|
||||||
|
self,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
) -> User:
|
||||||
|
# Super Admin bypass
|
||||||
|
if current_user.role.role_name.lower() == "super admin" or current_user.user_id == "internal-mcp":
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
# Query permission link
|
||||||
|
stmt = (
|
||||||
|
select(RolePermission)
|
||||||
|
.join(Permission, Permission.permission_id == RolePermission.permission_id)
|
||||||
|
.where(
|
||||||
|
RolePermission.role_id == current_user.role_id,
|
||||||
|
Permission.permission_code == self.permission_code,
|
||||||
|
Permission.is_active.is_(True)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
has_permission = db.execute(stmt).scalar_one_or_none()
|
||||||
|
if not has_permission:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"Missing required permission: {self.permission_code}"
|
||||||
|
)
|
||||||
|
return current_user
|
||||||
0
app/core/permissions/__init__.py
Normal file
87
app/core/razorpay.py
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import razorpay
|
||||||
|
import hmac
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
from app.core.config.Config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger("app.razorpay")
|
||||||
|
|
||||||
|
class RazorpayService:
|
||||||
|
def __init__(self):
|
||||||
|
self._client = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def client(self):
|
||||||
|
if self._client is None:
|
||||||
|
key_id = settings.RAZORPAY_KEY_ID
|
||||||
|
key_secret = settings.RAZORPAY_KEY_SECRET
|
||||||
|
if not key_id or not key_secret:
|
||||||
|
raise ValueError("Razorpay credentials (RAZORPAY_KEY_ID / RAZORPAY_KEY_SECRET) not configured.")
|
||||||
|
self._client = razorpay.Client(auth=(key_id, key_secret))
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
def create_order(self, amount_paise: int, receipt: str, notes: Optional[Dict] = None) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Creates a Razorpay Order.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = {
|
||||||
|
"amount": amount_paise,
|
||||||
|
"currency": "INR",
|
||||||
|
"receipt": receipt,
|
||||||
|
"payment_capture": 1,
|
||||||
|
"notes": notes or {}
|
||||||
|
}
|
||||||
|
logger.info(f"Creating Razorpay Order for receipt {receipt}")
|
||||||
|
return self.client.order.create(data=data)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Razorpay Order Creation Failed: {str(e)}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def verify_payment_signature(self, rzp_order_id: str, rzp_payment_id: str, rzp_signature: str) -> bool:
|
||||||
|
"""
|
||||||
|
Verifies HMAC-SHA256 signature returned by client-side Razorpay modal.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
params_dict = {
|
||||||
|
'razorpay_order_id': rzp_order_id,
|
||||||
|
'razorpay_payment_id': rzp_payment_id,
|
||||||
|
'razorpay_signature': rzp_signature
|
||||||
|
}
|
||||||
|
self.client.utility.verify_payment_signature(params_dict)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Razorpay Signature Verification Failed: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def fetch_payment(self, payment_id: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Fetches payment details directly from Razorpay API for server-side verification.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return self.client.payment.fetch(payment_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Razorpay Payment Fetch Failed: {str(e)}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def verify_webhook_signature(self, raw_body: bytes, signature: str) -> bool:
|
||||||
|
"""
|
||||||
|
Verifies Webhook signature using raw request body.
|
||||||
|
"""
|
||||||
|
secret = settings.RAZORPAY_WEBHOOK_SECRET
|
||||||
|
if not secret:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
expected_signature = hmac.new(
|
||||||
|
secret.encode('utf-8'),
|
||||||
|
raw_body,
|
||||||
|
hashlib.sha256
|
||||||
|
).hexdigest()
|
||||||
|
return hmac.compare_digest(expected_signature, signature)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Webhook signature verification failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
razorpay_service = RazorpayService()
|
||||||
128
app/core/security/LockValidator.py
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
import re
|
||||||
|
import base64
|
||||||
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
|
from typing import Tuple, Optional
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
|
def validate_lock_credentials(lock_type: str, lock_passcode: Optional[str]) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Validates lock_type and lock_passcode strictly according to security requirements.
|
||||||
|
Returns cleaned passcode or None.
|
||||||
|
"""
|
||||||
|
valid_types = {"NONE", "PIN", "PASSWORD", "PATTERN"}
|
||||||
|
if lock_type not in valid_types:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"Invalid lock_type '{lock_type}'. Must be one of: NONE, PIN, PASSWORD, PATTERN."
|
||||||
|
)
|
||||||
|
|
||||||
|
if lock_type == "NONE":
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not lock_passcode or not lock_passcode.strip():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"Passcode is required when lock_type is '{lock_type}'."
|
||||||
|
)
|
||||||
|
|
||||||
|
passcode = lock_passcode.strip()
|
||||||
|
|
||||||
|
if lock_type == "PIN":
|
||||||
|
if not re.match(r"^\d{4,8}$", passcode):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="PIN must consist strictly of 4 to 8 numeric digits (0-9)."
|
||||||
|
)
|
||||||
|
return passcode
|
||||||
|
|
||||||
|
elif lock_type == "PASSWORD":
|
||||||
|
if len(passcode) < 1 or len(passcode) > 64:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="Password length must be between 1 and 64 characters."
|
||||||
|
)
|
||||||
|
return passcode
|
||||||
|
|
||||||
|
elif lock_type == "PATTERN":
|
||||||
|
# Format e.g. "1-4-7-8-9" or "1-2-3-6-9"
|
||||||
|
parts = passcode.split("-")
|
||||||
|
if len(parts) < 4 or len(parts) > 9:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="Pattern sequence must contain between 4 and 9 nodes."
|
||||||
|
)
|
||||||
|
seen_nodes = set()
|
||||||
|
for node in parts:
|
||||||
|
if not node.isdigit():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"Invalid pattern node '{node}'. All nodes must be numbers between 1 and 9."
|
||||||
|
)
|
||||||
|
n_int = int(node)
|
||||||
|
if n_int < 1 or n_int > 9:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"Pattern node '{n_int}' is out of bounds (must be 1-9)."
|
||||||
|
)
|
||||||
|
if n_int in seen_nodes:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"Duplicate node '{n_int}' in pattern sequence. A node cannot be visited twice."
|
||||||
|
)
|
||||||
|
seen_nodes.add(n_int)
|
||||||
|
return passcode
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_fulfillment_pricing(base_price_val: float, fulfillment_type: str) -> Tuple[Decimal, Decimal, Decimal, Decimal, Decimal]:
|
||||||
|
"""
|
||||||
|
Authoritative server calculation using Decimal arithmetic:
|
||||||
|
returns (base_price, fulfillment_fee, total_price, advance_deposit, remaining_balance)
|
||||||
|
"""
|
||||||
|
base = Decimal(str(base_price_val)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||||
|
|
||||||
|
if fulfillment_type == "DOORSTEP_PICKUP":
|
||||||
|
fee = Decimal("250.00")
|
||||||
|
else: # WALK_IN or COURIER
|
||||||
|
fee = Decimal("0.00")
|
||||||
|
|
||||||
|
total = base + fee
|
||||||
|
advance = (total * Decimal("0.20")).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||||
|
balance = total - advance
|
||||||
|
|
||||||
|
return base, fee, total, advance, balance
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_credential(plain_text: str) -> Optional[str]:
|
||||||
|
"""Encrypts device credential at rest."""
|
||||||
|
if not plain_text:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
import os
|
||||||
|
key = os.getenv("SECRET_KEY", "uO_v6N9kK7X6W_N7b5V8X3Z1Y9W5V3Z1Y9W5V3Z1Y9W=")
|
||||||
|
key_bytes = base64.urlsafe_b64encode(key.encode()[:32].ljust(32, b"0"))
|
||||||
|
f = Fernet(key_bytes)
|
||||||
|
return f.encrypt(plain_text.encode()).decode()
|
||||||
|
except Exception:
|
||||||
|
encoded = base64.b64encode(plain_text.encode()).decode()
|
||||||
|
return f"ENC_{encoded}"
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_credential(cipher_text: str) -> Optional[str]:
|
||||||
|
"""Decrypts stored device credential for authorized technician viewing."""
|
||||||
|
if not cipher_text:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
if cipher_text.startswith("ENC_"):
|
||||||
|
raw = cipher_text[4:]
|
||||||
|
return base64.b64decode(raw.encode()).decode()
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
import os
|
||||||
|
key = os.getenv("SECRET_KEY", "uO_v6N9kK7X6W_N7b5V8X3Z1Y9W5V3Z1Y9W5V3Z1Y9W=")
|
||||||
|
key_bytes = base64.urlsafe_b64encode(key.encode()[:32].ljust(32, b"0"))
|
||||||
|
f = Fernet(key_bytes)
|
||||||
|
return f.decrypt(cipher_text.encode()).decode()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
0
app/core/validators/__init__.py
Normal file
43
app/core/validators/password_validator.py
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import re
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
COMMON_PASSWORDS = {
|
||||||
|
"password", "password123", "12345678", "admin123", "welcome123", "qwertyuiop",
|
||||||
|
"ifixkart2026", "password2026", "letmein123"
|
||||||
|
}
|
||||||
|
|
||||||
|
def validate_password_complexity(password: str) -> None:
|
||||||
|
if len(password) < 12:
|
||||||
|
raise HTTPException(status_code=400, detail="Password must be at least 12 characters long.")
|
||||||
|
if len(password) > 128:
|
||||||
|
raise HTTPException(status_code=400, detail="Password cannot exceed 128 characters.")
|
||||||
|
|
||||||
|
if not re.search(r"[A-Z]", password):
|
||||||
|
raise HTTPException(status_code=400, detail="Password must contain at least one uppercase letter.")
|
||||||
|
if not re.search(r"[a-z]", password):
|
||||||
|
raise HTTPException(status_code=400, detail="Password must contain at least one lowercase letter.")
|
||||||
|
if not re.search(r"\d", password):
|
||||||
|
raise HTTPException(status_code=400, detail="Password must contain at least one number.")
|
||||||
|
if not re.search(r"[!@#$%^&*(),.?\":{}|<>]", password):
|
||||||
|
raise HTTPException(status_code=400, detail="Password must contain at least one special character.")
|
||||||
|
|
||||||
|
if password.lower() in COMMON_PASSWORDS:
|
||||||
|
raise HTTPException(status_code=400, detail="Password is too common or easily guessable.")
|
||||||
|
|
||||||
|
# Reject character repetitions of 4 or more (e.g. "aaaa", "1111")
|
||||||
|
for i in range(len(password) - 3):
|
||||||
|
chunk = password[i:i+4]
|
||||||
|
if len(set(chunk)) == 1:
|
||||||
|
raise HTTPException(status_code=400, detail="Password cannot contain consecutive repeating characters.")
|
||||||
|
|
||||||
|
# Reject sequential letters or numbers of 4 or more (e.g. "abcd", "1234")
|
||||||
|
for i in range(len(password) - 3):
|
||||||
|
chunk = password[i:i+4]
|
||||||
|
if all(chunk[j].isdigit() for j in range(4)):
|
||||||
|
nums = [int(x) for x in chunk]
|
||||||
|
if nums[1] - nums[0] == 1 and nums[2] - nums[1] == 1 and nums[3] - nums[2] == 1:
|
||||||
|
raise HTTPException(status_code=400, detail="Password cannot contain sequential number sequences.")
|
||||||
|
if all(chunk[j].isalpha() for j in range(4)):
|
||||||
|
chars = [ord(x.lower()) for x in chunk]
|
||||||
|
if chars[1] - chars[0] == 1 and chars[2] - chars[1] == 1 and chars[3] - chars[2] == 1:
|
||||||
|
raise HTTPException(status_code=400, detail="Password cannot contain sequential alphabetical sequences.")
|
||||||
18
app/events/handlers/notification_events.py
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
"""
|
||||||
|
@handler Notification Event Handlers (Backend/app/events/handlers/notification_events.py)
|
||||||
|
@purpose Decoupled notification handlers for Order Placed, Repair Completed, and Status updates.
|
||||||
|
"""
|
||||||
|
from typing import Dict, Any
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger("NotificationHandlers")
|
||||||
|
|
||||||
|
def handle_order_placed(payload: Dict[str, Any]):
|
||||||
|
order_id = payload.get("order_id")
|
||||||
|
customer_email = payload.get("email")
|
||||||
|
logger.info(f"[NOTIF SUCCESS] Order Confirmation Email dispatched for Order #{order_id} to {customer_email}")
|
||||||
|
|
||||||
|
def handle_repair_completed(payload: Dict[str, Any]):
|
||||||
|
ticket_id = payload.get("ticket_id")
|
||||||
|
phone = payload.get("phone")
|
||||||
|
logger.info(f"[NOTIF SUCCESS] WhatsApp Repair Completion Alert dispatched for Ticket #{ticket_id} to {phone}")
|
||||||
25
app/events/publisher.py
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
"""
|
||||||
|
@event Event Publisher (Backend/app/events/publisher.py)
|
||||||
|
@purpose Event Bus publisher for broadcasting domain events across Commerce, Repair, and Core services.
|
||||||
|
"""
|
||||||
|
from typing import Dict, Any, List, Callable
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger("EventBus")
|
||||||
|
|
||||||
|
_SUBSCRIBERS: Dict[str, List[Callable[[Dict[str, Any]], None]]] = {}
|
||||||
|
|
||||||
|
def subscribe(event_type: str, handler: Callable[[Dict[str, Any]], None]):
|
||||||
|
if event_type not in _SUBSCRIBERS:
|
||||||
|
_SUBSCRIBERS[event_type] = []
|
||||||
|
_SUBSCRIBERS[event_type].append(handler)
|
||||||
|
logger.info(f"Subscribed handler for event: {event_type}")
|
||||||
|
|
||||||
|
def publish_event(event_type: str, payload: Dict[str, Any]):
|
||||||
|
logger.info(f"Publishing event [{event_type}] with payload keys: {list(payload.keys())}")
|
||||||
|
handlers = _SUBSCRIBERS.get(event_type, [])
|
||||||
|
for handler in handlers:
|
||||||
|
try:
|
||||||
|
handler(payload)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error handling event [{event_type}]: {e}")
|
||||||
10
app/events/subscribers.py
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
"""
|
||||||
|
@subscriber Event Subscriber Registration (Backend/app/events/subscribers.py)
|
||||||
|
@purpose Subscribes domain handlers to event topics.
|
||||||
|
"""
|
||||||
|
from app.events.publisher import subscribe
|
||||||
|
from app.events.handlers.notification_events import handle_order_placed, handle_repair_completed
|
||||||
|
|
||||||
|
def register_subscribers():
|
||||||
|
subscribe("ORDER_PLACED", handle_order_placed)
|
||||||
|
subscribe("REPAIR_COMPLETED", handle_repair_completed)
|
||||||
323
app/main.py
Normal file
|
|
@ -0,0 +1,323 @@
|
||||||
|
import os
|
||||||
|
import hashlib
|
||||||
|
from fastapi import FastAPI, Request, Response, status
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.middleware.gzip import GZipMiddleware
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from app.core.config.Config import settings
|
||||||
|
|
||||||
|
# Ensure all database models are loaded into the SQLAlchemy registry
|
||||||
|
import app.models.db_base
|
||||||
|
|
||||||
|
# Router Imports
|
||||||
|
from app.api.v1.routers.StorefrontRouter import router as storefront_legacy_router
|
||||||
|
from app.api.v1.routers.AuthenticationRouter import router as auth_router
|
||||||
|
from app.api.v1.routers.UserCreationRouter import router as user_router
|
||||||
|
from app.api.v1.routers.MasterDataRouter import router as geo_router
|
||||||
|
from app.api.v1.routers.SettingsRouter import router as setting_router
|
||||||
|
from app.api.v1.routers.FileRouter import router as file_router
|
||||||
|
from app.api.v1.routers.MfaRouter import router as mfa_router
|
||||||
|
from app.api.v1.routers.AdminSecurityRouter import router as admin_security_router
|
||||||
|
from app.api.v1.routers.RolePermissionRouter import router as role_perm_router
|
||||||
|
from app.api.v1.routers.CatalogRouter import router as catalog_router
|
||||||
|
from app.api.v1.routers.MigrationRouter import router as migration_router
|
||||||
|
from app.api.v1.routers.storefront import router as public_storefront_router
|
||||||
|
from app.api.v1.routers.admin_storefront import router as admin_storefront_router
|
||||||
|
from app.api.v1.routers.CustomerAuthRouter import router as customer_auth_router
|
||||||
|
from app.api.v1.routers.CustomerProfileRouter import router as customer_profile_router
|
||||||
|
from app.api.v1.routers.CartRouter import router as cart_router
|
||||||
|
from app.api.v1.routers.CheckoutRouter import router as checkout_router
|
||||||
|
from app.api.v1.routers.OrderRouter import router as order_router
|
||||||
|
from app.api.v1.routers.PaymentRouter import router as payment_router
|
||||||
|
from app.api.v1.routers.InvoiceRouter import router as invoice_router
|
||||||
|
from app.api.v1.routers.PosSyncRouter import router as pos_sync_router
|
||||||
|
from app.api.v1.routers.WishlistRouter import router as wishlist_router
|
||||||
|
from app.api.v1.routers.ProductCompareRouter import router as product_compare_router
|
||||||
|
from app.api.v1.routers.InventoryRouter import router as inventory_router
|
||||||
|
from app.api.v1.routers.AdminOrderRouter import router as admin_order_router
|
||||||
|
from app.api.v1.routers.AdminInvoiceRouter import router as admin_invoice_router
|
||||||
|
from app.api.v1.routers.AdminCustomerRouter import router as admin_customer_router
|
||||||
|
from app.api.v1.routers.DashboardRouter import router as dashboard_router
|
||||||
|
from app.api.v1.routers.ServiceJobRouter import router as service_job_router
|
||||||
|
|
||||||
|
# Middleware Imports
|
||||||
|
from app.core.middleware.trace_middleware import RequestTraceMiddleware
|
||||||
|
from app.core.middleware.security_middleware import SecurityHeadersMiddleware, RequestSizeLimitMiddleware
|
||||||
|
from app.core.middleware.kill_switch_middleware import KillSwitchMiddleware
|
||||||
|
from app.core.Exception import AppException
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title=settings.PROJECT_NAME,
|
||||||
|
description="iFixKart Enterprise ERP/CRM/E-commerce Platform Backend Core Foundation",
|
||||||
|
version="1.0.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mount Uploads directory for media serving with fallback to production server for missing local files
|
||||||
|
from pathlib import Path
|
||||||
|
from fastapi.responses import FileResponse, RedirectResponse
|
||||||
|
|
||||||
|
uploads_path = str(Path(__file__).resolve().parents[1] / "uploads")
|
||||||
|
os.makedirs(uploads_path, exist_ok=True)
|
||||||
|
|
||||||
|
@app.api_route("/uploads/{file_path:path}", methods=["GET", "HEAD"])
|
||||||
|
def serve_upload_file(file_path: str):
|
||||||
|
local_file = os.path.abspath(os.path.join(uploads_path, file_path))
|
||||||
|
if os.path.isfile(local_file) and local_file.startswith(uploads_path):
|
||||||
|
return FileResponse(local_file)
|
||||||
|
return RedirectResponse(
|
||||||
|
url=f"https://ifixkartbe.trionixsolution.com/uploads/{file_path}",
|
||||||
|
status_code=307
|
||||||
|
)
|
||||||
|
|
||||||
|
# 1. CORS Configuration
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=[
|
||||||
|
"https://ifixkartdev.trionixsolution.com",
|
||||||
|
"https://ifixkart.trionixsolution.com",
|
||||||
|
"https://ifixkartbe.trionixsolution.com",
|
||||||
|
"http://localhost:3021",
|
||||||
|
"http://localhost:3022",
|
||||||
|
"http://localhost:8000",
|
||||||
|
"http://127.0.0.1:3021",
|
||||||
|
"http://127.0.0.1:3022",
|
||||||
|
"http://127.0.0.1:8000",
|
||||||
|
],
|
||||||
|
allow_origin_regex=r"https?://.*",
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Enable Gzip Compression for large payloads (minimizing response size)
|
||||||
|
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
||||||
|
|
||||||
|
# ETag and Cache-Control middleware for CDNs / Browsers caching
|
||||||
|
@app.middleware("http")
|
||||||
|
async def add_cache_headers_and_etag(request: Request, call_next):
|
||||||
|
if request.method != "GET":
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
response = await call_next(request)
|
||||||
|
path = request.url.path
|
||||||
|
|
||||||
|
# Admin requests, authenticated requests, /products/all or layout configs MUST NEVER be cached
|
||||||
|
is_no_cache = (
|
||||||
|
request.headers.get("authorization") is not None or
|
||||||
|
"/products/all" in path or
|
||||||
|
"/admin" in path or
|
||||||
|
"/storefront/layout" in path
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_no_cache:
|
||||||
|
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
||||||
|
response.headers["Pragma"] = "no-cache"
|
||||||
|
response.headers["Expires"] = "0"
|
||||||
|
return response
|
||||||
|
|
||||||
|
is_cacheable = any(p in path for p in [
|
||||||
|
"/api/v1/catalog/products",
|
||||||
|
"/api/v1/catalog/brands",
|
||||||
|
"/api/v1/catalog/categories",
|
||||||
|
"/api/v1/settings/public"
|
||||||
|
])
|
||||||
|
|
||||||
|
if is_cacheable and response.status_code == 200:
|
||||||
|
body = b""
|
||||||
|
async for chunk in response.body_iterator:
|
||||||
|
body += chunk
|
||||||
|
|
||||||
|
etag_val = f'W/"{hashlib.md5(body).hexdigest()}"'
|
||||||
|
|
||||||
|
if_none_match = request.headers.get("if-none-match")
|
||||||
|
if if_none_match and if_none_match == etag_val:
|
||||||
|
return Response(status_code=304, headers={
|
||||||
|
"ETag": etag_val,
|
||||||
|
"Cache-Control": "public, max-age=60, must-revalidate"
|
||||||
|
})
|
||||||
|
|
||||||
|
headers = dict(response.headers)
|
||||||
|
headers["ETag"] = etag_val
|
||||||
|
headers["Cache-Control"] = "public, max-age=60, must-revalidate"
|
||||||
|
|
||||||
|
if "/layout/" in path:
|
||||||
|
headers["Cache-Control"] = "public, max-age=300, must-revalidate"
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=body,
|
||||||
|
status_code=response.status_code,
|
||||||
|
headers=headers,
|
||||||
|
media_type=response.media_type
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
# 2. Register Middleware Stack (Executed in reverse order of addition)
|
||||||
|
from app.core.middleware.audit_middleware import AuditMiddleware
|
||||||
|
app.add_middleware(AuditMiddleware)
|
||||||
|
app.add_middleware(KillSwitchMiddleware)
|
||||||
|
app.add_middleware(RequestSizeLimitMiddleware, max_bytes=3 * 1024 * 1024 * 1024) # Increased limit to 3GB
|
||||||
|
app.add_middleware(SecurityHeadersMiddleware)
|
||||||
|
app.add_middleware(RequestTraceMiddleware)
|
||||||
|
|
||||||
|
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||||
|
|
||||||
|
# 3. Register Global Handlers for Custom AppException with CORS safety
|
||||||
|
@app.exception_handler(AppException)
|
||||||
|
def app_exception_handler(request: Request, exc: AppException):
|
||||||
|
req_id = getattr(request.state, "request_id", "unknown")
|
||||||
|
response = JSONResponse(
|
||||||
|
status_code=exc.status_code,
|
||||||
|
content={"detail": exc.detail, "request_id": req_id}
|
||||||
|
)
|
||||||
|
# Explicitly append CORS headers so browser doesn't block cross-origin error responses
|
||||||
|
origin = request.headers.get("origin")
|
||||||
|
if origin:
|
||||||
|
response.headers["Access-Control-Allow-Origin"] = origin
|
||||||
|
response.headers["Access-Control-Allow-Credentials"] = "true"
|
||||||
|
return response
|
||||||
|
|
||||||
|
@app.exception_handler(StarletteHTTPException)
|
||||||
|
def http_exception_handler(request: Request, exc: StarletteHTTPException):
|
||||||
|
response = JSONResponse(
|
||||||
|
status_code=exc.status_code,
|
||||||
|
content={"detail": exc.detail}
|
||||||
|
)
|
||||||
|
origin = request.headers.get("origin")
|
||||||
|
if origin:
|
||||||
|
response.headers["Access-Control-Allow-Origin"] = origin
|
||||||
|
response.headers["Access-Control-Allow-Credentials"] = "true"
|
||||||
|
return response
|
||||||
|
|
||||||
|
from fastapi.encoders import jsonable_encoder
|
||||||
|
|
||||||
|
@app.exception_handler(RequestValidationError)
|
||||||
|
def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||||
|
response = JSONResponse(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
content={"detail": jsonable_encoder(exc.errors())}
|
||||||
|
)
|
||||||
|
origin = request.headers.get("origin")
|
||||||
|
if origin:
|
||||||
|
response.headers["Access-Control-Allow-Origin"] = origin
|
||||||
|
response.headers["Access-Control-Allow-Credentials"] = "true"
|
||||||
|
return response
|
||||||
|
|
||||||
|
# 4. Register Router Endpoints
|
||||||
|
app.include_router(storefront_legacy_router)
|
||||||
|
app.include_router(auth_router)
|
||||||
|
app.include_router(user_router)
|
||||||
|
app.include_router(geo_router)
|
||||||
|
app.include_router(setting_router)
|
||||||
|
app.include_router(file_router)
|
||||||
|
app.include_router(mfa_router)
|
||||||
|
app.include_router(admin_security_router)
|
||||||
|
app.include_router(role_perm_router)
|
||||||
|
app.include_router(catalog_router)
|
||||||
|
app.include_router(migration_router)
|
||||||
|
app.include_router(public_storefront_router)
|
||||||
|
app.include_router(admin_storefront_router)
|
||||||
|
app.include_router(customer_auth_router)
|
||||||
|
app.include_router(customer_profile_router)
|
||||||
|
app.include_router(cart_router)
|
||||||
|
app.include_router(checkout_router)
|
||||||
|
app.include_router(order_router)
|
||||||
|
app.include_router(payment_router)
|
||||||
|
app.include_router(invoice_router)
|
||||||
|
app.include_router(pos_sync_router)
|
||||||
|
app.include_router(wishlist_router)
|
||||||
|
app.include_router(product_compare_router)
|
||||||
|
app.include_router(inventory_router)
|
||||||
|
app.include_router(admin_order_router)
|
||||||
|
app.include_router(admin_invoice_router)
|
||||||
|
app.include_router(admin_customer_router)
|
||||||
|
app.include_router(dashboard_router)
|
||||||
|
app.include_router(service_job_router)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
def read_root():
|
||||||
|
return {
|
||||||
|
"status": "online",
|
||||||
|
"project": settings.PROJECT_NAME,
|
||||||
|
"version": "1.0.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 5. Startup Hook for Automatic Database Initialization & Seeding & Media Worker
|
||||||
|
@app.on_event("startup")
|
||||||
|
def startup_event():
|
||||||
|
print("FastAPI Application Booting... Running automatic database schema verification and permission seeding...")
|
||||||
|
from app.core.database.init_db import initialize_database
|
||||||
|
try:
|
||||||
|
initialize_database()
|
||||||
|
print("Automatic database initialization and seeding completed successfully.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error during automatic database initialization: {e}")
|
||||||
|
|
||||||
|
# Start Async Media Cleanup & Reconciliation Thread Worker
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from app.core.database.db_session import SessionLocal
|
||||||
|
from app.core.media.media_garbage_collector import process_async_media_cleanup, reconcile_media_database
|
||||||
|
|
||||||
|
def run_media_background_worker():
|
||||||
|
last_cleanup = 0
|
||||||
|
last_reconcile = 0
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
now = time.time()
|
||||||
|
# Run hourly cleanup
|
||||||
|
if now - last_cleanup >= 3600:
|
||||||
|
last_cleanup = now
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
process_async_media_cleanup(db)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
# Run daily reconciliation (86400s)
|
||||||
|
if now - last_reconcile >= 86400:
|
||||||
|
last_reconcile = now
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
reconcile_media_database(db)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
except Exception as err:
|
||||||
|
print(f"Error in Media Background Worker loop: {err}")
|
||||||
|
|
||||||
|
time.sleep(300) # Check every 5 minutes
|
||||||
|
|
||||||
|
worker_thread = threading.Thread(target=run_media_background_worker, daemon=True, name="MediaWorkerThread")
|
||||||
|
worker_thread.start()
|
||||||
|
print("Media Async Garbage Collector & Reconciliation Worker thread started.")
|
||||||
|
|
||||||
|
# Start Persistent Migration Worker Background Thread
|
||||||
|
from app.services.migration_engine.migration_worker import MigrationWorker
|
||||||
|
|
||||||
|
def run_migration_background_worker():
|
||||||
|
worker = MigrationWorker()
|
||||||
|
print(f"[{worker.worker_id}] Migration Worker daemon initialized and listening for queued jobs...")
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
res = worker.claim_next_job(db)
|
||||||
|
if res:
|
||||||
|
job_id, lease_version = res
|
||||||
|
worker.process_job(job_id, lease_version)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
except Exception as err:
|
||||||
|
print(f"Error in Migration Worker loop: {err}")
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
mig_thread = threading.Thread(target=run_migration_background_worker, daemon=True, name="MigrationWorkerThread")
|
||||||
|
mig_thread.start()
|
||||||
|
print("Persistent Migration Worker background thread started.")
|
||||||
|
|
||||||
|
|
||||||
124
app/migrations/seed_crm_catalog.py
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
import ulid
|
||||||
|
import sqlalchemy
|
||||||
|
from app.core.database.db_session import SessionLocal, engine_crm, engine_commerce
|
||||||
|
from app.models.BrandDeviceTypeModel import BrandDeviceType
|
||||||
|
from app.models.DeviceCatalogModel import ServiceType, RepairService, RepairVariant, DeviceModel
|
||||||
|
|
||||||
|
def seed_crm():
|
||||||
|
db = SessionLocal()
|
||||||
|
|
||||||
|
try:
|
||||||
|
print("Checking existing ServiceTypes...")
|
||||||
|
service_types_data = [
|
||||||
|
{"name": "Screen Replacement", "slug": "screen-replacement", "description": "Display & Touchscreen glass replacement with warranty"},
|
||||||
|
{"name": "Battery Replacement", "slug": "battery-replacement", "description": "High health battery replacement with quick charging support"},
|
||||||
|
{"name": "Camera Repair", "slug": "camera-repair", "description": "Front/Rear camera module lens and sensor replacement"},
|
||||||
|
{"name": "Charging Port Repair", "slug": "charging-port-repair", "description": "USB-C / Lightning port connector replacement"},
|
||||||
|
{"name": "Speaker Repair", "slug": "speaker-repair", "description": "Earpiece and loudspeaker audio restoration"},
|
||||||
|
{"name": "Back Glass Replacement", "slug": "back-glass-replacement", "description": "Rear glass panel restoration"}
|
||||||
|
]
|
||||||
|
|
||||||
|
st_map = {}
|
||||||
|
for st_info in service_types_data:
|
||||||
|
existing = db.execute(
|
||||||
|
sqlalchemy.select(ServiceType).where(ServiceType.slug == st_info["slug"])
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
if not existing:
|
||||||
|
st_id = str(ulid.ULID())
|
||||||
|
st = ServiceType(
|
||||||
|
service_type_id=st_id,
|
||||||
|
name=st_info["name"],
|
||||||
|
slug=st_info["slug"],
|
||||||
|
description=st_info["description"],
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
db.add(st)
|
||||||
|
st_map[st_info["slug"]] = st_id
|
||||||
|
print(f"Created ServiceType: {st_info['name']}")
|
||||||
|
else:
|
||||||
|
st_map[st_info["slug"]] = existing.service_type_id
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Fetch all models from Commerce DB
|
||||||
|
models = db.execute(sqlalchemy.select(DeviceModel)).scalars().all()
|
||||||
|
print(f"Found {len(models)} device models in commerce catalog.")
|
||||||
|
|
||||||
|
for m in models:
|
||||||
|
for st_slug, st_id in st_map.items():
|
||||||
|
# Check if RepairService exists
|
||||||
|
existing_rs = db.execute(
|
||||||
|
sqlalchemy.select(RepairService).where(
|
||||||
|
RepairService.model_id == m.model_id,
|
||||||
|
RepairService.service_type_id == st_id
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
if not existing_rs:
|
||||||
|
rs_id = str(ulid.ULID())
|
||||||
|
rs = RepairService(
|
||||||
|
repair_service_id=rs_id,
|
||||||
|
model_id=m.model_id,
|
||||||
|
service_type_id=st_id,
|
||||||
|
slug=f"{m.slug}-{st_slug}",
|
||||||
|
full_path=f"{m.full_path}/{st_slug}",
|
||||||
|
description=f"{st_slug.replace('-', ' ').title()} for {m.name}"
|
||||||
|
)
|
||||||
|
db.add(rs)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# Add Variants
|
||||||
|
if st_slug == "screen-replacement":
|
||||||
|
v1 = RepairVariant(
|
||||||
|
variant_id=str(ulid.ULID()),
|
||||||
|
repair_service_id=rs_id,
|
||||||
|
name="Original OLED",
|
||||||
|
price=12000.00,
|
||||||
|
cost=8000.00,
|
||||||
|
duration_minutes=180,
|
||||||
|
warranty_days=90
|
||||||
|
)
|
||||||
|
v2 = RepairVariant(
|
||||||
|
variant_id=str(ulid.ULID()),
|
||||||
|
repair_service_id=rs_id,
|
||||||
|
name="Premium Display",
|
||||||
|
price=7500.00,
|
||||||
|
cost=4500.00,
|
||||||
|
duration_minutes=120,
|
||||||
|
warranty_days=60
|
||||||
|
)
|
||||||
|
db.add_all([v1, v2])
|
||||||
|
elif st_slug == "battery-replacement":
|
||||||
|
v1 = RepairVariant(
|
||||||
|
variant_id=str(ulid.ULID()),
|
||||||
|
repair_service_id=rs_id,
|
||||||
|
name="Original High Capacity",
|
||||||
|
price=3500.00,
|
||||||
|
cost=2000.00,
|
||||||
|
duration_minutes=60,
|
||||||
|
warranty_days=180
|
||||||
|
)
|
||||||
|
db.add(v1)
|
||||||
|
else:
|
||||||
|
v1 = RepairVariant(
|
||||||
|
variant_id=str(ulid.ULID()),
|
||||||
|
repair_service_id=rs_id,
|
||||||
|
name="Standard Service",
|
||||||
|
price=2500.00,
|
||||||
|
cost=1200.00,
|
||||||
|
duration_minutes=60,
|
||||||
|
warranty_days=30
|
||||||
|
)
|
||||||
|
db.add(v1)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
print("CRM Catalog seeding complete!")
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
print("Error seeding CRM catalog:", e)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
seed_crm()
|
||||||
70
app/migrations/sync_schema.py
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import sqlalchemy
|
||||||
|
from app.core.database.db_session import engine_commerce, engine_crm
|
||||||
|
|
||||||
|
def sync():
|
||||||
|
with engine_commerce.connect() as conn:
|
||||||
|
print("Syncing ifixkart_commerce database tables...")
|
||||||
|
# Alter service_id to be nullable
|
||||||
|
try:
|
||||||
|
conn.execute(sqlalchemy.text("ALTER TABLE service_jobs MODIFY COLUMN service_id VARCHAR(26) NULL;"))
|
||||||
|
print("Modified service_id to NULLABLE.")
|
||||||
|
except Exception as e:
|
||||||
|
print("service_id modify note:", e)
|
||||||
|
|
||||||
|
# Add missing columns to service_jobs
|
||||||
|
cols = [
|
||||||
|
("device_type", "VARCHAR(50) NULL"),
|
||||||
|
("brand_id", "VARCHAR(26) NULL"),
|
||||||
|
("series_id", "VARCHAR(26) NULL"),
|
||||||
|
("model_id", "VARCHAR(26) NULL"),
|
||||||
|
("service_type_id", "VARCHAR(26) NULL"),
|
||||||
|
("repair_service_id", "VARCHAR(26) NULL"),
|
||||||
|
("repair_variant_id", "VARCHAR(26) NULL"),
|
||||||
|
("currency", "VARCHAR(3) NOT NULL DEFAULT 'INR'"),
|
||||||
|
("service_name_snapshot", "VARCHAR(255) NULL"),
|
||||||
|
("variant_name_snapshot", "VARCHAR(255) NULL"),
|
||||||
|
("base_price_snapshot", "DECIMAL(10, 2) NULL"),
|
||||||
|
("duration_snapshot", "INT NULL"),
|
||||||
|
("warranty_snapshot", "INT NULL"),
|
||||||
|
("inspection_fee_snapshot", "DECIMAL(10, 2) NULL"),
|
||||||
|
("queue_number", "VARCHAR(50) NULL"),
|
||||||
|
("queue_date", "DATE NULL"),
|
||||||
|
("priority", "VARCHAR(20) NOT NULL DEFAULT 'NORMAL'")
|
||||||
|
]
|
||||||
|
|
||||||
|
for col_name, col_def in cols:
|
||||||
|
try:
|
||||||
|
conn.execute(sqlalchemy.text(f"ALTER TABLE service_jobs ADD COLUMN {col_name} {col_def};"))
|
||||||
|
print(f"Added column {col_name} to service_jobs.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Column {col_name} note:", e)
|
||||||
|
|
||||||
|
# Update service_payments in ifixkart_commerce if present
|
||||||
|
pmt_cols = [
|
||||||
|
("provider_refund_id", "VARCHAR(100) NULL"),
|
||||||
|
("refund_reason", "VARCHAR(255) NULL"),
|
||||||
|
("refunded_at", "DATETIME NULL")
|
||||||
|
]
|
||||||
|
for col_name, col_def in pmt_cols:
|
||||||
|
try:
|
||||||
|
conn.execute(sqlalchemy.text(f"ALTER TABLE service_payments ADD COLUMN {col_name} {col_def};"))
|
||||||
|
print(f"Added column {col_name} to service_payments.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Payment column {col_name} note:", e)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
with engine_crm.connect() as conn:
|
||||||
|
print("Syncing ifixkart_crm database tables...")
|
||||||
|
# Update technician_skills column name
|
||||||
|
try:
|
||||||
|
conn.execute(sqlalchemy.text("ALTER TABLE technician_skills CHANGE COLUMN service_id service_type_id VARCHAR(26) NOT NULL;"))
|
||||||
|
print("Updated technician_skills table column service_id -> service_type_id.")
|
||||||
|
except Exception as e:
|
||||||
|
print("TechnicianSkill column note:", e)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
print("Database schema sync complete!")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sync()
|
||||||
18
app/models/AuditLogModel.py
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
from sqlalchemy import Column, String, DateTime, ForeignKey, JSON
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from app.core.database.db_session import Base
|
||||||
|
|
||||||
|
class AuditLog(Base):
|
||||||
|
__tablename__ = "audit_logs"
|
||||||
|
|
||||||
|
audit_id = Column(String(26), primary_key=True)
|
||||||
|
request_id = Column(String(50), nullable=False)
|
||||||
|
user_id = Column(String(26), ForeignKey("users.user_id", ondelete="SET NULL"), nullable=True)
|
||||||
|
entity_type = Column(String(50), nullable=False)
|
||||||
|
entity_id = Column(String(50), nullable=False)
|
||||||
|
action = Column(String(30), nullable=False)
|
||||||
|
old_value = Column(JSON, nullable=True)
|
||||||
|
new_value = Column(JSON, nullable=True)
|
||||||
|
ip_address = Column(String(45), nullable=False)
|
||||||
|
user_agent = Column(String(255), nullable=True)
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
11
app/models/BrandDeviceTypeModel.py
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
from sqlalchemy import Column, String, ForeignKey
|
||||||
|
from app.core.database.db_session import Base
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
class BrandDeviceType(Base):
|
||||||
|
__tablename__ = "brand_device_types"
|
||||||
|
|
||||||
|
brand_id = Column(String(26), ForeignKey("brands.brand_id", ondelete="CASCADE"), primary_key=True)
|
||||||
|
device_type = Column(String(50), primary_key=True) # laptop, tablet, mobile
|
||||||
|
|
||||||
|
brand = relationship("Brand", back_populates="device_types_rel")
|
||||||
22
app/models/BrandModel.py
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
from sqlalchemy import Column, String, Boolean, DateTime
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from app.core.database.db_session import Base
|
||||||
|
from app.models.BrandDeviceTypeModel import BrandDeviceType
|
||||||
|
|
||||||
|
class Brand(Base):
|
||||||
|
__tablename__ = "brands"
|
||||||
|
|
||||||
|
brand_id = Column(String(26), primary_key=True)
|
||||||
|
name = Column(String(100), unique=True, nullable=False)
|
||||||
|
slug = Column(String(100), unique=True, nullable=False)
|
||||||
|
logo_url = Column(String(500), nullable=True)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
device_types_rel = relationship("BrandDeviceType", back_populates="brand", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_types(self):
|
||||||
|
return [dt.device_type for dt in self.device_types_rel]
|
||||||
16
app/models/CartModel.py
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import ulid
|
||||||
|
from sqlalchemy import Column, String, DateTime, JSON, Text
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from app.core.database.db_session import Base
|
||||||
|
|
||||||
|
class Cart(Base):
|
||||||
|
__tablename__ = "carts"
|
||||||
|
|
||||||
|
cart_id = Column(String(26), primary_key=True, default=lambda: str(ulid.new()))
|
||||||
|
customer_id = Column(String(26), unique=True, nullable=True, index=True)
|
||||||
|
visitor_id = Column(String(64), unique=True, nullable=True, index=True)
|
||||||
|
items_json = Column(JSON, nullable=True) # [{variant_id, qty, unit_price}]
|
||||||
|
coupon_code = Column(String(50), nullable=True)
|
||||||
|
expires_at = Column(DateTime, nullable=True)
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
25
app/models/CategoryModel.py
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
from sqlalchemy import Column, String, Boolean, DateTime, ForeignKey
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from app.core.database.db_session import Base
|
||||||
|
|
||||||
|
class Category(Base):
|
||||||
|
__tablename__ = "categories"
|
||||||
|
|
||||||
|
category_id = Column(String(26), primary_key=True)
|
||||||
|
parent_category_id = Column(String(26), ForeignKey("categories.category_id", ondelete="SET NULL"), nullable=True)
|
||||||
|
name = Column(String(100), nullable=False)
|
||||||
|
slug = Column(String(100), unique=True, nullable=False)
|
||||||
|
description = Column(String(500), nullable=True)
|
||||||
|
image_url = Column(String(500), nullable=True)
|
||||||
|
sort_order = Column(String(10), default="0")
|
||||||
|
is_parent_feature = Column(Boolean, default=False, nullable=False)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
# CMS-driven display controls
|
||||||
|
show_in_sidebar = Column(Boolean, default=True, nullable=False)
|
||||||
|
mega_group = Column(String(64), nullable=True) # e.g. "smartphones", "laptops"
|
||||||
|
badge = Column(String(32), nullable=True) # e.g. "HOT", "NEW", "SALE"
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
parent = relationship("Category", remote_side=[category_id], backref="children")
|
||||||
21
app/models/CollectionModel.py
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
from sqlalchemy import Column, String, Boolean, DateTime, ForeignKey, Table
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from app.core.database.db_session import Base
|
||||||
|
|
||||||
|
# Many-to-many helper table for Product <-> Collection
|
||||||
|
product_collections = Table(
|
||||||
|
"product_collections",
|
||||||
|
Base.metadata,
|
||||||
|
Column("product_id", String(26), ForeignKey("products.product_id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
Column("collection_id", String(26), ForeignKey("collections.collection_id", ondelete="CASCADE"), primary_key=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
class Collection(Base):
|
||||||
|
__tablename__ = "collections"
|
||||||
|
|
||||||
|
collection_id = Column(String(26), primary_key=True)
|
||||||
|
name = Column(String(100), unique=True, nullable=False)
|
||||||
|
slug = Column(String(100), unique=True, nullable=False)
|
||||||
|
description = Column(String(500), nullable=True)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
34
app/models/ContactModel.py
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
from sqlalchemy import Column, String, Boolean, Date, DateTime, ForeignKey
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from app.core.database.db_session import Base
|
||||||
|
|
||||||
|
class Contact(Base):
|
||||||
|
__tablename__ = "contacts"
|
||||||
|
|
||||||
|
contact_id = Column(String(26), primary_key=True)
|
||||||
|
first_name = Column(String(100), nullable=False)
|
||||||
|
last_name = Column(String(100), nullable=False)
|
||||||
|
email = Column(String(255), nullable=True)
|
||||||
|
phone = Column(String(20), unique=True, nullable=False)
|
||||||
|
gender = Column(String(10), nullable=True)
|
||||||
|
dob = Column(Date, nullable=True)
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
|
addresses = relationship("ContactAddress", back_populates="contact", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
class ContactAddress(Base):
|
||||||
|
__tablename__ = "contact_addresses"
|
||||||
|
|
||||||
|
address_id = Column(String(26), primary_key=True)
|
||||||
|
contact_id = Column(String(26), ForeignKey("contacts.contact_id", ondelete="CASCADE"), nullable=False)
|
||||||
|
type = Column(String(20), default="Shipping") # Billing, Shipping
|
||||||
|
address_line1 = Column(String(255), nullable=False)
|
||||||
|
address_line2 = Column(String(255), nullable=True)
|
||||||
|
city = Column(String(100), nullable=False)
|
||||||
|
state = Column(String(100), nullable=False)
|
||||||
|
country = Column(String(100), nullable=False)
|
||||||
|
postal_code = Column(String(20), nullable=False)
|
||||||
|
is_default = Column(Boolean, default=False)
|
||||||
|
|
||||||
|
contact = relationship("Contact", back_populates="addresses")
|
||||||
21
app/models/CustomerDeviceModel.py
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
from sqlalchemy import Column, String, DateTime
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from app.core.database.db_session import Base
|
||||||
|
|
||||||
|
class CustomerDevice(Base):
|
||||||
|
__tablename__ = "customer_devices"
|
||||||
|
|
||||||
|
device_id = Column(String(26), primary_key=True)
|
||||||
|
customer_id = Column(String(26), nullable=False, index=True)
|
||||||
|
brand = Column(String(100), nullable=False)
|
||||||
|
model = Column(String(100), nullable=False)
|
||||||
|
model_number = Column(String(100), nullable=True)
|
||||||
|
imei_primary = Column(String(50), nullable=True)
|
||||||
|
imei_secondary = Column(String(50), nullable=True)
|
||||||
|
color = Column(String(50), nullable=True)
|
||||||
|
device_condition = Column(String(500), nullable=True)
|
||||||
|
device_type = Column(String(50), nullable=True)
|
||||||
|
notes = Column(String(1000), nullable=True)
|
||||||
|
storage_capacity = Column(String(100), nullable=True)
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
14
app/models/DepartmentModel.py
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
from sqlalchemy import Column, String, Boolean, DateTime
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from app.core.database.db_session import Base
|
||||||
|
|
||||||
|
class Department(Base):
|
||||||
|
__tablename__ = "departments"
|
||||||
|
|
||||||
|
department_id = Column(String(26), primary_key=True)
|
||||||
|
name = Column(String(100), unique=True, nullable=False)
|
||||||
|
description = Column(String(255), nullable=True)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
|
deleted_at = Column(DateTime, nullable=True)
|
||||||
14
app/models/DesignationModel.py
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
from sqlalchemy import Column, String, Boolean, DateTime
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from app.core.database.db_session import Base
|
||||||
|
|
||||||
|
class Designation(Base):
|
||||||
|
__tablename__ = "designations"
|
||||||
|
|
||||||
|
designation_id = Column(String(26), primary_key=True)
|
||||||
|
name = Column(String(100), unique=True, nullable=False)
|
||||||
|
description = Column(String(255), nullable=True)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
|
deleted_at = Column(DateTime, nullable=True)
|
||||||
89
app/models/DeviceCatalogModel.py
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
from sqlalchemy import Column, String, Boolean, Integer, Numeric, ForeignKey, DateTime
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from app.core.database.db_session import Base
|
||||||
|
from app.models.BrandModel import Brand
|
||||||
|
from app.models.PartsModel import RepairVariantPart
|
||||||
|
|
||||||
|
class DeviceSeries(Base):
|
||||||
|
__tablename__ = "device_series"
|
||||||
|
|
||||||
|
series_id = Column(String(26), primary_key=True)
|
||||||
|
brand_id = Column(String(26), ForeignKey("brands.brand_id", ondelete="SET NULL"), nullable=True)
|
||||||
|
name = Column(String(100), nullable=False)
|
||||||
|
slug = Column(String(100), unique=True, nullable=False)
|
||||||
|
device_type = Column(String(50), nullable=True) # laptop, tablet, mobile
|
||||||
|
sort_order = Column(Integer, default=0)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
|
||||||
|
brand = relationship("Brand")
|
||||||
|
models = relationship("DeviceModel", back_populates="series")
|
||||||
|
|
||||||
|
class DeviceModel(Base):
|
||||||
|
__tablename__ = "device_models"
|
||||||
|
|
||||||
|
model_id = Column(String(26), primary_key=True)
|
||||||
|
series_id = Column(String(26), ForeignKey("device_series.series_id", ondelete="SET NULL"), nullable=True)
|
||||||
|
brand_id = Column(String(26), ForeignKey("brands.brand_id", ondelete="SET NULL"), nullable=True)
|
||||||
|
name = Column(String(100), nullable=False)
|
||||||
|
slug = Column(String(100), unique=True, nullable=False)
|
||||||
|
device_type = Column(String(50), nullable=True) # laptop, tablet, mobile
|
||||||
|
full_path = Column(String(500), nullable=False)
|
||||||
|
release_year = Column(Integer, nullable=True)
|
||||||
|
image_url = Column(String(500), nullable=True)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
|
||||||
|
series = relationship("DeviceSeries", back_populates="models")
|
||||||
|
brand = relationship("Brand")
|
||||||
|
services = relationship("RepairService", primaryjoin="DeviceModel.model_id == RepairService.model_id", foreign_keys="[RepairService.model_id]", back_populates="model", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
class ServiceType(Base):
|
||||||
|
__tablename__ = "service_types"
|
||||||
|
|
||||||
|
service_type_id = Column(String(26), primary_key=True)
|
||||||
|
name = Column(String(100), unique=True, nullable=False)
|
||||||
|
slug = Column(String(100), unique=True, nullable=False)
|
||||||
|
icon_url = Column(String(500), nullable=True)
|
||||||
|
description = Column(String(500), nullable=True)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
|
||||||
|
class RepairService(Base):
|
||||||
|
__tablename__ = "repair_services"
|
||||||
|
|
||||||
|
repair_service_id = Column(String(26), primary_key=True)
|
||||||
|
model_id = Column(String(26), nullable=False)
|
||||||
|
service_type_id = Column(String(26), ForeignKey("service_types.service_type_id", ondelete="RESTRICT"), nullable=False)
|
||||||
|
slug = Column(String(100), nullable=False)
|
||||||
|
full_path = Column(String(500), nullable=False)
|
||||||
|
description = Column(String(1000), nullable=True)
|
||||||
|
|
||||||
|
model = relationship("DeviceModel", primaryjoin="DeviceModel.model_id == RepairService.model_id", foreign_keys=[model_id], back_populates="services")
|
||||||
|
service_type = relationship("ServiceType")
|
||||||
|
variants = relationship("RepairVariant", back_populates="repair_service", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
class RepairVariant(Base):
|
||||||
|
__tablename__ = "repair_variants"
|
||||||
|
|
||||||
|
variant_id = Column(String(26), primary_key=True)
|
||||||
|
repair_service_id = Column(String(26), ForeignKey("repair_services.repair_service_id", ondelete="CASCADE"), nullable=False)
|
||||||
|
name = Column(String(100), nullable=False) # e.g. "Original OLED", "Premium OLED"
|
||||||
|
price = Column(Numeric(10, 2), nullable=False)
|
||||||
|
cost = Column(Numeric(10, 2), nullable=False)
|
||||||
|
duration_minutes = Column(Integer, default=45)
|
||||||
|
warranty_days = Column(Integer, default=90)
|
||||||
|
status = Column(String(20), default="active") # active, inactive
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
|
repair_service = relationship("RepairService", back_populates="variants")
|
||||||
|
images = relationship("RepairVariantImage", back_populates="variant", cascade="all, delete-orphan")
|
||||||
|
bom_parts = relationship("RepairVariantPart", back_populates="variant", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
class RepairVariantImage(Base):
|
||||||
|
__tablename__ = "repair_variant_images"
|
||||||
|
|
||||||
|
image_id = Column(String(26), primary_key=True)
|
||||||
|
variant_id = Column(String(26), ForeignKey("repair_variants.variant_id", ondelete="CASCADE"), nullable=False)
|
||||||
|
image_url = Column(String(500), nullable=False)
|
||||||
|
sort_order = Column(Integer, default=0)
|
||||||
|
|
||||||
|
variant = relationship("RepairVariant", back_populates="images")
|
||||||
61
app/models/EcomCustomerModel.py
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import ulid
|
||||||
|
from sqlalchemy import Column, String, Boolean, DateTime, func, ForeignKey
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from app.core.database.db_session import Base
|
||||||
|
|
||||||
|
class EcomCustomer(Base):
|
||||||
|
__tablename__ = "ecom_customers"
|
||||||
|
|
||||||
|
customer_id = Column(String(26), primary_key=True, default=lambda: str(ulid.ULID()))
|
||||||
|
google_id = Column(String(128), unique=True, index=True, nullable=True)
|
||||||
|
email = Column(String(255), unique=True, index=True, nullable=False)
|
||||||
|
password_hash = Column(String(255), nullable=True)
|
||||||
|
first_name = Column(String(100), nullable=False)
|
||||||
|
last_name = Column(String(100), nullable=False)
|
||||||
|
phone = Column(String(30), unique=True, index=True, nullable=True)
|
||||||
|
profile_picture = Column(String(1024), nullable=True)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
email_verified = Column(Boolean, default=False)
|
||||||
|
phone_verified = Column(Boolean, default=False)
|
||||||
|
last_login = Column(DateTime, nullable=True)
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
orders = relationship("Order", back_populates="customer")
|
||||||
|
addresses = relationship("CustomerAddress", back_populates="customer", cascade="all, delete-orphan")
|
||||||
|
refresh_tokens = relationship("CustomerRefreshToken", back_populates="customer", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
class CustomerAddress(Base):
|
||||||
|
__tablename__ = "customer_addresses"
|
||||||
|
|
||||||
|
address_id = Column(String(26), primary_key=True, default=lambda: str(ulid.ULID()))
|
||||||
|
customer_id = Column(String(26), ForeignKey("ecom_customers.customer_id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
address_type = Column(String(20), default="SHIPPING") # SHIPPING, BILLING
|
||||||
|
full_name = Column(String(150), nullable=False)
|
||||||
|
phone = Column(String(30), nullable=False)
|
||||||
|
street_address = Column(String(255), nullable=False)
|
||||||
|
city = Column(String(100), nullable=False)
|
||||||
|
state = Column(String(100), nullable=False)
|
||||||
|
pincode = Column(String(20), nullable=False, index=True)
|
||||||
|
is_default = Column(Boolean, default=False)
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
|
customer = relationship("EcomCustomer", back_populates="addresses")
|
||||||
|
|
||||||
|
class CustomerRefreshToken(Base):
|
||||||
|
__tablename__ = "customer_refresh_tokens"
|
||||||
|
|
||||||
|
id = Column(String(26), primary_key=True, default=lambda: str(ulid.ULID()))
|
||||||
|
customer_id = Column(String(26), ForeignKey("ecom_customers.customer_id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
token_hash = Column(String(255), unique=True, index=True, nullable=False)
|
||||||
|
token_family_id = Column(String(26), nullable=False, index=True)
|
||||||
|
expires_at = Column(DateTime, nullable=False)
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
revoked_at = Column(DateTime, nullable=True)
|
||||||
|
last_used_at = Column(DateTime, nullable=True)
|
||||||
|
user_agent = Column(String(255), nullable=True)
|
||||||
|
ip_address = Column(String(45), nullable=True)
|
||||||
|
|
||||||
|
customer = relationship("EcomCustomer", back_populates="refresh_tokens")
|
||||||
|
|
||||||
55
app/models/FileUploadModel.py
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
from sqlalchemy import Column, String, Integer, BigInteger, Boolean, DateTime, ForeignKey, Text, CheckConstraint, Index
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from datetime import datetime
|
||||||
|
from app.core.database.db_session import Base
|
||||||
|
|
||||||
|
class FileUpload(Base):
|
||||||
|
__tablename__ = "file_uploads"
|
||||||
|
|
||||||
|
file_id = Column(String(26), primary_key=True)
|
||||||
|
original_name = Column(String(255), nullable=False)
|
||||||
|
stored_name = Column(String(100), nullable=True)
|
||||||
|
mime_type = Column(String(100), nullable=False)
|
||||||
|
extension = Column(String(10), nullable=True)
|
||||||
|
file_size = Column(BigInteger, nullable=False)
|
||||||
|
storage_provider = Column(String(30), nullable=False, default="LOCAL")
|
||||||
|
|
||||||
|
# Path variants
|
||||||
|
raw_path = Column(String(500), nullable=True)
|
||||||
|
webp_path = Column(String(500), nullable=True)
|
||||||
|
thumbnail_path = Column(String(500), nullable=True)
|
||||||
|
medium_path = Column(String(500), nullable=True)
|
||||||
|
large_path = Column(String(500), nullable=True)
|
||||||
|
storage_path = Column(String(500), nullable=True) # Legacy fallback compatibility
|
||||||
|
|
||||||
|
entity_type = Column(String(50), nullable=True)
|
||||||
|
entity_id = Column(String(50), nullable=True)
|
||||||
|
blur_hash = Column(String(255), nullable=True)
|
||||||
|
|
||||||
|
# State machine status
|
||||||
|
status = Column(
|
||||||
|
String(30),
|
||||||
|
CheckConstraint("status IN ('ACTIVE', 'ORPHANED', 'PENDING_DELETE', 'DELETED', 'INCONSISTENT')"),
|
||||||
|
nullable=False,
|
||||||
|
default="ACTIVE",
|
||||||
|
server_default="ACTIVE"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Lifecycle audit timestamps & tracking
|
||||||
|
orphaned_at = Column(DateTime, nullable=True)
|
||||||
|
cleanup_claimed_at = Column(DateTime, nullable=True)
|
||||||
|
cleanup_started_at = Column(DateTime, nullable=True)
|
||||||
|
deleted_at = Column(DateTime, nullable=True)
|
||||||
|
last_reconciled_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
cleanup_attempts = Column(Integer, nullable=False, default=0)
|
||||||
|
last_cleanup_error = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
uploaded_by = Column(String(26), ForeignKey("users.user_id", ondelete="SET NULL"), nullable=True)
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_file_uploads_status_orphaned", "status", "orphaned_at"),
|
||||||
|
)
|
||||||
|
|
||||||
47
app/models/GeoModel.py
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
from sqlalchemy import Column, String, Boolean, DateTime, Integer, ForeignKey, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from app.core.database.db_session import Base
|
||||||
|
|
||||||
|
class Country(Base):
|
||||||
|
__tablename__ = "countries"
|
||||||
|
|
||||||
|
country_id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
country_name = Column(String(100), nullable=False)
|
||||||
|
country_code = Column(String(3), unique=True, nullable=False)
|
||||||
|
currency = Column(String(10), nullable=False)
|
||||||
|
currency_symbol = Column(String(5), nullable=False)
|
||||||
|
phone_code = Column(String(10), nullable=False)
|
||||||
|
timezone = Column(String(100), default="UTC")
|
||||||
|
iso2 = Column(String(2), nullable=False)
|
||||||
|
iso3 = Column(String(3), nullable=False)
|
||||||
|
continent = Column(String(50), nullable=False)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
deleted_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
class State(Base):
|
||||||
|
__tablename__ = "states"
|
||||||
|
|
||||||
|
state_id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
country_id = Column(Integer, ForeignKey("countries.country_id", ondelete="RESTRICT"), nullable=False)
|
||||||
|
state_name = Column(String(100), nullable=False)
|
||||||
|
state_code = Column(String(10), nullable=False)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
deleted_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
country = relationship("Country", foreign_keys=[country_id])
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("country_id", "state_code", name="uq_country_state"),
|
||||||
|
)
|
||||||
|
|
||||||
|
class City(Base):
|
||||||
|
__tablename__ = "cities"
|
||||||
|
|
||||||
|
city_id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
state_id = Column(Integer, ForeignKey("states.state_id", ondelete="RESTRICT"), nullable=False)
|
||||||
|
city_name = Column(String(100), nullable=False)
|
||||||
|
postal_code = Column(String(15), nullable=False)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
deleted_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
state = relationship("State", foreign_keys=[state_id])
|
||||||
22
app/models/InventoryLedgerModel.py
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import ulid
|
||||||
|
from sqlalchemy import Column, String, Integer, DateTime, Index
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from app.core.database.db_session import Base
|
||||||
|
|
||||||
|
class InventoryLedger(Base):
|
||||||
|
__tablename__ = "inventory_ledger"
|
||||||
|
|
||||||
|
ledger_id = Column(String(26), primary_key=True, default=lambda: str(ulid.new()))
|
||||||
|
variant_id = Column(String(26), nullable=False, index=True)
|
||||||
|
event_type = Column(String(50), nullable=False, index=True) # RECEIPT, ONLINE_RESERVE, ONLINE_RESERVE_RELEASE, ONLINE_SALE_FROM_RESERVATION, DIRECT_ONLINE_SALE, POS_ALLOCATION, POS_SALE_FROM_ALLOCATION, DIRECT_POS_SALE, RETURN, DAMAGE
|
||||||
|
qty = Column(Integer, nullable=False)
|
||||||
|
|
||||||
|
warehouse_id = Column(String(26), nullable=True)
|
||||||
|
store_id = Column(String(26), nullable=True)
|
||||||
|
reference_id = Column(String(100), nullable=True, index=True) # Order ID / POS Tx ID
|
||||||
|
notes = Column(String(255), nullable=True)
|
||||||
|
created_at = Column(DateTime, server_default=func.now(), index=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index('idx_ledger_variant_event', 'variant_id', 'event_type', 'created_at'),
|
||||||
|
)
|
||||||
28
app/models/InvoiceModel.py
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import ulid
|
||||||
|
from sqlalchemy import Column, String, Numeric, DateTime, Text, Index
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from app.core.database.db_session import Base
|
||||||
|
|
||||||
|
class Invoice(Base):
|
||||||
|
__tablename__ = "invoices"
|
||||||
|
|
||||||
|
invoice_id = Column(String(26), primary_key=True, default=lambda: str(ulid.new()))
|
||||||
|
invoice_no = Column(String(16), unique=True, nullable=False, index=True) # CBIC 16-char limit format: C1P2-26-000452
|
||||||
|
order_id = Column(String(26), nullable=True, index=True)
|
||||||
|
pos_transaction_id = Column(String(26), nullable=True, index=True)
|
||||||
|
customer_id = Column(String(26), nullable=False, index=True)
|
||||||
|
|
||||||
|
subtotal = Column(Numeric(10, 2), nullable=False)
|
||||||
|
discount_amount = Column(Numeric(10, 2), default=0.00)
|
||||||
|
cgst = Column(Numeric(10, 2), default=0.00)
|
||||||
|
sgst = Column(Numeric(10, 2), default=0.00)
|
||||||
|
igst = Column(Numeric(10, 2), default=0.00)
|
||||||
|
total_amount = Column(Numeric(10, 2), nullable=False)
|
||||||
|
|
||||||
|
pdf_path = Column(String(500), nullable=True)
|
||||||
|
status = Column(String(30), default="GENERATED") # GENERATED, CANCELLED, REFUNDED
|
||||||
|
created_at = Column(DateTime, server_default=func.now(), index=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index('idx_invoices_customer_date', 'customer_id', 'created_at'),
|
||||||
|
)
|
||||||
259
app/models/MigrationModel.py
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
import enum
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import Column, String, Integer, BigInteger, Boolean, Enum as SQLEnum, Text, JSON, DateTime, ForeignKey
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from app.core.database.db_session import Base
|
||||||
|
|
||||||
|
def generate_uuid():
|
||||||
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
class BatchTypeEnum(str, enum.Enum):
|
||||||
|
PRODUCTS = "PRODUCTS"
|
||||||
|
VARIANTS = "VARIANTS"
|
||||||
|
MEDIA = "MEDIA"
|
||||||
|
INVENTORY = "INVENTORY"
|
||||||
|
CRM_LEADS = "CRM_LEADS"
|
||||||
|
REPAIR_CATALOG = "REPAIR_CATALOG"
|
||||||
|
CUSTOMERS = "CUSTOMERS"
|
||||||
|
WAREHOUSES = "WAREHOUSES"
|
||||||
|
|
||||||
|
class ImportModeEnum(str, enum.Enum):
|
||||||
|
CREATE_ONLY = "CREATE_ONLY"
|
||||||
|
UPDATE_EXISTING = "UPDATE_EXISTING"
|
||||||
|
UPSERT = "UPSERT"
|
||||||
|
SKIP_EXISTING = "SKIP_EXISTING"
|
||||||
|
|
||||||
|
class BatchStatusEnum(str, enum.Enum):
|
||||||
|
PENDING = "PENDING"
|
||||||
|
VALIDATING = "VALIDATING"
|
||||||
|
READY = "READY"
|
||||||
|
PROCESSING = "PROCESSING"
|
||||||
|
COMPLETED = "COMPLETED"
|
||||||
|
FAILED = "FAILED"
|
||||||
|
ROLLED_BACK = "ROLLED_BACK"
|
||||||
|
|
||||||
|
class JobStatusEnum(str, enum.Enum):
|
||||||
|
QUEUED = "QUEUED"
|
||||||
|
VALIDATING = "VALIDATING"
|
||||||
|
RUNNING = "RUNNING"
|
||||||
|
CANCELLING = "CANCELLING"
|
||||||
|
CANCELLED = "CANCELLED"
|
||||||
|
FAILED = "FAILED"
|
||||||
|
COMPLETED = "COMPLETED"
|
||||||
|
|
||||||
|
class PhaseEnum(str, enum.Enum):
|
||||||
|
UPLOAD = "UPLOAD"
|
||||||
|
VALIDATE = "VALIDATE"
|
||||||
|
DRY_RUN = "DRY_RUN"
|
||||||
|
MASTER_DATA = "MASTER_DATA"
|
||||||
|
PRODUCTS = "PRODUCTS"
|
||||||
|
VARIANTS = "VARIANTS"
|
||||||
|
MEDIA_PROCESS = "MEDIA_PROCESS"
|
||||||
|
MEDIA_LINK = "MEDIA_LINK"
|
||||||
|
VERIFY = "VERIFY"
|
||||||
|
COMPLETED = "COMPLETED"
|
||||||
|
|
||||||
|
class MediaItemStatusEnum(str, enum.Enum):
|
||||||
|
PENDING = "PENDING"
|
||||||
|
PROCESSING = "PROCESSING"
|
||||||
|
STORED = "STORED"
|
||||||
|
REGISTERED = "REGISTERED"
|
||||||
|
FAILED = "FAILED"
|
||||||
|
|
||||||
|
class RetryStatusEnum(str, enum.Enum):
|
||||||
|
UNRESOLVED = "UNRESOLVED"
|
||||||
|
RETRYING = "RETRYING"
|
||||||
|
RETRIED = "RETRIED"
|
||||||
|
RESOLVED = "RESOLVED"
|
||||||
|
SKIPPED = "SKIPPED"
|
||||||
|
PERMANENT_FAILURE = "PERMANENT_FAILURE"
|
||||||
|
|
||||||
|
class ErrorSeverityEnum(str, enum.Enum):
|
||||||
|
ERROR = "ERROR"
|
||||||
|
WARNING = "WARNING"
|
||||||
|
|
||||||
|
class MediaSourceTypeEnum(str, enum.Enum):
|
||||||
|
MEDIA_JSON = "MEDIA_JSON"
|
||||||
|
FOLDER_PATH = "FOLDER_PATH"
|
||||||
|
|
||||||
|
class MigrationBatch(Base):
|
||||||
|
__tablename__ = "migration_batches"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=generate_uuid)
|
||||||
|
batch_type = Column(SQLEnum(BatchTypeEnum), nullable=False, default=BatchTypeEnum.PRODUCTS)
|
||||||
|
user_id = Column(String(36), nullable=False)
|
||||||
|
import_mode = Column(SQLEnum(ImportModeEnum), nullable=False, default=ImportModeEnum.UPSERT)
|
||||||
|
status = Column(SQLEnum(BatchStatusEnum), nullable=False, default=BatchStatusEnum.PENDING)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
completed_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
jobs = relationship("MigrationJob", back_populates="batch", cascade="all, delete-orphan")
|
||||||
|
snapshots = relationship("MigrationSnapshot", back_populates="batch", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
class MappingConfig(Base):
|
||||||
|
__tablename__ = "mapping_configs"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=generate_uuid)
|
||||||
|
name = Column(String(100), nullable=False)
|
||||||
|
entity_type = Column(String(50), nullable=False)
|
||||||
|
column_maps = Column(JSON, nullable=False)
|
||||||
|
created_by = Column(String(36), nullable=False)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
class MediaGroup(Base):
|
||||||
|
__tablename__ = "media_groups"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=generate_uuid)
|
||||||
|
media_key = Column(String(255), unique=True, nullable=False, index=True)
|
||||||
|
source_type = Column(SQLEnum(MediaSourceTypeEnum), nullable=False, default=MediaSourceTypeEnum.FOLDER_PATH)
|
||||||
|
brand_name = Column(String(100), nullable=True)
|
||||||
|
model_name = Column(String(100), nullable=True)
|
||||||
|
variant_tag = Column(String(100), nullable=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
media_assets = relationship("MediaAsset", back_populates="group", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
class MediaAsset(Base):
|
||||||
|
__tablename__ = "media_library"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=generate_uuid)
|
||||||
|
media_group_id = Column(String(36), ForeignKey("media_groups.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
original_filename = Column(String(255), nullable=False)
|
||||||
|
stored_filename = Column(String(255), nullable=False)
|
||||||
|
mime_type = Column(String(50), nullable=False)
|
||||||
|
file_size_bytes = Column(BigInteger, nullable=False)
|
||||||
|
width = Column(Integer, nullable=False, default=0)
|
||||||
|
height = Column(Integer, nullable=False, default=0)
|
||||||
|
sha256_checksum = Column(String(64), unique=True, nullable=False, index=True) # Global unique SHA-256 asset
|
||||||
|
cdn_url = Column(String(512), nullable=False)
|
||||||
|
thumbnail_url = Column(String(512), nullable=False)
|
||||||
|
storage_path = Column(String(512), nullable=True)
|
||||||
|
exif_metadata = Column(JSON, nullable=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
group = relationship("MediaGroup", back_populates="media_assets")
|
||||||
|
|
||||||
|
class MigrationJob(Base):
|
||||||
|
__tablename__ = "migration_jobs"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=generate_uuid)
|
||||||
|
batch_id = Column(String(36), ForeignKey("migration_batches.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
job_type = Column(String(50), nullable=False, default="PRODUCTS")
|
||||||
|
is_dry_run = Column(Boolean, nullable=False, default=False)
|
||||||
|
file_name = Column(String(255), nullable=False, default="")
|
||||||
|
file_format = Column(String(10), nullable=False, default="CSV")
|
||||||
|
|
||||||
|
status = Column(SQLEnum(JobStatusEnum), nullable=False, default=JobStatusEnum.QUEUED, index=True)
|
||||||
|
current_phase = Column(SQLEnum(PhaseEnum), nullable=False, default=PhaseEnum.UPLOAD, index=True)
|
||||||
|
|
||||||
|
# Worker Lease & Lock
|
||||||
|
worker_id = Column(String(64), nullable=True, index=True)
|
||||||
|
locked_at = Column(DateTime, nullable=True)
|
||||||
|
heartbeat_at = Column(DateTime, nullable=True)
|
||||||
|
lease_version = Column(BigInteger, nullable=False, default=1)
|
||||||
|
|
||||||
|
# Cancellation
|
||||||
|
cancel_requested_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
# Phase Progress
|
||||||
|
current_batch = Column(Integer, nullable=False, default=0)
|
||||||
|
total_batches = Column(Integer, nullable=False, default=0)
|
||||||
|
last_successful_batch = Column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
# Overall Progress
|
||||||
|
processed_records = Column(Integer, nullable=False, default=0)
|
||||||
|
total_records = Column(Integer, nullable=False, default=0)
|
||||||
|
successful_records = Column(Integer, nullable=False, default=0)
|
||||||
|
failed_records = Column(Integer, nullable=False, default=0)
|
||||||
|
warning_records = Column(Integer, nullable=False, default=0)
|
||||||
|
retry_count = Column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
# Baseline Expectations (recorded during validation phase)
|
||||||
|
expected_products = Column(Integer, nullable=False, default=0)
|
||||||
|
expected_variants = Column(Integer, nullable=False, default=0)
|
||||||
|
expected_media_items = Column(Integer, nullable=False, default=0)
|
||||||
|
expected_media_links = Column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
# Timestamps & Errors
|
||||||
|
started_at = Column(DateTime, nullable=True)
|
||||||
|
completed_at = Column(DateTime, nullable=True)
|
||||||
|
failed_at = Column(DateTime, nullable=True)
|
||||||
|
finished_at = Column(DateTime, nullable=True)
|
||||||
|
error_message = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
batch = relationship("MigrationBatch", back_populates="jobs")
|
||||||
|
checkpoints = relationship("MigrationJobCheckpoint", back_populates="job", cascade="all, delete-orphan")
|
||||||
|
media_items = relationship("MigrationMediaItem", back_populates="job", cascade="all, delete-orphan")
|
||||||
|
errors = relationship("MigrationError", back_populates="job", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
class MigrationJobCheckpoint(Base):
|
||||||
|
__tablename__ = "migration_job_checkpoints"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=generate_uuid)
|
||||||
|
job_id = Column(String(36), ForeignKey("migration_jobs.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
phase = Column(SQLEnum(PhaseEnum), nullable=False, index=True)
|
||||||
|
last_successful_batch = Column(Integer, nullable=False, default=0)
|
||||||
|
total_batches = Column(Integer, nullable=False, default=0)
|
||||||
|
processed_records = Column(Integer, nullable=False, default=0)
|
||||||
|
failed_records = Column(Integer, nullable=False, default=0)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
job = relationship("MigrationJob", back_populates="checkpoints")
|
||||||
|
|
||||||
|
class MigrationMediaItem(Base):
|
||||||
|
__tablename__ = "migration_media_items"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=generate_uuid)
|
||||||
|
job_id = Column(String(36), ForeignKey("migration_jobs.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
batch_number = Column(Integer, nullable=False, default=0)
|
||||||
|
file_name = Column(String(255), nullable=False)
|
||||||
|
archive_name = Column(String(255), nullable=True)
|
||||||
|
zip_entry_path = Column(String(512), nullable=True)
|
||||||
|
media_key = Column(String(255), nullable=False, index=True)
|
||||||
|
sha256 = Column(String(64), nullable=True, index=True) # NOT unique (allows multiple source references to map to 1 MediaAsset)
|
||||||
|
storage_path = Column(String(512), nullable=True)
|
||||||
|
status = Column(SQLEnum(MediaItemStatusEnum), nullable=False, default=MediaItemStatusEnum.PENDING, index=True)
|
||||||
|
error = Column(Text, nullable=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
job = relationship("MigrationJob", back_populates="media_items")
|
||||||
|
|
||||||
|
class MigrationError(Base):
|
||||||
|
__tablename__ = "migration_errors"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=generate_uuid)
|
||||||
|
job_id = Column(String(36), ForeignKey("migration_jobs.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
batch_number = Column(Integer, nullable=False, default=0)
|
||||||
|
row_number = Column(Integer, nullable=False)
|
||||||
|
sku = Column(String(100), nullable=True)
|
||||||
|
product_name = Column(String(255), nullable=True)
|
||||||
|
file_name = Column(String(255), nullable=True)
|
||||||
|
entity_type = Column(String(50), nullable=True)
|
||||||
|
entity_id = Column(String(36), nullable=True)
|
||||||
|
phase = Column(String(50), nullable=False, default="VALIDATE")
|
||||||
|
error_type = Column(String(100), nullable=False, default="DATA_ERROR")
|
||||||
|
severity = Column(SQLEnum(ErrorSeverityEnum), nullable=False, default=ErrorSeverityEnum.ERROR)
|
||||||
|
field_name = Column(String(100), nullable=True)
|
||||||
|
error_message = Column(Text, nullable=False)
|
||||||
|
suggested_fix = Column(Text, nullable=True)
|
||||||
|
retry_status = Column(SQLEnum(RetryStatusEnum), nullable=False, default=RetryStatusEnum.UNRESOLVED)
|
||||||
|
attempt_count = Column(Integer, nullable=False, default=1)
|
||||||
|
raw_row_data = Column(JSON, nullable=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
resolved_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
job = relationship("MigrationJob", back_populates="errors")
|
||||||
|
|
||||||
|
class MigrationSnapshot(Base):
|
||||||
|
__tablename__ = "migration_snapshots"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=generate_uuid)
|
||||||
|
batch_id = Column(String(36), ForeignKey("migration_batches.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
entity_table = Column(String(50), nullable=False)
|
||||||
|
entity_id = Column(String(36), nullable=False)
|
||||||
|
action_taken = Column(String(20), nullable=False)
|
||||||
|
previous_state = Column(JSON, nullable=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
batch = relationship("MigrationBatch", back_populates="snapshots")
|
||||||
67
app/models/OrderModel.py
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import ulid
|
||||||
|
from sqlalchemy import Column, String, Numeric, DateTime, Integer, ForeignKey, Text, Index
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from app.core.database.db_session import Base
|
||||||
|
|
||||||
|
class Order(Base):
|
||||||
|
__tablename__ = "orders"
|
||||||
|
|
||||||
|
order_id = Column(String(26), primary_key=True, default=lambda: str(ulid.new()))
|
||||||
|
order_no = Column(String(50), unique=True, nullable=False, index=True)
|
||||||
|
customer_id = Column(String(26), ForeignKey("ecom_customers.customer_id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
|
||||||
|
total_amount = Column(Numeric(10, 2), nullable=False)
|
||||||
|
discount_amount = Column(Numeric(10, 2), default=0.00)
|
||||||
|
tax_amount = Column(Numeric(10, 2), default=0.00)
|
||||||
|
shipping_cost = Column(Numeric(10, 2), default=0.00)
|
||||||
|
final_amount = Column(Numeric(10, 2), nullable=False)
|
||||||
|
|
||||||
|
status = Column(String(50), default="ORDER_CREATED", index=True) # ORDER_CREATED, PAYMENT_PENDING, ORDER_CONFIRMED, PROCESSING, PACKED, SHIPPED, DELIVERED, CANCELLED
|
||||||
|
payment_status = Column(String(50), default="PAYMENT_PENDING", index=True) # PAYMENT_PENDING, PAYMENT_CAPTURED, PAYMENT_FAILED, REFUNDED
|
||||||
|
fulfillment_status = Column(String(50), default="UNFULFILLED")
|
||||||
|
|
||||||
|
shipping_address_json = Column(Text, nullable=True)
|
||||||
|
billing_address_json = Column(Text, nullable=True)
|
||||||
|
tracking_number = Column(String(100), nullable=True)
|
||||||
|
courier_name = Column(String(100), nullable=True)
|
||||||
|
|
||||||
|
created_at = Column(DateTime, server_default=func.now(), index=True)
|
||||||
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index('idx_orders_customer_created', 'customer_id', 'created_at'),
|
||||||
|
Index('idx_orders_status_created', 'status', 'created_at'),
|
||||||
|
)
|
||||||
|
|
||||||
|
customer = relationship("EcomCustomer", back_populates="orders")
|
||||||
|
items = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan")
|
||||||
|
history = relationship("OrderStatusHistory", back_populates="order", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
class OrderItem(Base):
|
||||||
|
__tablename__ = "order_items"
|
||||||
|
|
||||||
|
item_id = Column(String(26), primary_key=True, default=lambda: str(ulid.new()))
|
||||||
|
order_id = Column(String(26), ForeignKey("orders.order_id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
product_id = Column(String(26), nullable=False)
|
||||||
|
variant_id = Column(String(26), nullable=False, index=True)
|
||||||
|
product_name = Column(String(255), nullable=False)
|
||||||
|
sku = Column(String(100), nullable=False)
|
||||||
|
unit_price = Column(Numeric(10, 2), nullable=False)
|
||||||
|
quantity = Column(Integer, nullable=False)
|
||||||
|
total_price = Column(Numeric(10, 2), nullable=False)
|
||||||
|
|
||||||
|
order = relationship("Order", back_populates="items")
|
||||||
|
|
||||||
|
class OrderStatusHistory(Base):
|
||||||
|
__tablename__ = "order_status_history"
|
||||||
|
|
||||||
|
history_id = Column(String(26), primary_key=True, default=lambda: str(ulid.new()))
|
||||||
|
order_id = Column(String(26), ForeignKey("orders.order_id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
previous_status = Column(String(50), nullable=True)
|
||||||
|
new_status = Column(String(50), nullable=False)
|
||||||
|
changed_by = Column(String(100), default="SYSTEM")
|
||||||
|
reason = Column(String(255), nullable=True)
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
|
order = relationship("Order", back_populates="history")
|
||||||
20
app/models/OutboxEventModel.py
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import ulid
|
||||||
|
from sqlalchemy import Column, String, Integer, DateTime, JSON, Index
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from app.core.database.db_session import Base
|
||||||
|
|
||||||
|
class OutboxEvent(Base):
|
||||||
|
__tablename__ = "outbox_events"
|
||||||
|
|
||||||
|
event_id = Column(String(26), primary_key=True, default=lambda: str(ulid.new()))
|
||||||
|
aggregate_type = Column(String(50), nullable=False, index=True) # PRODUCT, ORDER, INVENTORY, INVOICE
|
||||||
|
aggregate_id = Column(String(26), nullable=False, index=True)
|
||||||
|
aggregate_version = Column(Integer, nullable=False)
|
||||||
|
payload_json = Column(JSON, nullable=False)
|
||||||
|
status = Column(String(20), default="PENDING", index=True) # PENDING, PROCESSED, FAILED
|
||||||
|
created_at = Column(DateTime, server_default=func.now(), index=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index('idx_outbox_status_created', 'status', 'created_at'),
|
||||||
|
Index('idx_outbox_aggregate_ver', 'aggregate_type', 'aggregate_id', 'aggregate_version'),
|
||||||
|
)
|
||||||
37
app/models/POSTerminalModel.py
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
from sqlalchemy import Column, String, Integer, Float, DateTime, Text, ForeignKey
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime
|
||||||
|
from app.core.database.db_session import Base
|
||||||
|
|
||||||
|
class POSTerminal(Base):
|
||||||
|
__tablename__ = "pos_terminals"
|
||||||
|
__bind_key__ = "commerce"
|
||||||
|
|
||||||
|
terminal_id = Column(String(50), primary_key=True)
|
||||||
|
store_name = Column(String(150), nullable=False)
|
||||||
|
status = Column(String(20), default="ONLINE") # ONLINE, IDLE, OFFLINE
|
||||||
|
ip_address = Column(String(45), nullable=True)
|
||||||
|
last_heartbeat = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
synced_today = Column(Integer, default=0)
|
||||||
|
pending_queue = Column(Integer, default=0)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
logs = relationship("POSTransactionLog", back_populates="terminal", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class POSTransactionLog(Base):
|
||||||
|
__tablename__ = "pos_transaction_logs"
|
||||||
|
__bind_key__ = "commerce"
|
||||||
|
|
||||||
|
sync_id = Column(String(50), primary_key=True)
|
||||||
|
terminal_id = Column(String(50), ForeignKey("pos_terminals.terminal_id"), nullable=False)
|
||||||
|
invoice_no = Column(String(100), nullable=False, index=True)
|
||||||
|
pos_transaction_id = Column(String(100), nullable=False, index=True)
|
||||||
|
items_count = Column(Integer, default=1)
|
||||||
|
total_amount = Column(Float, default=0.0)
|
||||||
|
status = Column(String(20), default="SYNCED") # SYNCED, FAILED, PENDING_RETRY
|
||||||
|
error_detail = Column(Text, nullable=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow, index=True)
|
||||||
|
|
||||||
|
terminal = relationship("POSTerminal", back_populates="logs")
|
||||||
39
app/models/PartsModel.py
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
from sqlalchemy import Column, String, Boolean, Integer, Numeric, ForeignKey
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from app.core.database.db_session import Base
|
||||||
|
|
||||||
|
class Part(Base):
|
||||||
|
__tablename__ = "parts"
|
||||||
|
|
||||||
|
part_id = Column(String(26), primary_key=True)
|
||||||
|
sku = Column(String(100), unique=True, nullable=False)
|
||||||
|
name = Column(String(255), nullable=False)
|
||||||
|
cost_price = Column(Numeric(10, 2), nullable=False)
|
||||||
|
low_stock_alert = Column(Integer, default=3)
|
||||||
|
supplier = Column(String(255), nullable=True)
|
||||||
|
barcode = Column(String(100), unique=True, nullable=True)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
|
||||||
|
compatibilities = relationship("PartDeviceCompatibility", back_populates="part", cascade="all, delete-orphan")
|
||||||
|
bom_variants = relationship("RepairVariantPart", back_populates="part", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
class PartDeviceCompatibility(Base):
|
||||||
|
__tablename__ = "part_device_compatibility"
|
||||||
|
|
||||||
|
id = Column(String(26), primary_key=True)
|
||||||
|
part_id = Column(String(26), ForeignKey("parts.part_id", ondelete="CASCADE"), nullable=False)
|
||||||
|
model_id = Column(String(26), nullable=False)
|
||||||
|
|
||||||
|
part = relationship("Part", back_populates="compatibilities")
|
||||||
|
model = relationship("DeviceModel", primaryjoin="DeviceModel.model_id == PartDeviceCompatibility.model_id", foreign_keys=[model_id])
|
||||||
|
|
||||||
|
class RepairVariantPart(Base):
|
||||||
|
__tablename__ = "repair_variant_parts"
|
||||||
|
|
||||||
|
id = Column(String(26), primary_key=True)
|
||||||
|
variant_id = Column(String(26), ForeignKey("repair_variants.variant_id", ondelete="CASCADE"), nullable=False)
|
||||||
|
part_id = Column(String(26), ForeignKey("parts.part_id", ondelete="RESTRICT"), nullable=False)
|
||||||
|
quantity = Column(Integer, default=1, nullable=False)
|
||||||
|
|
||||||
|
variant = relationship("RepairVariant", back_populates="bom_parts")
|
||||||
|
part = relationship("Part", back_populates="bom_variants")
|
||||||
24
app/models/PaymentLedgerModel.py
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import ulid
|
||||||
|
from sqlalchemy import Column, String, Numeric, DateTime, Text, Index
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from app.core.database.db_session import Base
|
||||||
|
|
||||||
|
class PaymentLedger(Base):
|
||||||
|
__tablename__ = "payment_ledger"
|
||||||
|
|
||||||
|
payment_id = Column(String(26), primary_key=True, default=lambda: str(ulid.new()))
|
||||||
|
order_id = Column(String(26), nullable=False, index=True)
|
||||||
|
provider = Column(String(30), nullable=False) # RAZORPAY, CASH, POS_CARD
|
||||||
|
transaction_ref = Column(String(100), nullable=True, index=True) # razorpay_payment_id or receipt_no
|
||||||
|
razorpay_order_id = Column(String(100), nullable=True, index=True)
|
||||||
|
razorpay_signature = Column(String(255), nullable=True)
|
||||||
|
|
||||||
|
amount = Column(Numeric(10, 2), nullable=False)
|
||||||
|
currency = Column(String(10), default="INR")
|
||||||
|
status = Column(String(30), default="PENDING", index=True) # PENDING, CAPTURED, FAILED, REFUNDED
|
||||||
|
raw_response_json = Column(Text, nullable=True)
|
||||||
|
created_at = Column(DateTime, server_default=func.now(), index=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index('idx_payments_order_status', 'order_id', 'status'),
|
||||||
|
)
|
||||||