Initial project upload

This commit is contained in:
Mohamed Mathar Irfan
2026-07-28 17:57:02 +05:30
commit ed6610d5d8
23919 changed files with 3003316 additions and 0 deletions

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,437 @@
"""Super Admin Panel — Product Admin roles only. Org utilization, plans, revenue."""
import csv
import io
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Body, Depends, HTTPException, Response, status
from app.core.config import settings
from app.core.deps import require_admin_roles, Principal
from app.models import (
Organization, Plan, Product, QRCode, ScanLog, StorageUsage, Subscription,
Invoice, AuditLog, OrgMember, User, SupportNote, PlanHistory,
)
from app.models.documents import now
from app.models.enums import ProductAdminRole, SubscriptionStatus
router = APIRouter(prefix="/admin", tags=["admin"])
@router.get("/config-status")
async def config_status(p: Principal = Depends(require_admin_roles())):
"""Env-only credential status surfaced on the Product Admin settings page."""
return {
"google_oauth": settings.google_configured,
"razorpay": settings.razorpay_configured,
"aws_ses": settings.ses_configured,
"mock_db": settings.mock_db,
"mock_email": settings.mock_email,
"mock_storage": settings.mock_storage,
}
async def _utilization(org: Organization) -> dict:
oid = str(org.id)
plan = await Plan.get(org.plan_id) if org.plan_id else None
storage = await StorageUsage.find_one(StorageUsage.org_id == oid)
sub = await Subscription.find_one(Subscription.org_id == oid)
qr_count = await QRCode.find(QRCode.org_id == oid).count()
scans = await ScanLog.find(ScanLog.org_id == oid).count()
return {
"org_id": oid, "name": org.name, "logo_url": org.logo_url,
"plan": plan.name if plan else None,
"plan_price": plan.price if plan else 0,
"products_used": org.product_count,
"product_limit": plan.product_limit if plan else None,
"users_used": org.user_count,
"user_limit": plan.user_limit if plan else None,
"storage_used_gb": round((storage.used_bytes if storage else 0) / 1024 ** 3, 1),
"storage_total_gb": round((storage.total_bytes if storage else 0) / 1024 ** 3, 1),
"qr_codes": qr_count,
"total_scans": scans,
"subscription_status": org.subscription_status,
"renewal_date": sub.renewal_date if sub else None,
"payment_status": sub.payment_status if sub else None,
}
@router.get("/organizations")
async def list_orgs(p: Principal = Depends(require_admin_roles())):
orgs = await Organization.find_all().to_list()
return [await _utilization(o) for o in orgs]
@router.get("/organizations/{org_id}")
async def org_detail(org_id: str, p: Principal = Depends(require_admin_roles())):
org = await Organization.get(org_id)
if not org:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Org not found")
util = await _utilization(org)
members = await OrgMember.find(OrgMember.org_id == org_id).to_list()
admins = []
for m in members:
u = await User.get(m.user_id)
if u:
admins.append({"name": u.name, "email": u.email, "role": m.role,
"last_login": u.last_login})
return {**util, "admins": admins}
@router.get("/organizations/{org_id}/utilization")
async def org_utilization(org_id: str, p: Principal = Depends(require_admin_roles())):
org = await Organization.get(org_id)
if not org:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Org not found")
return await _utilization(org)
@router.post("/organizations/{org_id}/impersonate")
async def impersonate(org_id: str,
p: Principal = Depends(require_admin_roles(ProductAdminRole.super_admin))):
org = await Organization.get(org_id)
if not org:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Org not found")
await AuditLog(org_id=org_id, user_id=str(p.user.id), user_name=p.user.name,
action="org.impersonate", module="admin",
details=f"Impersonation started for {org.name}",
impersonator_id=str(p.user.id)).insert()
return {"message": f"Impersonating {org.name}", "org_id": org_id}
@router.get("/plans")
async def list_plans(p: Principal = Depends(require_admin_roles())):
plans = await Plan.find_all().to_list()
return [{"id": str(pl.id), "name": pl.name, "price": pl.price,
"product_limit": pl.product_limit, "user_limit": pl.user_limit,
"storage_gb": pl.storage_gb, "features": pl.features,
"enabled": pl.enabled} for pl in plans]
@router.post("/plans", status_code=201)
async def create_plan(body: dict = Body(...),
p: Principal = Depends(require_admin_roles(ProductAdminRole.super_admin))):
if await Plan.find_one(Plan.name == body.get("name")):
raise HTTPException(status.HTTP_409_CONFLICT, "Plan name already exists")
plan = Plan(
name=body.get("name", "New Plan"), price=int(body.get("price", 0)),
product_limit=body.get("product_limit"), user_limit=body.get("user_limit"),
storage_gb=int(body.get("storage_gb", 5)),
grace_days=int(body.get("grace_days", 30)),
archive_years=int(body.get("archive_years", 1)),
features=body.get("features", []), enabled=body.get("enabled", True),
)
await plan.insert()
await AuditLog(user_id=str(p.user.id), user_name=p.user.name,
action="plan.create", module="admin",
details=f"Created plan {plan.name}").insert()
return {"id": str(plan.id)}
@router.put("/plans/{plan_id}")
async def update_plan(plan_id: str, body: dict = Body(...),
p: Principal = Depends(require_admin_roles(
ProductAdminRole.super_admin, ProductAdminRole.finance))):
plan = await Plan.get(plan_id)
if not plan:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Plan not found")
for k in ("name", "price", "product_limit", "user_limit", "storage_gb",
"grace_days", "archive_years", "features", "enabled"):
if k in body:
setattr(plan, k, body[k])
await plan.save()
await AuditLog(user_id=str(p.user.id), user_name=p.user.name,
action="plan.update", module="admin",
details=f"Updated plan {plan.name}").insert()
return {"message": "updated"}
@router.get("/dashboard")
async def admin_dashboard(p: Principal = Depends(require_admin_roles())):
"""Platform-wide overview KPIs for the Super Admin landing page."""
orgs = await Organization.find_all().to_list()
plans = {str(pl.id): pl for pl in await Plan.find_all().to_list()}
invoices = await Invoice.find_all().to_list()
total_qr = await QRCode.find_all().count()
total_scans = await ScanLog.find_all().count()
def lc(s): return sum(1 for o in orgs if o.subscription_status == s)
# MRR = sum of active orgs' plan prices
mrr = 0
paying = 0
for o in orgs:
if o.subscription_status == SubscriptionStatus.active and o.plan_id in plans:
price = plans[o.plan_id].price
if price > 0:
mrr += price
paying += 1
paid = [i for i in invoices if i.status == "paid"]
total_revenue = sum(i.total for i in paid)
# revenue trend — last 6 months by invoice month
from collections import defaultdict
buckets = defaultdict(int)
for i in paid:
key = i.created_at.strftime("%Y-%m")
buckets[key] += i.total
months = sorted(buckets.keys())[-6:]
trend = [{"month": m, "revenue": buckets[m]} for m in months]
return {
"total_orgs": len(orgs),
"active": lc(SubscriptionStatus.active),
"grace": lc(SubscriptionStatus.grace),
"archived": lc(SubscriptionStatus.archived),
"deleted": lc(SubscriptionStatus.deleted),
"mrr": mrr, "arr": mrr * 12, "paying_orgs": paying,
"arpo": round(mrr / paying) if paying else 0,
"total_revenue": total_revenue,
"total_qr": total_qr, "total_scans": total_scans,
"revenue_trend": trend,
}
@router.get("/revenue")
async def revenue(p: Principal = Depends(require_admin_roles(
ProductAdminRole.super_admin, ProductAdminRole.finance))):
orgs = await Organization.find_all().to_list()
plans = {str(pl.id): pl for pl in await Plan.find_all().to_list()}
invoices = await Invoice.find_all().to_list()
invoices.sort(key=lambda i: i.created_at, reverse=True)
paid = [i for i in invoices if i.status == "paid"]
mrr = sum(plans[o.plan_id].price for o in orgs
if o.subscription_status == SubscriptionStatus.active
and o.plan_id in plans and plans[o.plan_id].price > 0)
# plan-wise breakdown of active paying orgs
from collections import defaultdict
plan_rev = defaultdict(int)
for o in orgs:
if o.subscription_status == SubscriptionStatus.active and o.plan_id in plans:
pl = plans[o.plan_id]
if pl.price > 0:
plan_rev[pl.name] += pl.price
org_names = {str(o.id): o.name for o in orgs}
return {
"mrr": mrr, "arr": mrr * 12,
"total_revenue": sum(i.total for i in paid),
"invoices_count": len(invoices), "paid_count": len(paid),
"plan_breakdown": [{"plan": k, "revenue": v} for k, v in plan_rev.items()],
"invoices": [{"id": str(i.id), "invoice_number": i.invoice_number,
"org": org_names.get(i.org_id, ""), "plan": i.plan_name,
"subtotal": i.subtotal, "gst": i.gst_amount, "total": i.total,
"status": i.status, "at": i.created_at} for i in invoices[:100]],
}
@router.get("/revenue/export")
async def revenue_export(p: Principal = Depends(require_admin_roles(
ProductAdminRole.super_admin, ProductAdminRole.finance))):
orgs = {str(o.id): o.name for o in await Organization.find_all().to_list()}
invoices = await Invoice.find_all().to_list()
invoices.sort(key=lambda i: i.created_at, reverse=True)
buf = io.StringIO()
w = csv.writer(buf)
w.writerow(["Invoice", "Organization", "Plan", "Subtotal", "GST", "Total", "Status", "Date"])
for i in invoices:
w.writerow([i.invoice_number, orgs.get(i.org_id, ""), i.plan_name,
i.subtotal, i.gst_amount, i.total, i.status, i.created_at.isoformat()])
return Response(content=buf.getvalue(), media_type="text/csv",
headers={"Content-Disposition": 'attachment; filename="revenue.csv"'})
@router.get("/audit-logs")
async def audit_logs(p: Principal = Depends(require_admin_roles(
ProductAdminRole.super_admin, ProductAdminRole.admin)), limit: int = 100):
logs = await AuditLog.find_all().to_list()
logs.sort(key=lambda x: x.created_at, reverse=True)
return [{"at": l.created_at, "user": l.user_name, "role": l.user_role,
"action": l.action, "module": l.module, "details": l.details,
"ip": l.ip} for l in logs[:limit]]
# ---------- org detail extras: history, payments, support notes ----------
@router.get("/organizations/{org_id}/history")
async def org_history(org_id: str, p: Principal = Depends(require_admin_roles())):
subs = await Subscription.find(Subscription.org_id == org_id).to_list()
invs = await Invoice.find(Invoice.org_id == org_id).to_list()
invs.sort(key=lambda i: i.created_at, reverse=True)
plan_hist = await PlanHistory.find(PlanHistory.org_id == org_id).to_list()
plan_hist.sort(key=lambda h: h.created_at, reverse=True)
return {
"subscriptions": [{"plan": s.plan_name, "status": s.status,
"start": s.start_date, "renewal": s.renewal_date,
"payment_status": s.payment_status} for s in subs],
"invoices": [{"invoice_number": i.invoice_number, "total": i.total,
"status": i.status, "at": i.created_at} for i in invs],
"plan_history": [{"from": h.from_plan, "to": h.to_plan,
"reason": h.reason, "at": h.created_at} for h in plan_hist],
}
@router.get("/organizations/{org_id}/notes")
async def list_notes(org_id: str, p: Principal = Depends(require_admin_roles())):
notes = await SupportNote.find(SupportNote.org_id == org_id).to_list()
notes.sort(key=lambda n: n.created_at, reverse=True)
return [{"id": str(n.id), "note": n.note, "flag": n.flag,
"author": n.author_name, "at": n.created_at} for n in notes]
@router.post("/organizations/{org_id}/notes", status_code=201)
async def add_note(org_id: str, body: dict = Body(...),
p: Principal = Depends(require_admin_roles())):
note = SupportNote(org_id=org_id, author_id=str(p.user.id),
author_name=p.user.name, note=body.get("note", ""),
flag=body.get("flag"))
await note.insert()
return {"id": str(note.id)}
# ---------- subscription override / extend / reminder ----------
@router.put("/organizations/{org_id}/subscription")
async def override_subscription(org_id: str, body: dict = Body(...),
p: Principal = Depends(require_admin_roles(
ProductAdminRole.super_admin, ProductAdminRole.admin))):
org = await Organization.get(org_id)
sub = await Subscription.find_one(Subscription.org_id == org_id)
if not org or not sub:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Org or subscription not found")
if "status" in body and body["status"] in [s.value for s in SubscriptionStatus]:
org.subscription_status = SubscriptionStatus(body["status"])
sub.status = org.subscription_status
if body.get("extend_days"):
base = sub.renewal_date or now()
if base.tzinfo is None:
base = base.replace(tzinfo=timezone.utc)
sub.renewal_date = base + timedelta(days=int(body["extend_days"]))
if body.get("plan_id"):
plan = await Plan.get(body["plan_id"])
if plan:
org.plan_id = str(plan.id)
sub.plan_id = str(plan.id)
sub.plan_name = plan.name
await org.save()
await sub.save()
await AuditLog(org_id=org_id, user_id=str(p.user.id), user_name=p.user.name,
action="subscription.override", module="admin",
details=f"Admin override on {org.name}").insert()
return {"message": "updated", "status": org.subscription_status}
@router.post("/organizations/{org_id}/send-reminder")
async def send_reminder(org_id: str, p: Principal = Depends(require_admin_roles())):
from app.services.email import send_email
org = await Organization.get(org_id)
if not org:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Org not found")
to = org.contact_email or "admin@example.com"
await send_email(to, "VerifyPack subscription renewal reminder",
f"Hi {org.name}, your subscription is due for renewal.", "renewal_reminder")
await AuditLog(org_id=org_id, user_id=str(p.user.id), user_name=p.user.name,
action="reminder.send", module="admin",
details=f"Renewal reminder to {to}").insert()
return {"sent": True, "to": to}
# ---------- subscription lifecycle + utilization report ----------
@router.get("/organizations/{org_id}/support-view")
async def support_view(org_id: str, p: Principal = Depends(require_admin_roles())):
"""Read-only debugging view for Support staff."""
org = await Organization.get(org_id)
if not org:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Org not found")
products = await Product.find(Product.org_id == org_id).to_list()
qrs = await QRCode.find(QRCode.org_id == org_id).to_list()
scans = await ScanLog.find(ScanLog.org_id == org_id).to_list()
scans.sort(key=lambda s: s.scanned_at, reverse=True)
return {
"org": {"name": org.name, "slug": org.slug, "status": org.subscription_status,
"gstin": org.gstin, "contact_email": org.contact_email},
"products": [{"name": x.name, "sku": x.sku, "status": x.status,
"qr_count": x.qr_count} for x in products],
"qr_codes": [{"code": q.code, "status": q.status, "scans": q.scan_count}
for q in qrs[:50]],
"recent_scans": [{"at": s.scanned_at, "code": s.qr_code, "city": s.city,
"device": s.device} for s in scans[:25]],
}
@router.get("/subscription-lifecycle")
async def lifecycle(p: Principal = Depends(require_admin_roles())):
orgs = await Organization.find_all().to_list()
def count(s): return sum(1 for o in orgs if o.subscription_status == s)
upcoming = []
for o in orgs:
sub = await Subscription.find_one(Subscription.org_id == str(o.id))
if sub and sub.renewal_date:
rd = sub.renewal_date
if rd.tzinfo is None:
rd = rd.replace(tzinfo=timezone.utc)
days = (rd - now()).days
if 0 <= days <= 30:
upcoming.append({"org": o.name, "org_id": str(o.id),
"renewal": sub.renewal_date, "days": days})
upcoming.sort(key=lambda x: x["days"])
return {
"active": count(SubscriptionStatus.active),
"grace": count(SubscriptionStatus.grace),
"archived": count(SubscriptionStatus.archived),
"deleted": count(SubscriptionStatus.deleted),
"upcoming_expiries": upcoming,
}
@router.get("/utilization-report")
async def utilization_report(format: str = "csv",
p: Principal = Depends(require_admin_roles())):
orgs = await Organization.find_all().to_list()
rows = [await _utilization(o) for o in orgs]
if format == "pdf":
# Simple PDF table via reportlab
import io as _io
from reportlab.lib.pagesizes import A4
from reportlab.pdfgen import canvas
from reportlab.lib.units import mm
buf = _io.BytesIO()
c = canvas.Canvas(buf, pagesize=A4)
w, h = A4
y = h - 25 * mm
c.setFont("Helvetica-Bold", 14)
c.drawString(20 * mm, y, "VerifyPack — Utilization Report")
c.setFont("Helvetica", 8)
y -= 12 * mm
c.drawString(20 * mm, y, "Org / Plan / Products / Users / QRs / Scans / Status")
y -= 6 * mm
for r in rows:
line = (f"{r['name'][:24]:24} {str(r['plan'] or '-')[:10]:10} "
f"{r['products_used']}/{r['product_limit'] or ''} "
f"{r['users_used']}/{r['user_limit'] or ''} "
f"{r['qr_codes']} {r['total_scans']} {r['subscription_status']}")
c.drawString(20 * mm, y, line)
y -= 5 * mm
if y < 20 * mm:
c.showPage(); y = h - 25 * mm
c.showPage(); c.save()
return Response(content=buf.getvalue(), media_type="application/pdf",
headers={"Content-Disposition": 'attachment; filename="utilization.pdf"'})
# CSV
buf = io.StringIO()
wr = csv.writer(buf)
wr.writerow(["Organization", "Plan", "Price", "Products", "Product Limit",
"Users", "User Limit", "Storage GB", "QR Codes", "Total Scans", "Status"])
for r in rows:
wr.writerow([r["name"], r["plan"], r["plan_price"], r["products_used"],
r["product_limit"], r["users_used"], r["user_limit"],
r["storage_used_gb"], r["qr_codes"], r["total_scans"],
r["subscription_status"]])
return Response(content=buf.getvalue(), media_type="text/csv",
headers={"Content-Disposition": 'attachment; filename="utilization.csv"'})

View File

@@ -0,0 +1,316 @@
"""Analytics & Insights — scan aggregates with date-range filtering + CSV export."""
import csv
import io
from collections import Counter, defaultdict
from datetime import datetime, timedelta, timezone
from typing import Optional
from fastapi import APIRouter, Depends, Response
from app.core.deps import require_org, Principal
from app.models import ScanLog, Product
router = APIRouter(prefix="/analytics", tags=["analytics"])
DOW = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
def _aware(dt: datetime) -> datetime:
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
def _range(days: int):
end = now_utc()
start = end - timedelta(days=days)
return start, end
def now_utc() -> datetime:
return datetime.now(timezone.utc)
from datetime import datetime, timedelta, timezone
from typing import Optional
from typing import Optional
async def _scans(
org_id: str,
days: Optional[int] = None,
from_date: Optional[datetime] = None,
to_date: Optional[datetime] = None,
brand_id: Optional[str] = None,
):
items = await ScanLog.find(
ScanLog.org_id == org_id
).to_list()
if from_date:
from_date = _aware(from_date)
if to_date:
to_date = _aware(to_date) + timedelta(days=1)
if from_date and to_date:
items = [
s for s in items
if from_date <= _aware(s.scanned_at) < to_date
]
elif days:
start = now_utc() - timedelta(days=days)
items = [
s for s in items
if _aware(s.scanned_at) >= start
]
if brand_id:
products = await Product.find(
Product.org_id == org_id,
Product.brand_id == brand_id,
).to_list()
product_ids = {
str(p.id)
for p in products
}
items = [
s
for s in items
if s.product_id in product_ids
]
return items
@router.get("/summary")
async def summary(p: Principal = Depends(require_org), days: Optional[int] = None,
from_date: Optional[datetime] = None,
to_date: Optional[datetime] = None,brand_id: Optional[str] = None,):
oid = str(p.org.id)
scans = await _scans(oid, days,
from_date,
to_date,brand_id,
)
all_scans = await ScanLog.find(ScanLog.org_id == oid).to_list()
products = await Product.find(Product.org_id == oid).to_list()
today = now_utc().date()
unique = len({(s.ip, s.product_id) for s in scans})
avg_trust = round(sum(x.trust_score for x in products) / len(products)) if products else 0
return {
"total_scans": len(scans),
"all_time_scans": len(all_scans),
"unique_consumers": unique,
"today_scans": sum(1 for s in scans if _aware(s.scanned_at).date() == today),
"active_products": sum(1 for x in products if x.status == "active"),
"avg_trust_score": avg_trust,
"countries": len({s.country for s in scans if s.country}),
}
@router.get("/trend")
async def trend(
p: Principal = Depends(require_org),
days: Optional[int] = None,
from_date: Optional[datetime] = None,
to_date: Optional[datetime] = None,
brand_id: Optional[str] = None,
):
scans = await _scans(
str(p.org.id),
days,
from_date,
to_date,brand_id,
)
buckets = {}
for s in scans:
d = _aware(s.scanned_at).date()
buckets[d] = buckets.get(d, 0) + 1
return [
{"date": str(k), "scans": v}
for k, v in sorted(buckets.items())
]
@router.get("/top-products")
async def top_products(
p: Principal = Depends(require_org),
limit: int = 8,
days: Optional[int] = None,
from_date: Optional[datetime] = None,
to_date: Optional[datetime] = None,brand_id: Optional[str] = None,
):
scans = await _scans(
str(p.org.id),
days,
from_date,
to_date,brand_id,
)
counter = Counter(s.product_id for s in scans if s.product_id)
products = await Product.find(
Product.org_id == str(p.org.id)
).to_list()
lookup = {
str(prod.id): prod.name
for prod in products
}
return [
{
"name": lookup.get(pid, "Unknown Product"),
"scans": count,
}
for pid, count in counter.most_common(limit)
]
@router.get("/top-cities")
async def top_cities(p: Principal = Depends(require_org), limit: int = 10, days: Optional[int] = None,
from_date: Optional[datetime] = None,
to_date: Optional[datetime] = None,brand_id: Optional[str] = None,):
scans = await _scans(str(p.org.id), days,
from_date,
to_date,brand_id,
)
c = Counter(s.city for s in scans if s.city)
return [{"city": city, "scans": n} for city, n in c.most_common(limit)]
@router.get("/devices")
async def devices(
p: Principal = Depends(require_org),
days: Optional[int] = None,
from_date: Optional[datetime] = None,
to_date: Optional[datetime] = None,brand_id: Optional[str] = None,
):
scans = await _scans(
str(p.org.id),
days,
from_date,
to_date,brand_id,
)
c = Counter(s.device or "Other" for s in scans)
return [
{"device": d, "scans": n}
for d, n in c.most_common()
]
@router.get("/geography")
async def geography(p: Principal = Depends(require_org), days: Optional[int] = None,
from_date: Optional[datetime] = None,
to_date: Optional[datetime] = None,brand_id: Optional[str] = None,):
scans = await _scans(str(p.org.id), days,
from_date,
to_date,brand_id,
)
c = Counter(s.country for s in scans if s.country)
return [{"country": k, "scans": n} for k, n in c.most_common()]
@router.get("/time-distribution")
async def time_distribution(p: Principal = Depends(require_org), days: Optional[int] = None,
from_date: Optional[datetime] = None,
to_date: Optional[datetime] = None,brand_id: Optional[str] = None,):
"""Heatmap: day-of-week (0=Mon) x hour (0-23) scan counts."""
scans = await _scans(str(p.org.id), days,
from_date,
to_date,brand_id,
)
grid = defaultdict(int)
for s in scans:
dt = _aware(s.scanned_at)
grid[(dt.weekday(), dt.hour)] += 1
return [{"dow": d, "day": DOW[d], "hour": h, "count": grid.get((d, h), 0)}
for d in range(7) for h in range(24)]
@router.get("/recent-scans")
async def recent_scans(
p: Principal = Depends(require_org),
page: int = 1,
page_size: int = 20,
days: Optional[int] = None,
from_date: Optional[datetime] = None,
to_date: Optional[datetime] = None,
brand_id: Optional[str] = None,
):
scans = await _scans(
str(p.org.id),
days,
from_date,
to_date,
brand_id,
)
scans.sort(key=lambda s: s.scanned_at, reverse=True)
total = len(scans)
start = (page - 1) * page_size
end = start + page_size
rows = scans[start:end]
return {
"items": [
{
"at": s.scanned_at,
"product_id": s.product_id,
"city": s.city,
"country": s.country,
"device": s.device,
"code": s.qr_code,
"ip": s.ip,
}
for s in rows
],
"page": page,
"page_size": page_size,
"total": total,
"pages": (total + page_size - 1) // page_size,
}
@router.get("/export")
async def export_csv(p: Principal = Depends(require_org), days: Optional[int] = None,
from_date: Optional[datetime] = None,
to_date: Optional[datetime] = None,brand_id: Optional[str] = None,):
scans = await _scans(str(p.org.id), days,
from_date,
to_date,brand_id,
)
scans.sort(key=lambda s: s.scanned_at, reverse=True)
buf = io.StringIO()
w = csv.writer(buf)
w.writerow(["Timestamp", "QR Code", "Product ID", "City", "Country", "Device", "IP"])
for s in scans:
w.writerow([_aware(s.scanned_at).isoformat(), s.qr_code, s.product_id,
s.city or "", s.country or "", s.device or "", s.ip or ""])
return Response(content=buf.getvalue(), media_type="text/csv",
headers={"Content-Disposition": 'attachment; filename="scans.csv"'})
from app.models import Brand
@router.get("/brands")
async def analytics_brands(
p: Principal = Depends(require_org),
):
brands = await Brand.find(
Brand.org_id == str(p.org.id)
).to_list()
return [
{
"id": str(b.id),
"name": b.name
}
for b in brands
]

233
backend/app/routers/auth.py Normal file
View File

@@ -0,0 +1,233 @@
import secrets
from datetime import timedelta, timezone
from typing import Optional
from fastapi import APIRouter, Body, HTTPException, status, Request
from fastapi.responses import RedirectResponse
from app.core.config import settings
from app.core.security import (
hash_password, verify_password, create_access_token, create_token, decode_token,
)
from app.models import User, AuditLog
from app.models.documents import now
from app.schemas.auth import (
SignupIn, LoginIn, TokenOut, ForgotIn, ResetIn, MessageOut,
)
from app.services.email import send_verification_email, send_reset_email,send_email
router = APIRouter(prefix="/auth", tags=["auth"])
def _new_code() -> str:
return f"{secrets.randbelow(1000000):06d}"
async def _issue_verification(user: User):
"""Generate a 6-digit code + signed link token and email both."""
code = _new_code()
user.verify_code = code
user.verify_code_expires = now() + timedelta(minutes=30)
await user.save()
token = create_token(str(user.id), "verify", 60 * 24)
await send_verification_email(user.email, user.name, token, code)
@router.post("/signup", response_model=TokenOut, status_code=201)
async def signup(body: SignupIn):
if await User.find_one(User.email == body.email):
raise HTTPException(status.HTTP_409_CONFLICT, "Email already registered")
user = User(name=body.name, email=body.email,
hashed_password=hash_password(body.password))
await user.insert()
await _issue_verification(user)
access = create_access_token(str(user.id), {"v": user.email_verified})
return TokenOut(access_token=access, email_verified=user.email_verified,
name=user.name, email=user.email)
@router.post("/verify-code", response_model=MessageOut)
async def verify_code(body: dict = Body(...)):
"""Verify email using the 6-digit code sent on signup."""
email = (body.get("email") or "").strip().lower()
code = (body.get("code") or "").strip()
user = await User.find_one(User.email == email)
if not user or not user.verify_code:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "No pending verification for this email")
exp = user.verify_code_expires
if exp and exp.tzinfo is None:
exp = exp.replace(tzinfo=timezone.utc)
if exp and exp < now():
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Code expired — request a new one")
if code != user.verify_code:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Incorrect code")
user.email_verified = True
user.verify_code = None
user.verify_code_expires = None
await user.save()
return MessageOut(message="Email verified")
@router.post("/resend-code", response_model=MessageOut)
async def resend_code(body: dict = Body(...)):
email = (body.get("email") or "").strip().lower()
user = await User.find_one(User.email == email)
if user and not user.email_verified:
await _issue_verification(user)
return MessageOut(message="If the email is pending verification, a new code was sent")
@router.post("/login")
async def login(body: LoginIn):
user = await User.find_one(User.email == body.email)
if not user or not user.hashed_password:
raise HTTPException(401, "Invalid credentials")
if not verify_password(body.password, user.hashed_password):
raise HTTPException(401, "Invalid credentials")
if user.disabled:
raise HTTPException(403, "Account disabled")
otp = _new_code()
user.login_otp = otp
user.login_otp_expires = now() + timedelta(minutes=10)
await user.save()
await send_email(
user.email,
"Verify Login",
f"Your login OTP is: {otp}",
"login_otp",
)
return {
"message": "OTP sent",
"email": user.email,
}
@router.post("/verify-login-otp", response_model=TokenOut)
async def verify_login_otp(body: dict = Body(...)):
email = body.get("email")
otp = body.get("otp")
user = await User.find_one(User.email == email)
if not user:
raise HTTPException(404, "User not found")
if user.login_otp != otp:
raise HTTPException(400, "Invalid OTP")
exp = user.login_otp_expires
if exp and exp.tzinfo is None:
exp = exp.replace(tzinfo=timezone.utc)
if exp and exp < now():
raise HTTPException(status.HTTP_400_BAD_REQUEST, "OTP expired")
user.login_otp = None
user.login_otp_expires = None
user.last_login = now()
await user.save()
token = create_access_token(
str(user.id),
{"v": user.email_verified},
)
return TokenOut(
access_token=token,
email_verified=user.email_verified,
name=user.name,
email=user.email,
)
@router.get("/verify-email", response_model=MessageOut)
async def verify_email(token: str):
payload = decode_token(token)
if not payload or payload.get("type") != "verify":
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid or expired token")
user = await User.get(payload["sub"])
if not user:
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
user.email_verified = True
await user.save()
return MessageOut(message="Email verified")
@router.post("/forgot-password", response_model=MessageOut)
async def forgot_password(body: ForgotIn):
user = await User.find_one(User.email == body.email)
if user:
token = create_token(str(user.id), "reset", 60)
await send_reset_email(user.email, user.name, token)
# Always return success to avoid account enumeration
return MessageOut(message="If the email exists, a reset link was sent")
@router.post("/reset-password", response_model=MessageOut)
async def reset_password(body: ResetIn):
payload = decode_token(body.token)
if not payload or payload.get("type") != "reset":
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid or expired token")
user = await User.get(payload["sub"])
if not user:
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
user.hashed_password = hash_password(body.password)
await user.save()
return MessageOut(message="Password reset")
# ---------- Google OAuth (env-driven; stubbed if not configured) ----------
@router.get("/google")
async def google_oauth_start():
if not settings.google_configured:
raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED,
"Google OAuth not configured (set GOOGLE_CLIENT_ID/SECRET)")
url = (
"https://accounts.google.com/o/oauth2/v2/auth"
f"?client_id={settings.google_client_id}"
f"&redirect_uri={settings.google_redirect_uri}"
"&response_type=code&scope=openid%20email%20profile"
)
return RedirectResponse(url)
@router.get("/google/callback")
async def google_oauth_callback(code: Optional[str] = None):
if not settings.google_configured:
raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED, "Google OAuth not configured")
import httpx
async with httpx.AsyncClient() as c:
tok = await c.post("https://oauth2.googleapis.com/token", data={
"code": code, "client_id": settings.google_client_id,
"client_secret": settings.google_client_secret,
"redirect_uri": settings.google_redirect_uri,
"grant_type": "authorization_code",
})
if tok.status_code != 200:
print("Google Token Error:")
print(tok.status_code)
print(tok.text)
raise HTTPException(400, tok.text)
access = tok.json()["access_token"]
info = await c.get("https://www.googleapis.com/oauth2/v2/userinfo",
headers={"Authorization": f"Bearer {access}"})
profile = info.json()
user = await User.find_one(User.email == profile["email"])
if not user:
user = User(name=profile.get("name", profile["email"]),
email=profile["email"], google_id=profile["id"],
avatar_url=profile.get("picture"), email_verified=True)
await user.insert()
user.last_login = now()
await user.save()
jwt = create_access_token(str(user.id), {"v": True})
return RedirectResponse(f"{settings.frontend_url}/auth/callback?token={jwt}")

View File

@@ -0,0 +1,61 @@
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status
from app.core.deps import require_org, require_client_roles, require_write_access, Principal
from app.models import Batch, Product
from app.models.enums import ClientRole, QRType
from app.schemas.common import BatchCreate
router = APIRouter(prefix="/batches", tags=["batches"])
def _parse(d):
if not d:
return None
try:
return datetime.fromisoformat(d)
except ValueError:
return None
def _out(b: Batch) -> dict:
return {"id": str(b.id), "product_id": b.product_id, "batch_number": b.batch_number,
"mfg_date": b.mfg_date, "expiry_date": b.expiry_date,
"quantity": b.quantity, "qr_type": b.qr_type, "notes": b.notes,
"created_at": b.created_at}
@router.get("")
async def list_batches(p: Principal = Depends(require_org), product_id: Optional[str] = None):
q = Batch.find(Batch.org_id == str(p.org.id))
batches = await q.to_list()
if product_id:
batches = [b for b in batches if b.product_id == product_id]
return [_out(b) for b in batches]
@router.post("", status_code=201)
async def create_batch(body: BatchCreate,
p: Principal = Depends(require_client_roles(ClientRole.admin, ClientRole.user))):
require_write_access(p)
product = await Product.get(body.product_id)
if not product or product.org_id != str(p.org.id):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Product not found")
bn = body.batch_number or f"{(product.sku or 'B')}{datetime.utcnow():%y%m%d%H%M}"
batch = Batch(org_id=str(p.org.id), product_id=body.product_id, batch_number=bn,
mfg_date=_parse(body.mfg_date), expiry_date=_parse(body.expiry_date),
quantity=max(1, body.quantity), qr_type=body.qr_type, notes=body.notes)
await batch.insert()
product.batch_count += 1
await product.save()
return _out(batch)
@router.get("/{batch_id}")
async def get_batch(batch_id: str, p: Principal = Depends(require_org)):
batch = await Batch.get(batch_id)
if not batch or batch.org_id != str(p.org.id):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Batch not found")
return _out(batch)

View File

@@ -0,0 +1,250 @@
"""Billing & Subscription — Razorpay orders, GST invoices, plan changes.
GST is 18% on the plan price (INR only). Invoices store subtotal + gst + total.
Plan price changes never affect an active subscription until renewal.
"""
from datetime import datetime, timedelta, timezone
from typing import Optional
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response, status
from app.core.config import settings
from app.core.deps import require_org, require_client_roles, Principal
from app.models import (
Organization, Plan, Subscription, Invoice, Payment, PlanHistory,
StorageUsage, AuditLog,
)
from app.models.documents import now
from app.models.enums import ClientRole, PaymentStatus, SubscriptionStatus
from app.services import razorpay_svc
from app.services.invoice_pdf import build_invoice_pdf
router = APIRouter(prefix="/billing", tags=["billing"])
# Simple demo coupons: code -> percent off
COUPONS = {"WELCOME10": 10, "VERIFY20": 20, "LAUNCH50": 50}
def _gst(amount: int) -> int:
return round(amount * settings.gst_rate)
async def _next_invoice_number() -> str:
count = await Invoice.find_all().count()
yr = datetime.now().year
return f"VP/{yr}/{count + 1:05d}"
@router.get("/plans")
async def list_plans_for_clients(p: Principal = Depends(require_org)):
"""Plans available for upgrade/downgrade (enabled only), visible to any org member."""
plans = await Plan.find(Plan.enabled == True).to_list() # noqa: E712
plans.sort(key=lambda x: x.price)
return [{"id": str(pl.id), "name": pl.name, "price": pl.price,
"product_limit": pl.product_limit, "user_limit": pl.user_limit,
"storage_gb": pl.storage_gb, "features": pl.features} for pl in plans]
@router.get("/subscription")
async def get_subscription(p: Principal = Depends(require_org)):
sub = await Subscription.find_one(Subscription.org_id == str(p.org.id))
plan = await Plan.get(p.org.plan_id) if p.org.plan_id else None
storage = await StorageUsage.find_one(StorageUsage.org_id == str(p.org.id))
days_remaining = None
if sub and sub.renewal_date:
rd = sub.renewal_date
if rd.tzinfo is None:
rd = rd.replace(tzinfo=timezone.utc)
days_remaining = max(0, (rd - now()).days)
return {
"status": p.org.subscription_status,
"plan": {"id": str(plan.id), "name": plan.name, "price": plan.price,
"product_limit": plan.product_limit, "user_limit": plan.user_limit,
"storage_gb": plan.storage_gb} if plan else None,
"auto_renew": sub.auto_renew if sub else True,
"start_date": sub.start_date if sub else None,
"renewal_date": sub.renewal_date if sub else None,
"days_remaining": days_remaining,
"payment_status": sub.payment_status if sub else None,
"usage": {
"products": p.org.product_count,
"product_limit": plan.product_limit if plan else None,
"users": p.org.user_count,
"user_limit": plan.user_limit if plan else None,
"storage_used_gb": round((storage.used_bytes if storage else 0) / 1024 ** 3, 1),
"storage_total_gb": round((storage.total_bytes if storage else 0) / 1024 ** 3, 1),
},
}
@router.put("/subscription/auto-renewal")
async def set_auto_renewal(body: dict = Body(...),
p: Principal = Depends(require_client_roles(ClientRole.admin, ClientRole.finance))):
sub = await Subscription.find_one(Subscription.org_id == str(p.org.id))
if not sub:
raise HTTPException(status.HTTP_404_NOT_FOUND, "No subscription")
sub.auto_renew = bool(body.get("auto_renew", True))
await sub.save()
return {"auto_renew": sub.auto_renew}
@router.post("/coupon/apply")
async def apply_coupon(body: dict = Body(...), p: Principal = Depends(require_org)):
code = (body.get("code") or "").strip().upper()
pct = COUPONS.get(code)
if not pct:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid coupon code")
return {"code": code, "percent_off": pct}
@router.post("/create-order")
async def create_order(body: dict = Body(...),
p: Principal = Depends(require_client_roles(ClientRole.admin, ClientRole.finance))):
"""Create a Razorpay order for a plan upgrade/downgrade.
body = {"plan_id": "...", "coupon": "WELCOME10"?}
Returns order details + GST breakdown for the checkout modal.
"""
plan = await Plan.get(body.get("plan_id"))
if not plan:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Plan not found")
base = plan.price
discount = 0
coupon = (body.get("coupon") or "").strip().upper()
if coupon and coupon in COUPONS:
discount = round(base * COUPONS[coupon] / 100)
subtotal = max(0, base - discount)
gst = _gst(subtotal)
total = subtotal + gst
if total == 0:
# Free plan or fully discounted — activate immediately, no Razorpay.
await _activate_plan(p.org, plan, subtotal, gst, total, method="free")
return {"free": True, "plan": plan.name}
receipt = f"rcpt_{str(p.org.id)[-6:]}_{int(now().timestamp())}"
order = razorpay_svc.create_order(total * 100, receipt,
{"org_id": str(p.org.id), "plan_id": str(plan.id)})
await Payment(org_id=str(p.org.id), razorpay_order_id=order["id"],
amount=total, status=PaymentStatus.pending).insert()
return {
"order_id": order["id"],
"amount": total,
"currency": "INR",
"key_id": settings.razorpay_key_id,
"mock": order.get("mock", False),
"breakdown": {"base": base, "discount": discount, "subtotal": subtotal,
"gst": gst, "total": total, "coupon": coupon or None},
"plan_id": str(plan.id),
"plan_name": plan.name,
}
@router.post("/verify")
async def verify_payment(body: dict = Body(...),
p: Principal = Depends(require_client_roles(ClientRole.admin, ClientRole.finance))):
"""Verify Razorpay payment signature and activate the plan."""
order_id = body.get("razorpay_order_id")
payment_id = body.get("razorpay_payment_id")
signature = body.get("razorpay_signature", "")
plan_id = body.get("plan_id")
if not razorpay_svc.verify_payment_signature(order_id, payment_id, signature):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid payment signature")
plan = await Plan.get(plan_id)
if not plan:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Plan not found")
pay = await Payment.find_one(Payment.razorpay_order_id == order_id)
subtotal = plan.price
if pay:
subtotal = pay.amount - _gst(pay.amount) # back out gst-inclusive stored total
gst = _gst(plan.price)
total = plan.price + gst
if pay:
pay.razorpay_payment_id = payment_id
pay.status = PaymentStatus.paid
await pay.save()
invoice = await _activate_plan(p.org, plan, plan.price, gst, plan.price + gst,
method="razorpay", txn=payment_id)
return {"success": True, "plan": plan.name, "invoice_number": invoice}
@router.post("/webhook")
async def razorpay_webhook(request: Request):
"""Razorpay webhook handler (payment.captured / payment.failed)."""
payload = await request.body()
sig = request.headers.get("x-razorpay-signature", "")
if not razorpay_svc.verify_webhook_signature(payload, sig):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid webhook signature")
# In production: parse event, reconcile Payment/Invoice by order id.
return {"ok": True}
async def _activate_plan(org: Organization, plan: Plan, subtotal: int, gst: int,
total: int, method: str, txn: Optional[str] = None) -> str:
"""Switch org to a plan, write subscription + invoice + plan_history."""
old_plan = await Plan.get(org.plan_id) if org.plan_id else None
org.plan_id = str(plan.id)
org.subscription_status = SubscriptionStatus.active
await org.save()
storage = await StorageUsage.find_one(StorageUsage.org_id == str(org.id))
if storage:
storage.total_bytes = plan.storage_gb * 1024 ** 3
await storage.save()
sub = await Subscription.find_one(Subscription.org_id == str(org.id))
renewal = now() + timedelta(days=30)
if sub:
sub.plan_id = str(plan.id)
sub.plan_name = plan.name
sub.status = SubscriptionStatus.active
sub.start_date = now()
sub.renewal_date = renewal
sub.payment_status = PaymentStatus.paid
await sub.save()
else:
await Subscription(org_id=str(org.id), plan_id=str(plan.id), plan_name=plan.name,
renewal_date=renewal).insert()
inv_no = await _next_invoice_number()
if total > 0:
await Invoice(org_id=str(org.id), invoice_number=inv_no, plan_name=plan.name,
period=f"{now():%b %Y} - {renewal:%b %Y}",
subtotal=subtotal, gst_amount=gst, total=total,
status=PaymentStatus.paid, payment_method=method,
transaction_id=txn, gstin=org.gstin,
place_of_supply="Karnataka").insert()
await PlanHistory(org_id=str(org.id),
from_plan=old_plan.name if old_plan else None,
to_plan=plan.name, reason=f"{method} payment").insert()
await AuditLog(org_id=str(org.id), action="subscription.change", module="billing",
details=f"Plan changed to {plan.name}").insert()
return inv_no
@router.get("/invoices")
async def list_invoices(p: Principal = Depends(require_org)):
invs = await Invoice.find(Invoice.org_id == str(p.org.id)).to_list()
invs.sort(key=lambda i: i.created_at, reverse=True)
return [{"id": str(i.id), "invoice_number": i.invoice_number, "plan_name": i.plan_name,
"period": i.period, "subtotal": i.subtotal, "gst_amount": i.gst_amount,
"total": i.total, "status": i.status, "payment_method": i.payment_method,
"transaction_id": i.transaction_id, "created_at": i.created_at} for i in invs]
@router.get("/invoices/{invoice_id}/pdf")
async def invoice_pdf(invoice_id: str, p: Principal = Depends(require_org)):
inv = await Invoice.get(invoice_id)
if not inv or inv.org_id != str(p.org.id):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Invoice not found")
pdf = build_invoice_pdf(inv.model_dump(), p.org.model_dump())
return Response(content=pdf, media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{inv.invoice_number.replace("/", "-")}.pdf"'})

View File

@@ -0,0 +1,119 @@
from fastapi import APIRouter, Depends, HTTPException, status
from app.core.deps import require_org, require_client_roles, require_write_access, Principal
from app.models import Brand, Product,AuditLog
from app.models.enums import ClientRole
from app.schemas.common import BrandCreate
router = APIRouter(prefix="/brands", tags=["brands"])
def _out(b: Brand) -> dict:
return {"id": str(b.id), "name": b.name, "logo_url": b.logo_url,
"description": b.description, "product_count": b.product_count,
"created_at": b.created_at}
from fastapi import APIRouter, UploadFile, File
import os
import uuid
import os
BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
UPLOAD_DIR = os.path.join(BASE_DIR, "_uploads", "brands")
os.makedirs(UPLOAD_DIR, exist_ok=True)
@router.post("/upload-logo")
async def upload_logo(file: UploadFile = File(...)):
ext = file.filename.split(".")[-1]
filename = f"{uuid.uuid4()}.{ext}"
filepath = os.path.join(UPLOAD_DIR, filename)
with open(filepath, "wb") as buffer:
buffer.write(await file.read())
return {
"url": f"http://localhost:8000/uploads/brands/{filename}"
}
@router.get("")
async def list_brands(p: Principal = Depends(require_org)):
brands = await Brand.find(Brand.org_id == str(p.org.id)).to_list()
return [_out(b) for b in brands]
@router.post("", status_code=201)
async def create_brand(body: BrandCreate,
p: Principal = Depends(require_client_roles(ClientRole.admin, ClientRole.user))):
require_write_access(p)
brand = Brand(org_id=str(p.org.id), name=body.name,
description=body.description, logo_url=body.logo_url)
await brand.insert()
await AuditLog(
org_id=str(p.org.id),
user_id=str(p.user.id),
user_name=p.user.name,
user_role=p.client_role,
action="brand.create",
module="brands",
details=f"Created brand {brand.name}",
).insert()
return _out(brand)
@router.get("/{brand_id}")
async def get_brand(brand_id: str, p: Principal = Depends(require_org)):
brand = await Brand.get(brand_id)
if not brand or brand.org_id != str(p.org.id):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Brand not found")
products = await Product.find(Product.brand_id == brand_id).to_list()
return {**_out(brand),
"products": [{"id": str(x.id), "name": x.name, "sku": x.sku,
"status": x.status,"image_url": x.image_url,
"qr_count": x.qr_count,
"batch_count": x.batch_count,} for x in products]}
@router.put("/{brand_id}")
async def update_brand(brand_id: str, body: dict,
p: Principal = Depends(require_client_roles(ClientRole.admin, ClientRole.user))):
brand = await Brand.get(brand_id)
if not brand or brand.org_id != str(p.org.id):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Brand not found")
for k in ("name", "logo_url", "description"):
if k in body:
setattr(brand, k, body[k])
await brand.save()
await AuditLog(
org_id=str(p.org.id),
user_id=str(p.user.id),
user_name=p.user.name,
user_role=p.client_role,
action="brand.update",
module="brands",
details=f"Updated brand {brand.name}",
).insert()
return _out(brand)
@router.delete("/{brand_id}", status_code=204)
async def delete_brand(brand_id: str,
p: Principal = Depends(require_client_roles(ClientRole.admin))):
brand = await Brand.get(brand_id)
if not brand or brand.org_id != str(p.org.id):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Brand not found")
await brand.delete()
await AuditLog(
org_id=str(p.org.id),
user_id=str(p.user.id),
user_name=p.user.name,
user_role=p.client_role,
action="brand.delete",
module="brands",
details=f"Deleted brand {brand.name}",
).insert()

View File

@@ -0,0 +1,365 @@
"""Compliance Center — licenses & certificates with expiry tracking."""
import base64
import binascii
from datetime import datetime, timedelta, timezone
from typing import Optional
from fastapi import APIRouter, Body, Depends, HTTPException, status
from app.core.deps import require_org, require_client_roles, Principal
from app.models import Compliance, Product,AuditLog
from app.models.documents import now
from app.models.enums import ClientRole, ComplianceStatus
from app.services.storage import upload_file
router = APIRouter(prefix="/compliance", tags=["compliance"])
def _parse(d):
if not d:
return None
try:
return datetime.fromisoformat(str(d).replace("Z", "+00:00"))
except ValueError:
return None
def _status_for(expiry: Optional[datetime], has_number: bool) -> ComplianceStatus:
today = now()
if expiry:
exp = expiry if expiry.tzinfo else expiry.replace(tzinfo=timezone.utc)
if exp < today:
return ComplianceStatus.expired
if exp <= today + timedelta(days=30):
return ComplianceStatus.expiring
if not has_number:
return ComplianceStatus.pending
return ComplianceStatus.complete
def _days_left(expiry: Optional[datetime]):
if not expiry:
return None
exp = expiry if expiry.tzinfo else expiry.replace(tzinfo=timezone.utc)
return (exp - now()).days
def _out(c: Compliance, prod=None) -> dict:
return {
"id": str(c.id), "product_id": c.product_id,
"product_name": prod.name if prod else None,
"sku": prod.sku if prod else None,
"product_image": prod.image_url if prod else None,
"compliance_type": c.compliance_type,
"license_number": c.license_number, "license_name": c.license_name,
"issuing_authority": c.issuing_authority,
"issue_date": c.issue_date, "expiry_date": c.expiry_date,
"days_left": _days_left(c.expiry_date),
"country": c.country, "state": c.state, "plant": c.plant,
"category": c.category, "applicable_to": c.applicable_to,
"remarks": c.remarks, "document_url": c.document_url,
"documents": c.documents, "created_by": c.created_by,
"status": c.status, "created_at": c.created_at, "updated_at": c.updated_at,
}
@router.get("")
async def list_compliance(p: Principal = Depends(require_org),
status_filter: Optional[str] = None,
compliance_type: Optional[str] = None,
country: Optional[str] = None,
category: Optional[str] = None,
search: Optional[str] = None):
items = await Compliance.find(Compliance.org_id == str(p.org.id)).to_list()
products = {str(x.id): x for x in await Product.find(Product.org_id == str(p.org.id)).to_list()}
for c in items:
c.status = _status_for(c.expiry_date, bool(c.license_number))
rows = []
for c in items:
if status_filter and c.status != status_filter:
continue
if compliance_type and c.compliance_type != compliance_type:
continue
if country and c.country != country:
continue
if category and c.category != category:
continue
prod = products.get(c.product_id or "")
if search:
s = search.lower()
hay = f"{c.compliance_type} {c.license_number or ''} {c.issuing_authority or ''} {prod.name if prod else ''}".lower()
if s not in hay:
continue
rows.append(_out(c, prod))
rows.sort(key=lambda r: r["updated_at"], reverse=True)
return rows
@router.get("/summary")
async def summary(p: Principal = Depends(require_org)):
items = await Compliance.find(Compliance.org_id == str(p.org.id)).to_list()
for c in items:
c.status = _status_for(c.expiry_date, bool(c.license_number))
def n(s): return sum(1 for c in items if c.status == s)
# Match the dashboard's Products KPI: use the org's live product_count
# (excludes archived), not a raw collection count.
total_products = p.org.product_count
total = len(items) or 1
return {
"total": len(items),
"total_products": total_products,
"complete": n(ComplianceStatus.complete),
"pending": n(ComplianceStatus.pending),
"expired": n(ComplianceStatus.expired),
"expiring": n(ComplianceStatus.expiring),
"complete_pct": round(n(ComplianceStatus.complete) / total * 100, 1),
"pending_pct": round(n(ComplianceStatus.pending) / total * 100, 1),
"expired_pct": round(n(ComplianceStatus.expired) / total * 100, 1),
"alerts": [
{"type": c.compliance_type, "number": c.license_number,
"status": c.status, "expiry": c.expiry_date}
for c in items if c.status in (ComplianceStatus.expired, ComplianceStatus.expiring)
],
}
@router.post("", status_code=201)
async def create_compliance(body: dict = Body(...),
p: Principal = Depends(require_client_roles(ClientRole.admin, ClientRole.user))):
expiry = _parse(body.get("expiry_date"))
c = Compliance(
org_id=str(p.org.id), product_id=body.get("product_id"),
compliance_type=body.get("compliance_type", "Certificate"),
license_number=body.get("license_number"),
license_name=body.get("license_name"),
issuing_authority=body.get("issuing_authority"),
issue_date=_parse(body.get("issue_date")), expiry_date=expiry,
country=body.get("country"), state=body.get("state"), plant=body.get("plant"),
category=body.get("category"), applicable_to=body.get("applicable_to"),
remarks=body.get("remarks"), created_by=p.user.name,
status=_status_for(expiry, bool(body.get("license_number"))),
)
await c.insert()
await AuditLog(
org_id=str(p.org.id),
user_id=str(p.user.id),
user_name=p.user.name,
user_role=p.client_role,
action="compliance.create",
module="compliance",
details=f"Created {c.compliance_type} for product {c.product_id}",
).insert()
return _out(c, await _safe_prod(c.product_id))
async def _safe_prod(pid):
if not pid:
return None
try:
return await Product.get(pid)
except Exception:
return None
async def _owned(cid: str, p: Principal) -> Compliance:
try:
c = await Compliance.get(cid)
except Exception:
c = None
if not c or c.org_id != str(p.org.id):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Compliance record not found")
return c
@router.get("/{cid}")
async def get_compliance(cid: str, p: Principal = Depends(require_org)):
c = await _owned(cid, p)
return _out(c, await _safe_prod(c.product_id))
@router.get("/{cid}/detail")
async def compliance_detail(cid: str, p: Principal = Depends(require_org)):
"""Full detail for the Compliance Details modal (overview + documents + history)."""
c = await _owned(cid, p)
return {
**_out(c, await _safe_prod(c.product_id)),
"history": [
{"action": "Created", "by": c.created_by, "at": c.created_at},
{"action": "Last updated", "by": c.created_by, "at": c.updated_at},
],
}
@router.put("/{cid}")
async def update_compliance(cid: str, body: dict = Body(...),
p: Principal = Depends(require_client_roles(ClientRole.admin, ClientRole.user))):
c = await _owned(cid, p)
for k in ("compliance_type", "license_number", "license_name", "issuing_authority",
"country", "state", "plant", "category", "applicable_to", "remarks"):
if k in body:
setattr(c, k, body[k])
if "issue_date" in body:
c.issue_date = _parse(body["issue_date"])
if "expiry_date" in body:
c.expiry_date = _parse(body["expiry_date"])
c.status = _status_for(c.expiry_date, bool(c.license_number))
c.updated_at = now()
await c.save()
await AuditLog(
org_id=str(p.org.id),
user_id=str(p.user.id),
user_name=p.user.name,
user_role=p.client_role,
action="compliance.update",
module="compliance",
details=f"Updated {c.compliance_type}",
).insert()
return _out(c, await _safe_prod(c.product_id))
@router.delete("/{cid}", status_code=204)
async def delete_compliance(cid: str,
p: Principal = Depends(require_client_roles(ClientRole.admin))):
c = await _owned(cid, p)
await c.delete()
await AuditLog(
org_id=str(p.org.id),
user_id=str(p.user.id),
user_name=p.user.name,
user_role=p.client_role,
action="compliance.delete",
module="compliance",
details=f"Deleted {c.compliance_type}",
).insert()
@router.post("/{cid}/document")
async def upload_document(cid: str, body: dict = Body(...),
p: Principal = Depends(require_client_roles(ClientRole.admin, ClientRole.user))):
"""body = {data_url, filename}"""
c = await _owned(cid, p)
data_url = body.get("data_url", "")
if "," not in data_url:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid data URL")
try:
content = base64.b64decode(data_url.split(",", 1)[1])
except (binascii.Error, ValueError):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Bad base64")
fname = body.get("filename", f"{cid}.pdf")
url = await upload_file(content, fname, "application/pdf")
c.document_url = url
c.documents = (c.documents or []) + [{"name": fname, "url": url,
"uploaded_at": now().isoformat()}]
c.updated_at = now()
await c.save()
await AuditLog(
org_id=str(p.org.id),
user_id=str(p.user.id),
user_name=p.user.name,
user_role=p.client_role,
action="compliance.upload_document",
module="compliance",
details=f"Uploaded document {fname}",
).insert()
return {"document_url": url, "documents": c.documents}
import csv
import io
from fastapi import UploadFile, File
@router.post("/import")
async def import_compliance(
file: UploadFile = File(...),
p: Principal = Depends(require_client_roles(ClientRole.admin, ClientRole.user)),
):
if not file.filename.lower().endswith(".csv"):
raise HTTPException(
status_code=400,
detail="Only CSV files are supported."
)
content = await file.read()
try:
text = content.decode("utf-8-sig")
except Exception:
raise HTTPException(
status_code=400,
detail="Invalid CSV file."
)
reader = csv.DictReader(io.StringIO(text))
inserted = 0
skipped = 0
products = await Product.find(
Product.org_id == str(p.org.id)
).to_list()
product_map = {
x.name.strip().lower(): str(x.id)
for x in products
}
for row in reader:
product_name = (row.get("Product") or "").strip()
product_id = product_map.get(product_name.lower())
if not product_id:
skipped += 1
continue
expiry = _parse(row.get("Expiry Date"))
issue = _parse(row.get("Issue Date"))
compliance = Compliance(
org_id=str(p.org.id),
product_id=product_id,
compliance_type=row.get("Compliance Type"),
license_number=row.get("License Number"),
license_name=row.get("License Name"),
issuing_authority=row.get("Issuing Authority"),
issue_date=issue,
expiry_date=expiry,
country=row.get("Country"),
state=row.get("State"),
category=row.get("Category"),
applicable_to=row.get("Applicable To"),
plant=row.get("Manufacturing Plant"),
remarks=row.get("Remarks"),
created_by=p.user.name,
status=_status_for(
expiry,
bool(row.get("License Number"))
),
)
await compliance.insert()
inserted += 1
await AuditLog(
org_id=str(p.org.id),
user_id=str(p.user.id),
user_name=p.user.name,
user_role=p.client_role,
action="compliance.import",
module="compliance",
details=f"Imported {inserted} compliance records",
).insert()
return {
"message": "Import completed.",
"inserted": inserted,
"skipped": skipped,
}

View File

@@ -0,0 +1,106 @@
"""Dashboard + analytics aggregates that feed the client dashboard UI."""
from collections import Counter
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends
from app.core.deps import require_org, Principal
from app.models import Product, Batch, QRCode, ScanLog, Plan, StorageUsage
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
@router.get("/summary")
async def summary(p: Principal = Depends(require_org)):
oid = str(p.org.id)
products = await Product.find(Product.org_id == oid).to_list()
batches = await Batch.find(Batch.org_id == oid).to_list()
qrs = await QRCode.find(QRCode.org_id == oid).to_list()
scans = await ScanLog.find(ScanLog.org_id == oid).to_list()
plan = await Plan.get(p.org.plan_id) if p.org.plan_id else None
storage = await StorageUsage.find_one(StorageUsage.org_id == oid)
today = datetime.now(timezone.utc).date()
scans_today = sum(1 for s in scans if s.scanned_at.date() == today)
active_qr = sum(1 for q in qrs if q.status == "active")
avg_trust = round(sum(x.trust_score for x in products) / len(products)) if products else 0
return {
"products": len(products),
"product_limit": plan.product_limit if plan else None,
"qr_codes": len(qrs),
"active_qr": active_qr,
"batches": len(batches),
"scans_today": scans_today,
"total_scans": len(scans),
"trust_score_avg": avg_trust,
"storage_used_gb": round((storage.used_bytes if storage else 0) / 1024 ** 3, 1),
"storage_total_gb": round((storage.total_bytes if storage else 0) / 1024 ** 3, 1),
"plan_name": plan.name if plan else "Free",
"user_count": p.org.user_count,
"user_limit": plan.user_limit if plan else None,
}
@router.get("/scan-trend")
async def scan_trend(p: Principal = Depends(require_org), days: int = 7):
oid = str(p.org.id)
scans = await ScanLog.find(ScanLog.org_id == oid).to_list()
start = datetime.now(timezone.utc).date() - timedelta(days=days - 1)
buckets = {start + timedelta(days=i): 0 for i in range(days)}
for s in scans:
d = s.scanned_at.date()
if d in buckets:
buckets[d] += 1
return [{"date": str(d), "scans": c} for d, c in sorted(buckets.items())]
@router.get("/top-products")
async def top_products(
p: Principal = Depends(require_org),
limit: int = 8,
):
scans = await ScanLog.find(
ScanLog.org_id == str(p.org.id)
).to_list()
counter = Counter(s.product_id for s in scans if s.product_id)
products = await Product.find(
Product.org_id == str(p.org.id)
).to_list()
lookup = {str(prod.id): prod.name for prod in products}
result = []
for product_id, count in counter.most_common(limit):
result.append({
"name": lookup.get(product_id, "Unknown Product"),
"scans": count,
})
return result
@router.get("/top-locations")
async def top_locations(p: Principal = Depends(require_org), limit: int = 5):
scans = await ScanLog.find(ScanLog.org_id == str(p.org.id)).to_list()
counter = Counter(s.city for s in scans if s.city)
return [{"city": city, "scans": n} for city, n in counter.most_common(limit)]
@router.get("/recent-activity")
async def recent_activity(p: Principal = Depends(require_org), limit: int = 8):
from app.models import AuditLog
logs = await AuditLog.find(AuditLog.org_id == str(p.org.id)).to_list()
logs.sort(key=lambda x: x.created_at, reverse=True)
return [{"action": l.action, "details": l.details, "user": l.user_name,
"module": l.module, "at": l.created_at} for l in logs[:limit]]
@router.get("/devices")
async def devices(p: Principal = Depends(require_org)):
scans = await ScanLog.find(ScanLog.org_id == str(p.org.id)).to_list()
counter = Counter(s.device or "Other" for s in scans)
return [{"device": d, "scans": n} for d, n in counter.most_common()]

159
backend/app/routers/orgs.py Normal file
View File

@@ -0,0 +1,159 @@
import re
from fastapi import APIRouter, Depends, HTTPException, status
from app.core.deps import current_principal, current_user, require_client_roles, Principal
from app.models import Organization, OrgMember, Plan, Subscription, StorageUsage, AuditLog
from app.models.enums import ClientRole
from app.schemas.common import OrgCreate
router = APIRouter(tags=["organization"])
@router.get("/me")
async def me(p: Principal = Depends(current_principal)):
"""Who am I — used by the frontend to route admins vs clients."""
return {
"id": str(p.user.id),
"name": p.user.name,
"email": p.user.email,
"email_verified": p.user.email_verified,
"is_product_admin": p.user.is_product_admin,
"product_admin_role": p.admin_role,
"client_role": p.client_role,
"has_org": p.org is not None,
"org_name": p.org.name if p.org else None,
}
def _slug(name: str) -> str:
base = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
return base or "org"
@router.post("/organization", status_code=201)
async def create_org(body: OrgCreate, user=Depends(current_user)):
existing = await OrgMember.find_one(OrgMember.user_id == str(user.id))
if existing:
raise HTTPException(status.HTTP_409_CONFLICT, "User already belongs to an organization")
free = await Plan.find_one(Plan.name == "Free")
slug = _slug(body.name)
if await Organization.find_one(Organization.slug == slug):
slug = f"{slug}-{str(user.id)[-4:]}"
org = Organization(name=body.name, slug=slug, gstin=body.gstin, pan=body.pan,
contact_email=body.contact_email or user.email,address=body.address,
phone=body.phone, website=body.website,
plan_id=str(free.id) if free else None)
await org.insert()
await OrgMember(org_id=str(org.id), user_id=str(user.id),
role=ClientRole.admin, status="active").insert()
await StorageUsage(org_id=str(org.id),
total_bytes=(free.storage_gb if free else 5) * 1024 ** 3).insert()
if free:
await Subscription(org_id=str(org.id), plan_id=str(free.id),
plan_name=free.name).insert()
await AuditLog(org_id=str(org.id), user_id=str(user.id), user_name=user.name,
action="org.create", module="organization",
details=f"Created org {org.name}").insert()
return {"id": str(org.id), "name": org.name, "slug": org.slug}
@router.get("/organization")
async def get_org(p: Principal = Depends(current_principal)):
if not p.org:
raise HTTPException(status.HTTP_404_NOT_FOUND, "No organization")
plan = await Plan.get(p.org.plan_id) if p.org.plan_id else None
return {
"id": str(p.org.id), "name": p.org.name, "slug": p.org.slug,
"logo_url": p.org.logo_url, "gstin": p.org.gstin, "pan": p.org.pan,
"timezone": p.org.timezone, "language": p.org.language,
"subscription_status": p.org.subscription_status,"address": p.org.address,
"phone": p.org.phone,
"website": p.org.website,
"contact_email": p.org.contact_email,
"address":p.org.address,"country": p.org.country,
"product_count": p.org.product_count, "user_count": p.org.user_count,
"plan": {"name": plan.name, "product_limit": plan.product_limit,
"user_limit": plan.user_limit, "price": plan.price} if plan else None,
"your_role": p.client_role,
}
@router.put("/organization")
async def update_org(body: dict, p: Principal = Depends(require_client_roles(ClientRole.admin))):
allowed = {"name", "logo_url", "gstin", "pan", "address", "contact_email","country",
"phone", "website", "timezone", "language"}
for k, v in body.items():
if k in allowed:
setattr(p.org, k, v)
await p.org.save()
await AuditLog(
org_id=str(p.org.id),
user_id=str(p.user.id),
user_name=p.user.name,
user_role=p.client_role,
action="organization.update",
module="organization",
details=f"Updated organization {p.org.name}",
).insert()
return {"message": "updated"}
from datetime import datetime
from typing import Optional
@router.get("/audit-logs")
async def org_audit_logs(
p: Principal = Depends(require_client_roles(ClientRole.admin)),
limit: int = 50,
module: Optional[str] = None,
user: Optional[str] = None,
from_date: Optional[str] = None,
to_date: Optional[str] = None,
):
query = AuditLog.find(AuditLog.org_id == str(p.org.id))
if module:
query = query.find(AuditLog.module == module)
if user:
query = query.find(AuditLog.user_name == user)
if from_date:
query = query.find(
AuditLog.created_at >= datetime.fromisoformat(from_date)
)
if to_date:
query = query.find(
AuditLog.created_at <= datetime.fromisoformat(to_date + "T23:59:59")
)
logs = await query.sort(-AuditLog.created_at).limit(limit).to_list()
return [
{
"at": l.created_at,
"user": l.user_name,
"role": l.user_role,
"action": l.action,
"module": l.module,
"details": l.details,
"ip": l.ip,
}
for l in logs
]
@router.get("/roles")
async def list_roles():
return [
{"role": "admin", "label": "Admin",
"description": "Full access within the organization: products, QRs, batches, compliance, users, billing."},
{"role": "finance", "label": "Finance",
"description": "Billing, invoices, payment methods, subscription within the org only."},
{"role": "user", "label": "User",
"description": "Create products, generate QRs, manage batches, view analytics. No billing/invites."},
]

View File

@@ -0,0 +1,324 @@
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status
from app.core.deps import require_org, require_client_roles, require_write_access, Principal
from app.models import Brand, Product, Plan, AuditLog
from app.models.documents import ManufacturerInfo, ProductDetails
from app.models.enums import ClientRole, ProductStatus
from app.schemas.common import ProductCreate
router = APIRouter(prefix="/products", tags=["products"])
def _out(p: Product, full: bool = False) -> dict:
base = {
"id": str(p.id), "name": p.name, "sku": p.sku, "category": p.category,
"brand_id": p.brand_id, "brand_name": p.brand_name, "image_url": p.image_url,
"status": p.status, "trust_score": p.trust_score, "recalled": p.recalled,
"batch_count": p.batch_count, "qr_count": p.qr_count, "scan_count": p.scan_count,
"created_at": p.created_at,"qr_status": "Generated" if p.qr_count > 0 else "Not Generated",
}
if full:
base.update({
"description": p.description,
"manufacturer": p.manufacturer.model_dump(),
"details": p.details.model_dump(),
"compliance_badges": [c.model_dump() for c in p.compliance_badges],
"recycling_info": p.recycling_info,
"internal_notes": p.internal_notes,
"qr_type": p.qr_type,
"display_brand_logo": p.display_brand_logo,
})
return base
from datetime import datetime
from typing import Optional
from fastapi import Depends
@router.get("")
async def list_products(
p: Principal = Depends(require_org),
# Search
search: Optional[str] = None,
# Filters
brand_id: Optional[str] = None,
category: Optional[str] = None,
status_filter: Optional[str] = None,
qr_status: Optional[str] = None,
trust_from: Optional[int] = None,
trust_to: Optional[int] = None,
created_from: Optional[datetime] = None,
created_to: Optional[datetime] = None,
# Pagination
page: int = 1,
page_size: int = 20,
):
products = await Product.find(
Product.org_id == str(p.org.id)
).to_list()
# Hide archived by default
if status_filter:
products = [p for p in products if p.status == status_filter]
else:
products = [
p for p in products
if p.status != ProductStatus.archived
]
# Search
if search:
s = search.lower()
products = [
p for p in products
if (
s in (p.name or "").lower()
or s in (p.sku or "").lower()
or s in (p.brand_name or "").lower()
or s in str(p.trust_score)
)
]
# Brand
if brand_id:
products = [
p for p in products
if p.brand_id == brand_id
]
# Category
if category:
products = [
p for p in products
if p.category == category
]
# QR Status
if qr_status:
if qr_status == "generated":
products = [
p for p in products
if p.qr_count > 0
]
elif qr_status == "not_generated":
products = [
p for p in products
if p.qr_count == 0
]
# Trust Score
if trust_from is not None:
products = [
p for p in products
if p.trust_score >= trust_from
]
if trust_to is not None:
products = [
p for p in products
if p.trust_score <= trust_to
]
# Created Date
if created_from:
products = [
p for p in products
if p.created_at >= created_from
]
if created_to:
products = [
p for p in products
if p.created_at <= created_to
]
products.sort(
key=lambda p: p.created_at,
reverse=True, # newest first
)
total = len(products)
start = (page - 1) * page_size
end = start + page_size
products = products[start:end]
plan = await Plan.get(p.org.plan_id) if p.org.plan_id else None
return {
"items": [_out(x) for x in products],
"total": total,
"page": page,
"page_size": page_size,
"pages": (total + page_size - 1) // page_size,
"count": p.org.product_count,
"limit": plan.product_limit if plan else None,
"limit_reached": bool(
plan
and plan.product_limit is not None
and p.org.product_count >= plan.product_limit
),
}
@router.post("", status_code=201)
async def create_product(body: ProductCreate,
p: Principal = Depends(require_client_roles(ClientRole.admin, ClientRole.user))):
require_write_access(p)
# ----- plan product-limit enforcement (the only enforced limit) -----
plan = await Plan.get(p.org.plan_id) if p.org.plan_id else None
if plan and plan.product_limit is not None and p.org.product_count >= plan.product_limit:
raise HTTPException(
status.HTTP_403_FORBIDDEN,
detail={"error": "product_limit_reached",
"message": f"You've reached your plan limit of {plan.product_limit} products. Upgrade to add more.",
"limit": plan.product_limit, "plan": plan.name, "upgrade": True},
)
if not body.brand_id:
raise HTTPException(status.HTTP_400_BAD_REQUEST,
detail={"message": "Please select a brand before saving."})
brand = await Brand.get(body.brand_id)
if not brand or brand.org_id != str(p.org.id):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Brand not found")
product = Product(
org_id=str(p.org.id), brand_id=body.brand_id, brand_name=brand.name,
name=body.name, sku=body.sku, category=body.category,
description=body.description, image_url=body.image_url,
manufacturer=body.manufacturer or ManufacturerInfo(),
details=body.details or ProductDetails(),
compliance_badges=body.compliance_badges, recycling_info=body.recycling_info,
internal_notes=body.internal_notes, qr_type=body.qr_type,
display_brand_logo=body.display_brand_logo, status=body.status,
)
await product.insert()
p.org.product_count += 1
await p.org.save()
brand.product_count += 1
await brand.save()
await AuditLog(org_id=str(p.org.id), user_id=str(p.user.id), user_name=p.user.name,
user_role=p.client_role, action="product.create", module="products",
details=f"Created product {product.name}").insert()
return _out(product)
@router.get("/{product_id}")
async def get_product(product_id: str, p: Principal = Depends(require_org)):
product = await Product.get(product_id)
if not product or product.org_id != str(p.org.id):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Product not found")
return _out(product, full=True)
@router.put("/{product_id}")
async def update_product(product_id: str, body: dict,
p: Principal = Depends(require_client_roles(ClientRole.admin, ClientRole.user))):
product = await Product.get(product_id)
if not product or product.org_id != str(p.org.id):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Product not found")
for k in ("name", "sku", "category", "description", "image_url",
"recycling_info", "internal_notes", "qr_type", "display_brand_logo",
"status", "trust_score", "recalled"):
if k in body:
setattr(product, k, body[k])
if "brand_id" in body and body["brand_id"]:
brand = await Brand.get(body["brand_id"])
if brand and brand.org_id == str(p.org.id):
product.brand_id = body["brand_id"]
product.brand_name = brand.name
if "manufacturer" in body and body["manufacturer"]:
product.manufacturer = ManufacturerInfo(**body["manufacturer"])
if "details" in body and body["details"]:
product.details = ProductDetails(**body["details"])
await product.save()
await AuditLog(
org_id=str(p.org.id),
user_id=str(p.user.id),
user_name=p.user.name,
user_role=p.client_role,
action="product.update",
module="products",
details=f"Updated product {product.name}",
).insert()
return _out(product, full=True)
@router.post("/{product_id}/duplicate", status_code=201)
async def duplicate_product(product_id: str,
p: Principal = Depends(require_client_roles(ClientRole.admin, ClientRole.user))):
"""Clone a product as a new draft (counts against the plan limit)."""
require_write_access(p)
src = await Product.get(product_id)
if not src or src.org_id != str(p.org.id):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Product not found")
plan = await Plan.get(p.org.plan_id) if p.org.plan_id else None
if plan and plan.product_limit is not None and p.org.product_count >= plan.product_limit:
raise HTTPException(status.HTTP_403_FORBIDDEN,
detail={"error": "product_limit_reached",
"message": f"You've reached your plan limit of {plan.product_limit} products. Upgrade to add more.",
"limit": plan.product_limit, "plan": plan.name, "upgrade": True})
data = src.model_dump(exclude={"id", "revision_id"})
data.update({"name": f"{src.name} (Copy)", "sku": (src.sku or "") + "-COPY",
"status": ProductStatus.draft, "batch_count": 0, "qr_count": 0,
"scan_count": 0})
dup = Product(**data)
await dup.insert()
p.org.product_count += 1
await p.org.save()
await AuditLog(org_id=str(p.org.id), user_id=str(p.user.id), user_name=p.user.name,
action="product.duplicate", module="products",
details=f"Duplicated {src.name}").insert()
return _out(dup, full=True)
@router.put("/{product_id}/archive")
async def archive_product(product_id: str,
p: Principal = Depends(require_client_roles(ClientRole.admin, ClientRole.user))):
"""Soft delete — status=archived. Existing QRs keep working; hidden from default list."""
product = await Product.get(product_id)
if not product or product.org_id != str(p.org.id):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Product not found")
product.status = ProductStatus.archived
await product.save()
p.org.product_count = max(0, p.org.product_count - 1)
await p.org.save()
await AuditLog(org_id=str(p.org.id), user_id=str(p.user.id), user_name=p.user.name,
action="product.archive", module="products",
details=f"Archived {product.name}").insert()
return {"status": "archived"}
@router.put("/{product_id}/restore")
async def restore_product(product_id: str,
p: Principal = Depends(require_client_roles(ClientRole.admin, ClientRole.user))):
product = await Product.get(product_id)
if not product or product.org_id != str(p.org.id):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Product not found")
plan = await Plan.get(p.org.plan_id) if p.org.plan_id else None
if plan and plan.product_limit is not None and p.org.product_count >= plan.product_limit:
raise HTTPException(status.HTTP_403_FORBIDDEN,
detail={"error": "product_limit_reached",
"message": "Restoring exceeds your plan limit. Upgrade first.",
"upgrade": True})
product.status = ProductStatus.active
await product.save()
p.org.product_count += 1
await p.org.save()
await AuditLog(
org_id=str(p.org.id),
user_id=str(p.user.id),
user_name=p.user.name,
user_role=p.client_role,
action="product.restore",
module="products",
details=f"Restored product {product.name}",
).insert()
return {"status": "active"}

474
backend/app/routers/qr.py Normal file
View File

@@ -0,0 +1,474 @@
import base64
import binascii
from typing import Optional
from fastapi import APIRouter, Body, Depends, HTTPException, status, Response
from app.core.deps import require_org, require_client_roles, require_write_access, Principal
from app.models import Batch, Product, Brand, QRCode, Compliance, ScanLog,AuditLog
from app.models.documents import QRStyle
from app.models.enums import ClientRole, QRType, QRStatus
from app.schemas.common import QRGenerateIn
from app.services import qr as qrsvc
from app.services.storage import upload_file
router = APIRouter(prefix="/qr", tags=["qr"])
async def _safe_get(model, oid):
"""Beanie .get() that returns None for empty/invalid ObjectIds instead of raising."""
if not oid:
return None
try:
return await model.get(oid)
except Exception:
return None
async def _build_snapshot(product: Product, brand: Brand, batch: Batch) -> dict:
return {
"product": {
"name": product.name, "sku": product.sku, "category": product.category,
"description": product.description, "image_url": product.image_url,
"trust_score": product.trust_score, "recalled": product.recalled,
"details": product.details.model_dump(),
"manufacturer": product.manufacturer.model_dump(),
"compliance": [c.model_dump() for c in product.compliance_badges],
"recycling_info": product.recycling_info,
},
"brand": {"name": brand.name, "logo_url": brand.logo_url} if brand else None,
"batch": {"number": batch.batch_number, "mfg_date": str(batch.mfg_date),
"expiry_date": str(batch.expiry_date)},
}
@router.post("/generate", status_code=201)
async def generate(body: QRGenerateIn,
p: Principal = Depends(require_client_roles(ClientRole.admin, ClientRole.user))):
"""Generate QR(s) for a batch. No credit deduction — free and unlimited."""
require_write_access(p)
batch = await Batch.get(body.batch_id)
if not batch or batch.org_id != str(p.org.id):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Batch not found")
product = await Product.get(batch.product_id)
brand = await Brand.get(product.brand_id) if product else None
style = body.style or QRStyle()
snapshot = await _build_snapshot(product, brand, batch)
created: list[QRCode] = []
if batch.qr_type == QRType.batch:
# Option 1 — one QR for the whole batch
code = qrsvc.new_code()
qr = QRCode(org_id=str(p.org.id), product_id=str(product.id), batch_id=str(batch.id),
code=code, qr_type=QRType.batch, style=style, snapshot=snapshot,
created_by=p.user.name)
await qr.insert()
created.append(qr)
else:
# Option 2 — serialized unique QR per pack
for i in range(1, batch.quantity + 1):
code = qrsvc.new_code()
qr = QRCode(org_id=str(p.org.id), product_id=str(product.id), batch_id=str(batch.id),
code=code, serial=i, qr_type=QRType.per_pack, style=style, snapshot=snapshot,
created_by=p.user.name)
await qr.insert()
created.append(qr)
product.qr_count += len(created)
await product.save()
await AuditLog(
org_id=str(p.org.id),
user_id=str(p.user.id),
user_name=p.user.name,
user_role=p.client_role,
action="qr.generate",
module="qr",
details=f"Generated {len(created)} QR(s) for batch {batch.batch_number}",
).insert()
return {
"batch_id": str(batch.id), "qr_type": batch.qr_type, "generated": len(created),
"codes": [{"id": str(q.id), "code": q.code, "serial": q.serial,
"url": qrsvc.verify_url(q.code)} for q in created],
"download_zip": batch.qr_type == QRType.per_pack,
}
@router.get("/summary")
async def qr_summary(p: Principal = Depends(require_org)):
from datetime import datetime, timezone
qrs = await QRCode.find(QRCode.org_id == str(p.org.id)).to_list()
scans = await ScanLog.find(ScanLog.org_id == str(p.org.id)).to_list()
total = len(qrs)
active = sum(1 for q in qrs if q.status == QRStatus.active)
inactive = total - active
today = datetime.now(timezone.utc).date()
today_scans = sum(1 for s in scans
if (s.scanned_at if s.scanned_at.tzinfo else s.scanned_at.replace(tzinfo=timezone.utc)).date() == today)
return {
"total": total, "active": active, "inactive": inactive,
"total_scans": sum(q.scan_count for q in qrs),
"today_scans": today_scans,
"active_pct": round(active / total * 100) if total else 0,
"inactive_pct": round(inactive / total * 100) if total else 0,
}
from datetime import datetime, timedelta
@router.get("")
async def list_qr(p: Principal = Depends(require_org), product_id: Optional[str] = None,
batch_id: Optional[str] = None, status_filter: Optional[str] = None,
brand_id: Optional[str] = None, qr_type: Optional[str] = None,
search: Optional[str] = None,start_date: Optional[str] = None,
end_date: Optional[str] = None):
qrs = await QRCode.find(QRCode.org_id == str(p.org.id)).to_list()
# join product + batch info for display
products = {str(x.id): x for x in await Product.find(Product.org_id == str(p.org.id)).to_list()}
batches = {str(b.id): b for b in await Batch.find(Batch.org_id == str(p.org.id)).to_list()}
rows = []
for q in qrs:
prod = products.get(q.product_id)
batch = batches.get(q.batch_id)
brand = await _safe_get(Brand, prod.brand_id) if prod else None
if product_id and q.product_id != product_id:
continue
if batch_id and q.batch_id != batch_id:
continue
if status_filter and q.status != status_filter:
continue
if qr_type and q.qr_type != qr_type:
continue
if start_date:
start = datetime.fromisoformat(start_date)
if q.created_at.replace(tzinfo=None) < start:
continue
if end_date:
end = datetime.fromisoformat(end_date) + timedelta(days=1)
if q.created_at.replace(tzinfo=None) >= end:
continue
if brand_id and (not prod or prod.brand_id != brand_id):
continue
if search:
s = search.lower()
hay = f"{q.code} {prod.name if prod else ''} {prod.sku if prod else ''} {batch.batch_number if batch else ''}".lower()
if s not in hay:
continue
rows.append({
"id": str(q.id), "code": q.code, "serial": q.serial, "qr_type": q.qr_type,
"status": q.status, "scan_count": q.scan_count, "last_scan_at": q.last_scan_at,
"last_scan_location": q.last_scan_location,
"product_id": q.product_id, "product_name": prod.name if prod else None,
"sku": prod.sku if prod else None, "brand_id": prod.brand_id if prod else None,"brand_name": brand.name if brand else None,
"batch_id": q.batch_id, "batch_number": batch.batch_number if batch else None,
"image_url": q.image_url, "url": qrsvc.verify_url(q.code), "created_at": q.created_at,"created_by": q.created_by,
})
rows.sort(key=lambda r: r["created_at"], reverse=True)
return rows
@router.post("/bulk")
async def bulk_action(body: dict = Body(...),
p: Principal = Depends(require_client_roles(ClientRole.admin, ClientRole.user))):
"""body = {ids: [...], action: 'disable'|'enable'|'archive'|'delete'}"""
ids = body.get("ids", [])
action = body.get("action")
n = 0
for qid in ids:
qr = await QRCode.get(qid)
if not qr or qr.org_id != str(p.org.id):
continue
if action == "disable":
qr.status = QRStatus.disabled
elif action == "enable":
qr.status = QRStatus.active
elif action == "archive":
qr.status = QRStatus.inactive
elif action == "delete":
await qr.delete(); n += 1; continue
else:
continue
await qr.save(); n += 1
return {"affected": n, "action": action}
async def _get_owned(qr_id: str, p: Principal) -> QRCode:
qr = await QRCode.get(qr_id)
if not qr or qr.org_id != str(p.org.id):
raise HTTPException(status.HTTP_404_NOT_FOUND, "QR not found")
return qr
@router.get("/{qr_id}")
async def get_qr(qr_id: str, p: Principal = Depends(require_org)):
qr = await _get_owned(qr_id, p)
return {"id": str(qr.id), "code": qr.code, "serial": qr.serial, "qr_type": qr.qr_type,
"status": qr.status, "scan_count": qr.scan_count, "style": qr.style.model_dump(),
"url": qrsvc.verify_url(qr.code), "snapshot": qr.snapshot}
@router.get("/{qr_id}/detail")
async def qr_detail(qr_id: str, p: Principal = Depends(require_org)):
"""Rich detail for the QR Details drawer: overview + scan stats + history."""
from datetime import datetime, timezone, timedelta
qr = await _get_owned(qr_id, p)
prod = await _safe_get(Product, qr.product_id)
batch = await _safe_get(Batch, qr.batch_id)
scans = await ScanLog.find(ScanLog.qr_id == str(qr.id)).to_list()
def aware(dt):
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
now_ = datetime.now(timezone.utc)
today = now_.date()
week_ago = now_ - timedelta(days=7)
scans.sort(key=lambda s: s.scanned_at, reverse=True)
serial_suffix = f"-{qr.serial:04d}" if qr.serial else "-0001"
qr_id_label = f"QR-{batch.batch_number}{serial_suffix}" if batch else f"QR-{qr.code}"
return {
"id": str(qr.id), "code": qr.code, "qr_id_label": qr_id_label,
"status": qr.status, "qr_type": qr.qr_type,
"short_url": qrsvc.verify_url(qr.code),
"image_url": qr.image_url,
"overview": {
"product": prod.name if prod else None,
"sku": prod.sku if prod else None,
"batch_number": batch.batch_number if batch else None,
"created_by": qr.created_by,
"created_at": qr.created_at,
"redirect_url": qrsvc.verify_url(qr.code),
"verification_enabled": qr.status == QRStatus.active,
"expiry": "No Expiry",
"remarks": qr.remarks,
},
"stats": {
"total": qr.scan_count,
"today": sum(1 for s in scans if aware(s.scanned_at).date() == today),
"week": sum(1 for s in scans if aware(s.scanned_at) >= week_ago),
},
"history": [{"at": s.scanned_at, "city": s.city, "country": s.country,
"device": s.device} for s in scans[:25]],
}
@router.post("/{qr_id}/image")
async def upload_qr_image(qr_id: str,
payload: dict = Body(...),
p: Principal = Depends(require_org)):
"""Store the client-rendered WYSIWYG image (data URL) for a QR code.
payload = {"data_url": "data:image/png;base64,....", "format": "png"|"svg"}
"""
qr = await _get_owned(qr_id, p)
data_url = payload.get("data_url", "")
fmt = payload.get("format", "png")
if "," not in data_url:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid data URL")
b64 = data_url.split(",", 1)[1]
try:
content = base64.b64decode(b64)
except (binascii.Error, ValueError):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Bad base64")
mime = "image/svg+xml" if fmt == "svg" else "image/png"
url = await upload_file(content, f"{qr.code}.{fmt}", mime)
qr.image_url = url
await qr.save()
return {"image_url": url}
@router.get("/{qr_id}/download")
async def download_qr(qr_id: str, format: str = "png"):
# Public: the rendered QR only encodes the public verify URL — no private data.
# This lets <a>/<img> links (which can't send the Bearer header) work directly.
qr = await QRCode.get(qr_id)
if not qr:
raise HTTPException(status.HTTP_404_NOT_FOUND, "QR not found")
data, mime = qrsvc.render(qr.code, qr.style, format)
ext = {"image/svg+xml": "svg", "application/pdf": "pdf"}.get(mime, "png")
return Response(content=data, media_type=mime,
headers={"Content-Disposition": f'attachment; filename="{qr.code}.{ext}"'})
@router.get("/batch/{batch_id}/download-zip")
async def download_zip(batch_id: str, format: str = "png"):
batch = await Batch.get(batch_id)
if not batch:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Batch not found")
qrs = await QRCode.find(QRCode.batch_id == batch_id).to_list()
if not qrs:
raise HTTPException(status.HTTP_404_NOT_FOUND, "No QR codes for this batch")
style = qrs[0].style
data = qrsvc.zip_codes([q.code for q in qrs], style, format)
return Response(content=data, media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="batch_{batch.batch_number}.zip"'})
@router.put("/{qr_id}/disable")
async def disable_qr(qr_id: str, p: Principal = Depends(require_client_roles(ClientRole.admin, ClientRole.user))):
qr = await _get_owned(qr_id, p)
qr.status = QRStatus.disabled
await qr.save()
await AuditLog(
org_id=str(p.org.id),
user_id=str(p.user.id),
user_name=p.user.name,
user_role=p.client_role,
action="qr.disable",
module="qr",
details=f"Disabled QR {qr.code}",
).insert()
return {"message": "disabled"}
@router.post("/{qr_id}/regenerate")
async def regenerate_qr(qr_id: str, p: Principal = Depends(require_client_roles(ClientRole.admin, ClientRole.user))):
"""Regenerate the code (free, no cost). Keeps style; old code stops resolving."""
qr = await _get_owned(qr_id, p)
qr.code = qrsvc.new_code()
qr.status = QRStatus.active
await qr.save()
await AuditLog(
org_id=str(p.org.id),
user_id=str(p.user.id),
user_name=p.user.name,
user_role=p.client_role,
action="qr.regenerate",
module="qr",
details=f"Regenerated QR {qr.code}",
).insert()
return {"id": str(qr.id), "code": qr.code, "url": qrsvc.verify_url(qr.code)}
from fastapi.responses import StreamingResponse
from io import BytesIO
from openpyxl import Workbook
from datetime import datetime, timedelta
@router.get("/export")
async def export_qr(
p: Principal = Depends(require_org),
product_id: Optional[str] = None,
batch_id: Optional[str] = None,
brand_id: Optional[str] = None,
qr_type: Optional[str] = None,
status_filter: Optional[str] = None,
search: Optional[str] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
):
qrs = await QRCode.find(QRCode.org_id == str(p.org.id)).to_list()
products = {
str(x.id): x
for x in await Product.find(Product.org_id == str(p.org.id)).to_list()
}
brands = {
str(x.id): x
for x in await Brand.find(Brand.org_id == str(p.org.id)).to_list()
}
batches = {
str(x.id): x
for x in await Batch.find(Batch.org_id == str(p.org.id)).to_list()
}
wb = Workbook()
ws = wb.active
ws.title = "QR Codes"
ws.append([
"QR Code",
"Status",
"QR Type",
"Serial",
"Product",
"SKU",
"Brand",
"Category",
"Batch No",
"Quantity",
"Manufacture Date",
"Expiry Date",
"Scan Count",
"Last Scan",
"Last Scan Location",
"Created By",
"Created At",
"Verification URL",
])
for q in qrs:
product = products.get(q.product_id)
batch = batches.get(q.batch_id)
brand = brands.get(product.brand_id) if product else None
if product_id and q.product_id != product_id:
continue
if batch_id and q.batch_id != batch_id:
continue
if status_filter and q.status != status_filter:
continue
if qr_type and q.qr_type != qr_type:
continue
if brand_id and (not product or product.brand_id != brand_id):
continue
if search:
s = search.lower()
text = f"{q.code} {product.name if product else ''} {product.sku if product else ''} {batch.batch_number if batch else ''}".lower()
if s not in text:
continue
if start_date:
start = datetime.fromisoformat(start_date)
if q.created_at.replace(tzinfo=None) < start:
continue
if end_date:
end = datetime.fromisoformat(end_date) + timedelta(days=1)
if q.created_at.replace(tzinfo=None) >= end:
continue
ws.append([
q.code,
q.status,
q.qr_type,
q.serial,
product.name if product else "",
product.sku if product else "",
brand.name if brand else "",
product.category if product else "",
batch.batch_number if batch else "",
batch.quantity if batch else "",
str(batch.mfg_date) if batch else "",
str(batch.expiry_date) if batch else "",
q.scan_count,
str(q.last_scan_at) if q.last_scan_at else "",
q.last_scan_location,
q.created_by,
str(q.created_at),
qrsvc.verify_url(q.code),
])
stream = BytesIO()
wb.save(stream)
stream.seek(0)
return StreamingResponse(
stream,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
"Content-Disposition": "attachment; filename=QR_Report.xlsx"
},
)

View File

@@ -0,0 +1,248 @@
"""Users & Organization — team management within an org, role-scoped."""
import secrets
from typing import Optional
from fastapi import APIRouter, Body, Depends, HTTPException, status
from app.core.config import settings
from app.core.deps import require_org, require_client_roles, Principal
from app.core.security import create_token
from app.models import User, OrgMember, Plan, AuditLog
from app.models.documents import now
from app.models.enums import ClientRole
from app.services.email import send_invite_email
import csv
from io import StringIO
from fastapi.responses import StreamingResponse
from app.core.security import hash_password
router = APIRouter(prefix="/users", tags=["users"])
async def _member_out(m: OrgMember) -> dict:
user = await User.get(m.user_id) if m.user_id else None
return {
"member_id": str(m.id),
"user_id": m.user_id,
"name": user.name if user else (m.invited_email or "Invited user"),
"email": user.email if user else m.invited_email,
"avatar_url": user.avatar_url if user else None,
"role": m.role,
"department": m.department,
"status": m.status,
"last_login": user.last_login if user else None,
"joined_at": m.joined_at or m.created_at,
}
from collections import Counter
@router.get("")
async def list_users(p: Principal = Depends(require_org)):
members = await OrgMember.find(OrgMember.org_id == str(p.org.id)).to_list()
rows = [await _member_out(m) for m in members]
role_summary = Counter()
for r in rows:
role_summary[r["role"]] += 1
plan = await Plan.get(p.org.plan_id) if p.org.plan_id else None
return {
"items": rows,
"role_summary": dict(role_summary),
"kpis": {
"total": len(rows),
"active": sum(1 for r in rows if r["status"] == "active"),
"pending": sum(1 for r in rows if r["status"] == "pending"),
"disabled": sum(1 for r in rows if r["status"] == "disabled"),
"user_limit": plan.user_limit if plan else None,
},
}
@router.get("/export")
async def export_users(
p: Principal = Depends(require_client_roles(ClientRole.admin))
):
members = await OrgMember.find(
OrgMember.org_id == str(p.org.id)
).to_list()
rows = [await _member_out(m) for m in members]
output = StringIO()
writer = csv.writer(output)
writer.writerow([
"Name",
"Email",
"Role",
"Department",
"Status",
"Last Login",
"Joined On",
])
for r in rows:
writer.writerow([
r["name"],
r["email"],
r["role"],
r["department"] or "",
r["status"],
r["last_login"] or "",
r["joined_at"] or "",
])
output.seek(0)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={
"Content-Disposition":
"attachment; filename=users.csv"
},
)
@router.post("/invite", status_code=201)
async def invite_user(
body: dict = Body(...),
p: Principal = Depends(require_client_roles(ClientRole.admin))
):
email = body["email"].strip().lower()
if await User.find_one(User.email == email):
raise HTTPException(
status.HTTP_409_CONFLICT,
"Email already exists",
)
user = User(
name=body["name"],
email=email,
hashed_password=hash_password(body["password"]),
email_verified=True,
)
await user.insert()
member = OrgMember(
org_id=str(p.org.id),
user_id=str(user.id),
invited_email=email,
role=ClientRole(body["role"]),
department=body.get("department"),
status="active",
joined_at=now(),
)
await member.insert()
await AuditLog(
org_id=str(p.org.id),
user_id=str(p.user.id),
user_name=p.user.name,
action="user.create",
module="users",
details=f"Created user {email}",
).insert()
return await _member_out(member)
@router.get("/{member_id}/activity")
async def user_activity(member_id: str,
p: Principal = Depends(require_org)):
logs = await (
AuditLog.find(
AuditLog.org_id == str(p.org.id),
AuditLog.target_member_id == member_id,
)
.sort(-AuditLog.created_at)
.to_list()
)
return {
"items": [
{
"id": str(log.id),
"action": log.action,
"module": log.module,
"details": log.details,
"created_at": log.created_at,
}
for log in logs
]
}
@router.put("/{member_id}")
async def update_member(
member_id: str,
body: dict = Body(...),
p: Principal = Depends(require_client_roles(ClientRole.admin))
):
# Find member
m = await OrgMember.get(member_id)
if not m or m.org_id != str(p.org.id):
raise HTTPException(
status.HTTP_404_NOT_FOUND,
"Member not found"
)
# Find linked user
user = await User.get(m.user_id)
if user:
# Update name
if body.get("name"):
user.name = body["name"]
# Update email (optional)
if body.get("email"):
user.email = body["email"].strip().lower()
# Update password (optional)
if body.get("password"):
user.hashed_password = hash_password(body["password"])
await user.save()
# Update OrgMember fields
if "role" in body:
m.role = ClientRole(body["role"])
if "department" in body:
m.department = body["department"]
if (
"status" in body
and body["status"] in ("active", "disabled", "pending")
):
m.status = body["status"]
await m.save()
# Audit log
await AuditLog(
org_id=str(p.org.id),
user_id=str(p.user.id),
user_name=p.user.name,
action="user.update",
module="users",
details=f"Updated member {member_id}",
).insert()
return await _member_out(m)
@router.delete("/{member_id}", status_code=204)
async def remove_member(member_id: str,
p: Principal = Depends(require_client_roles(ClientRole.admin))):
m = await OrgMember.get(member_id)
if not m or m.org_id != str(p.org.id):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Member not found")
if m.user_id == str(p.user.id):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "You cannot remove yourself")
await m.delete()
p.org.user_count = max(1, p.org.user_count - 1)
await p.org.save()

View File

@@ -0,0 +1,85 @@
"""Public consumer verification — no auth. Single-read via the QR snapshot.
GET /verify/{code} is READ-ONLY (no scan logging) so renders, prefetches, bots
and React Strict Mode double-mounts never inflate the scan count.
The frontend logs a scan explicitly via POST /scans/log after the page mounts.
Must return valid data in Active, Grace, and Archived subscription states;
never 404 for a valid QR. Resolves the four states:
genuine | unverified | not_found | recalled.
"""
from fastapi import APIRouter, Request
from app.models import QRCode, Organization, ScanLog
from app.models.documents import now
from app.models.enums import QRStatus, VerificationState, SubscriptionStatus
router = APIRouter(tags=["verify"])
def _device_from_ua(ua: str) -> str:
ua = (ua or "").lower()
if "android" in ua:
return "Android"
if "iphone" in ua or "ipad" in ua or "ios" in ua:
return "iOS"
if "mobile" in ua:
return "Other"
return "Desktop"
def _resolve_state(qr: QRCode, product: dict) -> VerificationState:
if product.get("recalled"):
return VerificationState.recalled
if qr.status in (QRStatus.disabled, QRStatus.inactive, QRStatus.expired):
return VerificationState.unverified
return VerificationState.genuine
@router.get("/verify/{qr_code}")
async def verify(qr_code: str):
"""Read-only. Does NOT log a scan."""
qr = await QRCode.find_one(QRCode.code == qr_code)
if not qr:
return {"state": VerificationState.not_found,
"message": "This QR code is not registered with VerifyPack."}
org = await Organization.get(qr.org_id)
if org and org.subscription_status == SubscriptionStatus.deleted:
return {"state": VerificationState.not_found,
"message": "This product record is no longer available."}
snap = qr.snapshot or {}
product = snap.get("product", {})
state = _resolve_state(qr, product)
return {
"state": state,
"qr_code": qr.code,
"serial": qr.serial,
"brand": snap.get("brand"),
"product": product,
"batch": snap.get("batch"),
"trust_score": product.get("trust_score", 90),
"subscription_state": org.subscription_status if org else None,
"powered_by": "VerifyPack",
}
@router.post("/scans/log")
async def log_scan(qr_code: str, request: Request):
"""Record a single scan. Called once by the consumer page after it mounts."""
qr = await QRCode.find_one(QRCode.code == qr_code)
if not qr:
return {"logged": False}
ua = request.headers.get("user-agent", "")
try:
await ScanLog(org_id=qr.org_id, qr_id=str(qr.id), qr_code=qr.code,
product_id=qr.product_id, device=_device_from_ua(ua),
ip=request.client.host if request.client else None).insert()
qr.scan_count += 1
qr.last_scan_at = now()
await qr.save()
except Exception:
return {"logged": False}
return {"logged": True, "scan_count": qr.scan_count}