Initial commit - Backend API

This commit is contained in:
Adithiyan 2026-09-23 09:02:32 +02:00
commit 5383a46826
6414 changed files with 1141337 additions and 0 deletions

23
.env Normal file
View file

@ -0,0 +1,23 @@
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
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
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
RAZORPAY_KEY_ID=rzp_test_TTUzPFYF0hRV89
RAZORPAY_KEY_SECRET=74tmlSkS4qK7zQaH1zclQfeH
RAZORPAY_WEBHOOK_SECRET=6TjjXgErPG3@ZpM
RAZORPAY_ENABLED=true

30
.env.production Normal file
View 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

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
node_modules/\n__pycache__/\nvenv/\n.env\n*.pyc

View file

@ -0,0 +1,4 @@
Signature: 8a477f597d28d172789f06886806bc55
# This file is a cache directory tag created by pytest.
# For information about cache directory tags, see:
# https://bford.info/cachedir/spec.html

8
.pytest_cache/README.md Normal file
View file

@ -0,0 +1,8 @@
# pytest cache directory #
This directory contains data from the pytest's cache plugin,
which provides the `--lf` and `--ff` options, as well as the `cache` fixture.
**Do not** commit this to version control.
See [the docs](https://docs.pytest.org/en/stable/how-to/cache.html) for more information.

1
.pytest_cache/v/cache/lastfailed vendored Normal file
View file

@ -0,0 +1 @@
{}

8
.pytest_cache/v/cache/nodeids vendored Normal file
View file

@ -0,0 +1,8 @@
[
"tests/test_order_flow.py::test_cart_router_exports_sync_route",
"tests/test_order_flow.py::test_checkout_router_create_route_exists",
"tests/test_order_flow.py::test_final_amount_includes_tax",
"tests/test_order_flow.py::test_order_router_has_ulid_import",
"tests/test_service_engine.py::test_dynamic_slot_allocation_overlap",
"tests/test_service_engine.py::test_dynamic_slot_allocation_success"
]

23
Backend/.env Normal file
View file

@ -0,0 +1,23 @@
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
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
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
RAZORPAY_KEY_ID=rzp_test_TTUzPFYF0hRV89
RAZORPAY_KEY_SECRET=74tmlSkS4qK7zQaH1zclQfeH
RAZORPAY_WEBHOOK_SECRET=6TjjXgErPG3@ZpM
RAZORPAY_ENABLED=true

30
Backend/.env.production Normal file
View 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

0
Backend/app/__init__.py Normal file
View file

View file

Binary file not shown.

After

Width:  |  Height:  |  Size: 998 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View file

View 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
]
)

View 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"}
)

View file

@ -0,0 +1,353 @@
"""
@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"
@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 GST 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",
actor_email=current_user.email
)

View 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()
}

View file

@ -0,0 +1,275 @@
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.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"
@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:
raise HTTPException(status_code=401, detail="Invalid credentials")
if user.is_locked and user.deleted_at is None:
# Check if lockout period has expired (15 minutes)
# We can implement a lockout expiry check, but for simplicity, check last update time
# if 15 mins passed, unlock:
# For security, let's keep it locked until reset_password confirms, or 15 mins pass
# Since we lock dynamically, let's look at the DB fields:
# user has is_locked. Let's write a clean time-locked query if needed,
# or require password reset as configured.
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
if user.failed_login_attempts >= settings.MAX_FAILED_LOGIN:
user.is_locked = True
db.commit()
raise HTTPException(status_code=403, detail="Account locked due to too many failed attempts.")
db.commit()
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(
response: Response,
refresh_token: str = Cookie(None),
db: Session = Depends(get_db)
):
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:
raise HTTPException(status_code=401, detail="Invalid credentials")
if user.is_locked and user.deleted_at is None:
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
if user.failed_login_attempts >= settings.MAX_FAILED_LOGIN:
user.is_locked = True
db.commit()
raise HTTPException(status_code=403, detail="Account locked due to too many failed attempts.")
db.commit()
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"
}

View 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,
}

File diff suppressed because it is too large Load diff

View 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"
)

View 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"}

View 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"}

View 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),
}

View file

@ -0,0 +1,211 @@
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"}
@router.post("/upload", response_model=FileUploadResponse, status_code=status.HTTP_201_CREATED)
async def upload_file(
file: UploadFile = File(...),
entity_type: str = Form(...),
entity_id: str = Form(...),
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", 10))
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.")
# 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"}

View 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
}

View 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"
}

View file

@ -0,0 +1,42 @@
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),
current_user: User = Depends(get_current_user)
):
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),
current_user: User = Depends(get_current_user)
):
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),
current_user: User = Depends(get_current_user)
):
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)

View 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"}

View file

@ -0,0 +1,712 @@
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_months", "media_key", "barcode", "is_active", "description"
]
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", 6,
"apple_iphone_15_pro_screenguard", "8901234567890", "TRUE",
"Ultra-clear 9H hardness tempered glass screen guard with anti-scratch coating."
],
[
"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", 12,
"samsung_s24_ultra_case", "8901234567892", "TRUE",
"Military-grade dual layer shockproof armor cover with magnetic kickstand."
]
]
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"),
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)
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
}

View 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}

View file

@ -0,0 +1,128 @@
"""
@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
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.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"}

View 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.",
}

View 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
]
}

View file

@ -0,0 +1,156 @@
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))
permissions = db.execute(stmt).scalars().all()
if not permissions:
# Auto-seed standard platform permissions if permissions table is empty
seed_data = [
("users:read", "User Management", "View user lists and profile details"),
("users:write", "User Management", "Create and modify staff user accounts"),
("users:delete", "User Management", "Delete or deactivate staff user accounts"),
("roles:manage", "User Management", "Create, edit and assign roles & access control policies"),
("catalog:read", "Catalog Management", "View categories, brands, devices, and sales products"),
("catalog:write", "Catalog Management", "Create and edit product catalog items"),
("catalog:delete", "Catalog Management", "Delete catalog items, brands, and categories"),
("inventory:manage", "Inventory & Purchases", "Manage stock movements, warehouses, and purchase orders"),
("marketing:manage", "Marketing & Storefront", "Manage homepage sections, banners, and layout controls"),
("settings:manage", "System Infrastructure", "Configure global system settings and security policies"),
]
for code, group, desc in seed_data:
p_id = str(ulid.ULID())
parts = code.split(":")
module = parts[0]
action = parts[1]
resource = "general"
permission_code = f"{module}.{resource}.{action}"
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)
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"}

View file

@ -0,0 +1,392 @@
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
)
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")
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
parts = payload.customer_name.strip().split(maxsplit=1)
first_name = parts[0]
last_name = parts[1] if len(parts) > 1 else ""
email = payload.customer_email or f"walkin_{str(ulid.ULID())}@ifixkart.com"
phone = payload.customer_phone or f"walkin_{str(ulid.ULID())}"
existing = None
if payload.customer_phone:
existing = db.query(EcomCustomer).filter(EcomCustomer.phone == payload.customer_phone).first()
if existing:
customer_id = existing.customer_id
else:
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
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(["Technician", "Super Admin", "Admin"])),
db: Session = Depends(get_db)
):
"""Technicians submit verified damages, diagnostic results, and notes."""
return inspection_service.submit_inspection(db, job_id, current_user.user_id, payload)
@router.post("/jobs/{job_id}/quotes/create", response_model=ServiceJobQuoteResponse)
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 quote estimates. Marks older versions as SUPERSEDED."""
return quote_service.create_or_revise_quote(db, job_id, current_user.user_id, payload)
@router.post("/jobs/{job_id}/quotes/{quote_id}/respond", response_model=ServiceJobQuoteResponse)
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)
):
"""Customers or admins accept or reject estimates."""
from app.models.ServiceModel import ServiceJob as _SJ
from app.models.ServiceQuoteModel import ServiceJobQuote as _SJQ
quote = db.get(_SJQ, quote_id)
if not quote:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Quote not found")
job = db.get(_SJ, quote.service_job_id)
if not job:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Job not found")
# Admins/Technicians can respond on behalf of customers
if actor["type"] == "customer":
customer_id = actor["obj"].customer_id
else:
customer_id = job.customer_id # admin acting on behalf
if action == "REJECT":
result = quote_service.respond_to_quote(db, quote_id, customer_id, action)
refund_res = payment_service.process_quote_rejection_refund(db, quote.service_job_id)
result.update(refund_res)
return result
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,
customer: EcomCustomer = Depends(get_current_customer),
db: Session = Depends(get_db)
):
"""Generate Razorpay order for advance deposits or final balances."""
return payment_service.initiate_milestone_payment(
db, job_id, customer.customer_id, payload.payment_type, payload.quote_id
)
@router.post("/payments/verify")
def verify_milestone_payment(
payload: RazorpayVerificationRequest,
customer: EcomCustomer = Depends(get_current_customer),
db: Session = Depends(get_db)
):
"""Verify cryptographic Razorpay payment signature callback."""
return payment_service.verify_milestone_payment(db, 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)
):
"""Reschedule slot time. Cancels old slot, locks new slot, logs audit trail."""
if actor["type"] == "customer":
customer_id = actor["obj"].customer_id
else:
# Admin is rescheduling
job = db.get(ServiceJob, job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
customer_id = job.customer_id
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: dict = Depends(get_current_actor),
db: Session = Depends(get_db)
):
"""Retrieve service job, current appointment, timeline events, and active quotes."""
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["type"] == "customer" 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
# 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"),
"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"),
"created_at": j.created_at.isoformat()
} for j in jobs
]
@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
job = db.get(ServiceJob, job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
job.status = status
db.commit()
db.refresh(job)
return {"status": "success", "new_status": job.status}

View 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),
current_user: User = Depends(get_current_user)
):
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

View file

@ -0,0 +1,143 @@
"""
@router StorefrontRouter (Backend/app/api/v1/routers/StorefrontRouter.py)
@purpose Versioned Storefront API endpoints for customer reviews, trust badges, hot deals, blog posts, and footer settings.
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List, Dict, Any
from datetime import datetime, timedelta
import ulid
from app.core.database.db_session import get_db
router = APIRouter(prefix="/api/v1/storefront", tags=["Storefront Dynamic Services"])
@router.get("/reviews/{product_id}")
def get_product_reviews(product_id: str):
"""
Returns dynamic customer reviews for a given product.
"""
return [
{
"review_id": "rev_01",
"product_id": product_id,
"author_name": "Alex Morgan",
"rating": 5,
"verified_buyer": True,
"review_date": "2026-07-20",
"title": "Exceptional Quality!",
"comment": "Exceptional build quality and super-fast delivery! The product fits perfectly and exceeded expectations."
},
{
"review_id": "rev_02",
"product_id": product_id,
"author_name": "David Chen",
"rating": 5,
"verified_buyer": True,
"review_date": "2026-07-18",
"title": "Top Tier OEM Hardware",
"comment": "100% genuine OEM quality. Arrived well packaged within 24 hours. Will buy again!"
},
{
"review_id": "rev_03",
"product_id": product_id,
"author_name": "Samantha Reed",
"rating": 4,
"verified_buyer": True,
"review_date": "2026-07-15",
"title": "Great Value for Money",
"comment": "Works flawlessly with my device. Installation was simple and straightforward."
}
]
@router.get("/trust-badges")
def get_trust_badges():
"""
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():
"""
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. Why do people think...",
"author": "Support Desk",
"date": "July 15, 2026",
"image_url": ""
}
]
@router.get("/footer-info")
def get_footer_info():
"""
Returns dynamic footer contact info, store links, and legal links.
"""
return {
"phone": "(+00) 123-456-789",
"email": "demo@example.com",
"address": "99 New Theme St. XY, USA 12345, Beside the Sun point land.",
"copyright": "© 2026 TechShop_Electronics05 - WordPress Theme by Avanam",
"get_to_know_us": [
{"label": "About Us", "href": "/about"},
{"label": "Term & Policy", "href": "/terms"},
{"label": "Careers", "href": "/careers"},
{"label": "News & Blog", "href": "/blog"},
{"label": "Contact Us", "href": "/contact"}
],
"information": [
{"label": "Help Center", "href": "/help"},
{"label": "Feedback", "href": "/feedback"},
{"label": "FAQs", "href": "/faqs"},
{"label": "Size Guide", "href": "/size-guide"},
{"label": "Payments", "href": "/payments"}
],
"orders_returns": [
{"label": "Track Order", "href": "/account"},
{"label": "Delivery", "href": "/delivery"},
{"label": "Services", "href": "/services"},
{"label": "Returns", "href": "/returns"},
{"label": "Exchange", "href": "/exchange"}
],
"our_store": [
{"label": "Affiliate", "href": "/affiliate"},
{"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"}
]
}

View 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"}

View 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}

View file

View file

@ -0,0 +1,240 @@
"""
@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)):
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)):
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}

View 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)

View 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
Backend/app/core/Token.py Normal file
View 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"}
)

View file

View 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()

View file

View file

View 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()

View 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)

View file

@ -0,0 +1,391 @@
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
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.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
# 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}")
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)")
print("Bootstrap complete. All business data must be added via the Admin UI.")
if __name__ == "__main__":
initialize_database()

View 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()

View 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

View 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.")

View file

View 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)

View 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)

View 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"}
)

View 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)

View 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)

View file

@ -0,0 +1,198 @@
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))
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 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()

View 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

View file

View 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()

View file

View 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.")

View 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}")

View 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}")

View 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)

303
Backend/app/main.py Normal file
View file

@ -0,0 +1,303 @@
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.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
from pathlib import Path
uploads_path = str(Path(__file__).resolve().parents[1] / "uploads")
os.makedirs(uploads_path, exist_ok=True)
app.mount("/uploads", StaticFiles(directory=uploads_path), name="uploads")
# 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
# Only apply caching headers to static catalog/brand lists, NEVER layout configs which need instant admin updates
if "/storefront/layout" in path:
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(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.")

View 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()

View 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()

View 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())

View 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")

View 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]

View 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())

View file

@ -0,0 +1,21 @@
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)
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")

View 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())

View 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")

View 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())

View 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)

View 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)

View 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")

View 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")

View 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"),
)

View 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])

View 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'),
)

View 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'),
)

Some files were not shown because too many files have changed in this diff Show more