Prepare ERP source for Gitea deployment

This commit is contained in:
A R R R Associates
2026-06-20 15:01:44 +05:30
commit 5c75eb6bd9
450 changed files with 67698 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Marketplace / public lead foundation module."""
+65
View File
@@ -0,0 +1,65 @@
from __future__ import annotations
from datetime import datetime, timezone
from decimal import Decimal
from sqlalchemy import DateTime, ForeignKey, Integer, Numeric, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db.common import CommonBase
class MarketplaceLead(CommonBase):
"""Public/service marketplace lead captured for later assignment and conversion."""
__tablename__ = "marketplace_leads"
__table_args__ = (UniqueConstraint("lead_no", name="uq_marketplace_leads_lead_no"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
lead_no: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
source: Mapped[str] = mapped_column(String(50), nullable=False, default="manual", index=True)
service_category: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
service_requested: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
lead_name: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
business_name: Mapped[str | None] = mapped_column(String(200), nullable=True, index=True)
email: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
mobile: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
city: Mapped[str | None] = mapped_column(String(100), nullable=True)
state: Mapped[str | None] = mapped_column(String(100), nullable=True)
message: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(String(30), nullable=False, default="NEW", index=True)
priority: Mapped[str] = mapped_column(String(30), nullable=False, default="NORMAL", index=True)
estimated_value: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False, default=Decimal("0.00"))
assigned_tenant_id: Mapped[int | None] = mapped_column(ForeignKey("tenants.id"), nullable=True, index=True)
assigned_branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id"), nullable=True, index=True)
assigned_partner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
assigned_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
assigned_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
converted_client_id: Mapped[int | None] = mapped_column(ForeignKey("clients.id"), nullable=True, index=True)
converted_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
converted_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
class MarketplaceLeadAssignment(CommonBase):
"""Assignment history for marketplace leads."""
__tablename__ = "marketplace_lead_assignments"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
lead_id: Mapped[int] = mapped_column(ForeignKey("marketplace_leads.id", ondelete="CASCADE"), nullable=False, index=True)
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id"), nullable=False, index=True)
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id"), nullable=True, index=True)
partner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
status: Mapped[str] = mapped_column(String(30), nullable=False, default="ASSIGNED", index=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
assigned_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
assigned_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
+228
View File
@@ -0,0 +1,228 @@
from __future__ import annotations
from datetime import datetime, timezone
from decimal import Decimal, ROUND_HALF_UP
from sqlalchemy import func, or_, select
from app.modules.clients.models import Client
from app.modules.consultants.models import ConsultantProfile
from app.modules.core.iam.models import User
from app.modules.core.tenancy.models import Branch, Tenant
from app.modules.marketplace.models import MarketplaceLead, MarketplaceLeadAssignment
LEAD_STATUSES = ["NEW", "CONTACTED", "QUALIFIED", "ASSIGNED", "ACCEPTED", "REJECTED", "CONVERTED", "LOST"]
LEAD_PRIORITIES = ["LOW", "NORMAL", "HIGH", "URGENT"]
LEAD_SOURCES = ["manual", "public_website", "consultant", "referral", "campaign", "other"]
SERVICE_CATEGORIES = ["GST", "Income Tax", "ROC", "Audit", "Accounting", "Payroll", "Registration", "Advisory", "Other"]
def money(value) -> Decimal:
if value in (None, ""):
return Decimal("0.00")
return Decimal(str(value)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
def _role_set(role_names: list[str] | None) -> set[str]:
return set(role_names or [])
def is_system_admin(role_names: list[str] | None) -> bool:
return "System Admin" in _role_set(role_names)
def is_firm_admin(role_names: list[str] | None) -> bool:
return "Firm Admin" in _role_set(role_names)
def is_partner(role_names: list[str] | None) -> bool:
return "Partner" in _role_set(role_names)
def generate_lead_no(db) -> str:
year = datetime.now().year
count = db.execute(select(func.count(MarketplaceLead.id))).scalar_one() or 0
return f"ML-{year}-{count + 1:05d}"
def list_reference_audit_firms(db) -> list[Tenant]:
return list(db.execute(select(Tenant).order_by(Tenant.name.asc())).scalars().all())
def list_reference_branches(db, tenant_id: int | None = None) -> list[Branch]:
stmt = select(Branch).order_by(Branch.name.asc())
if tenant_id:
stmt = stmt.where(Branch.tenant_id == tenant_id)
return list(db.execute(stmt).scalars().all())
def list_reference_partners(db, tenant_id: int | None = None) -> list[User]:
stmt = select(User).order_by(User.full_name.asc(), User.email.asc())
if tenant_id:
stmt = stmt.where(User.tenant_id == tenant_id)
return list(db.execute(stmt).scalars().all())
def list_marketplace_leads(db, *, q: str = "", status: str = "", user=None, role_names: list[str] | None = None) -> list[MarketplaceLead]:
stmt = select(MarketplaceLead).order_by(MarketplaceLead.created_at_utc.desc(), MarketplaceLead.id.desc())
roles = _role_set(role_names)
if not is_system_admin(role_names):
tenant_id = getattr(user, "tenant_id", None)
if tenant_id:
stmt = stmt.where(MarketplaceLead.assigned_tenant_id == tenant_id)
if is_partner(role_names):
stmt = stmt.where(MarketplaceLead.assigned_partner_user_id == getattr(user, "id", None))
if status:
stmt = stmt.where(MarketplaceLead.status == status)
if q:
like = f"%{q}%"
stmt = stmt.where(or_(MarketplaceLead.lead_no.ilike(like), MarketplaceLead.lead_name.ilike(like), MarketplaceLead.business_name.ilike(like), MarketplaceLead.mobile.ilike(like), MarketplaceLead.email.ilike(like), MarketplaceLead.service_requested.ilike(like)))
return list(db.execute(stmt).scalars().all())
def get_marketplace_lead(db, lead_id: int) -> MarketplaceLead | None:
return db.get(MarketplaceLead, int(lead_id))
def create_marketplace_lead(db, *, lead_name: str, business_name: str | None, email: str | None, mobile: str | None, city: str | None, state: str | None, service_category: str | None, service_requested: str, message: str | None, source: str = "manual", priority: str = "NORMAL", estimated_value=0, created_by_user_id: int | None = None) -> MarketplaceLead:
lead = MarketplaceLead(
lead_no=generate_lead_no(db),
source=source or "manual",
service_category=service_category or None,
service_requested=(service_requested or "General enquiry").strip(),
lead_name=lead_name.strip(),
business_name=(business_name or None),
email=(email or None),
mobile=(mobile or None),
city=(city or None),
state=(state or None),
message=(message or None),
priority=priority if priority in LEAD_PRIORITIES else "NORMAL",
estimated_value=money(estimated_value),
created_by_user_id=created_by_user_id,
)
db.add(lead)
db.commit()
db.refresh(lead)
return lead
def assign_marketplace_lead(db, *, lead: MarketplaceLead, tenant_id: int, branch_id: int | None, partner_user_id: int | None, notes: str | None, assigned_by_user_id: int | None) -> MarketplaceLead:
now = datetime.now(timezone.utc)
lead.assigned_tenant_id = tenant_id
lead.assigned_branch_id = branch_id or None
lead.assigned_partner_user_id = partner_user_id or None
lead.assigned_by_user_id = assigned_by_user_id
lead.assigned_at_utc = now
lead.status = "ASSIGNED"
lead.updated_by_user_id = assigned_by_user_id
db.add(MarketplaceLeadAssignment(lead_id=lead.id, tenant_id=tenant_id, branch_id=branch_id or None, partner_user_id=partner_user_id or None, notes=notes or None, assigned_by_user_id=assigned_by_user_id, assigned_at_utc=now))
db.commit()
db.refresh(lead)
return lead
def update_lead_status(db, *, lead: MarketplaceLead, status: str, user_id: int | None) -> MarketplaceLead:
if status not in LEAD_STATUSES:
raise ValueError("Invalid lead status")
lead.status = status
lead.updated_by_user_id = user_id
db.commit()
db.refresh(lead)
return lead
def convert_lead_to_client(db, *, lead: MarketplaceLead, tenant_id: int, branch_id: int, partner_user_id: int | None, client_code: str | None, user_id: int | None) -> Client:
if lead.converted_client_id:
existing = db.get(Client, lead.converted_client_id)
if existing:
return existing
code = (client_code or f"LEAD-{lead.id:05d}").strip().upper()
name = (lead.business_name or lead.lead_name).strip()
client = Client(
tenant_id=tenant_id,
branch_id=branch_id,
partner_id=partner_user_id or None,
engagement_mode="internal_managed",
client_code=code,
client_name=name,
trade_name=lead.business_name or None,
client_type="Other",
contact_person_name=lead.lead_name,
mobile=lead.mobile,
email=lead.email,
city=lead.city,
state=lead.state,
status="active",
is_active=True,
is_archived=False,
notes=f"Converted from marketplace lead {lead.lead_no}.\n\n{lead.message or ''}".strip(),
created_by_user_id=user_id,
updated_by_user_id=user_id,
)
db.add(client)
db.flush()
lead.converted_client_id = client.id
lead.converted_by_user_id = user_id
lead.converted_at_utc = datetime.now(timezone.utc)
lead.status = "CONVERTED"
lead.updated_by_user_id = user_id
db.commit()
db.refresh(client)
return client
def get_marketplace_overview_counts(db) -> dict[str, int]:
"""Return lightweight public marketplace counts.
This is intentionally read-only and tolerant of partially configured data so the
public marketplace page never breaks the ERP login/runtime flow.
"""
try:
firm_count = db.execute(select(func.count(Tenant.id)).where(Tenant.is_active.is_(True))).scalar_one() or 0
except Exception:
firm_count = 0
try:
consultant_count = db.execute(select(func.count(ConsultantProfile.id)).where(ConsultantProfile.is_active.is_(True))).scalar_one() or 0
except Exception:
consultant_count = 0
try:
lead_count = db.execute(select(func.count(MarketplaceLead.id))).scalar_one() or 0
except Exception:
lead_count = 0
return {"audit_firms": int(firm_count), "consultants": int(consultant_count), "service_requests": int(lead_count)}
def list_public_marketplace_audit_firms(db, limit: int = 12) -> list[Tenant]:
"""List active audit firms for public marketplace display.
More advanced publication controls can be added later. For Phase 7T.4 this uses
only active tenants and does not expose private firm records beyond basic name/type.
"""
try:
stmt = select(Tenant).where(Tenant.is_active.is_(True)).order_by(Tenant.name.asc()).limit(int(limit or 12))
return list(db.execute(stmt).scalars().all())
except Exception:
return []
def list_public_marketplace_consultants(db, limit: int = 12) -> list[ConsultantProfile]:
"""List active consultants for public marketplace display."""
try:
stmt = (
select(ConsultantProfile)
.where(ConsultantProfile.is_active.is_(True))
.order_by(ConsultantProfile.contact_person.asc(), ConsultantProfile.firm_name.asc())
.limit(int(limit or 12))
)
return list(db.execute(stmt).scalars().all())
except Exception:
return []
def is_marketplace_domain_request(request) -> bool:
"""True when Phase 7T.2 resolved the host as a marketplace domain."""
try:
return getattr(request.state, "domain_type", None) == "marketplace"
except Exception:
return False
@@ -0,0 +1,35 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-semibold text-slate-900">Marketplace Leads</h1>
<p class="mt-1 text-sm text-slate-500">Public/service leads that can be assigned to Audit Firms and converted into clients.</p>
</div>
<div class="flex flex-wrap gap-2">
<a href="/marketplace/leads" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm">View Leads</a>
{% if can_create %}<a href="/marketplace/leads/new" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white shadow-soft">New Lead</a>{% endif %}
<a href="/marketplace/public-lead" class="rounded-xl border border-brand-200 bg-white px-4 py-2 text-sm font-semibold text-brand-700 shadow-sm">Public Lead Form</a>
</div>
</div>
<div class="grid gap-4 md:grid-cols-4">
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft"><div class="text-sm text-slate-500">Visible Leads</div><div class="mt-2 text-3xl font-semibold">{{ total }}</div></div>
<a href="/marketplace/leads?status=NEW" class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft"><div class="text-sm text-slate-500">New</div><div class="mt-2 text-3xl font-semibold">{{ leads|selectattr('status','equalto','NEW')|list|length }}</div></a>
<a href="/marketplace/leads?status=ASSIGNED" class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft"><div class="text-sm text-slate-500">Assigned</div><div class="mt-2 text-3xl font-semibold">{{ leads|selectattr('status','equalto','ASSIGNED')|list|length }}</div></a>
<a href="/marketplace/leads?status=CONVERTED" class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft"><div class="text-sm text-slate-500">Converted</div><div class="mt-2 text-3xl font-semibold">{{ leads|selectattr('status','equalto','CONVERTED')|list|length }}</div></a>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
<h2 class="font-semibold">Recent Leads</h2>
<div class="mt-4 overflow-x-auto">
<table class="min-w-full text-sm">
<thead><tr class="border-b text-left text-slate-500"><th class="py-2">Lead</th><th>Service</th><th>Status</th><th>Contact</th><th></th></tr></thead>
<tbody>
{% for row in leads %}
<tr class="border-b last:border-0"><td class="py-2 font-medium">{{ row.lead_no }}<div class="text-xs text-slate-500">{{ row.lead_name }}</div></td><td>{{ row.service_requested }}</td><td>{{ row.status }}</td><td>{{ row.mobile or row.email or '-' }}</td><td><a href="/marketplace/leads/{{ row.id }}" class="text-brand-700">Open</a></td></tr>
{% else %}<tr><td colspan="5" class="py-6 text-center text-slate-500">No leads found.</td></tr>{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,61 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div><h1 class="text-2xl font-semibold">{{ lead.lead_no }} - {{ lead.lead_name }}</h1><p class="text-sm text-slate-500">{{ lead.service_requested }} • {{ lead.status }}</p></div>
<a href="/marketplace/leads" class="rounded-xl border px-4 py-2 text-sm">Back</a>
</div>
<div class="grid gap-5 lg:grid-cols-3">
<div class="lg:col-span-2 rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
<h2 class="font-semibold">Lead Details</h2>
<dl class="mt-4 grid gap-3 text-sm md:grid-cols-2">
<div><dt class="text-slate-500">Business</dt><dd class="font-medium">{{ lead.business_name or '-' }}</dd></div>
<div><dt class="text-slate-500">Contact</dt><dd>{{ lead.mobile or '-' }} / {{ lead.email or '-' }}</dd></div>
<div><dt class="text-slate-500">Location</dt><dd>{{ lead.city or '-' }}, {{ lead.state or '-' }}</dd></div>
<div><dt class="text-slate-500">Category</dt><dd>{{ lead.service_category or '-' }}</dd></div>
<div><dt class="text-slate-500">Priority</dt><dd>{{ lead.priority }}</dd></div>
<div><dt class="text-slate-500">Estimated Value</dt><dd>{{ lead.estimated_value }}</dd></div>
<div><dt class="text-slate-500">Assigned Audit Firm ID</dt><dd>{{ lead.assigned_tenant_id or '-' }}</dd></div>
<div><dt class="text-slate-500">Assigned Partner User ID</dt><dd>{{ lead.assigned_partner_user_id or '-' }}</dd></div>
<div class="md:col-span-2"><dt class="text-slate-500">Message</dt><dd class="whitespace-pre-line">{{ lead.message or '-' }}</dd></div>
</dl>
</div>
<div class="space-y-5">
{% if can_update %}
<form method="post" action="/marketplace/leads/{{ lead.id }}/status" class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<h2 class="font-semibold">Update Status</h2>
<select name="status" class="mt-3 w-full rounded-xl border px-3 py-2 text-sm">{% for s in lead_statuses %}<option value="{{ s }}" {% if lead.status==s %}selected{% endif %}>{{ s }}</option>{% endfor %}</select>
<button class="mt-3 w-full rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Update</button>
</form>
{% endif %}
{% if can_assign %}
<form method="post" action="/marketplace/leads/{{ lead.id }}/assign" class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<h2 class="font-semibold">Assign Lead</h2>
<label class="mt-3 block text-sm">Audit Firm<select name="tenant_id" required class="mt-1 w-full rounded-xl border px-3 py-2"><option value="">Select</option>{% for t in audit_firms %}<option value="{{ t.id }}" {% if lead.assigned_tenant_id==t.id %}selected{% endif %}>{{ t.name }}</option>{% endfor %}</select></label>
<label class="mt-3 block text-sm">Branch<select name="branch_id" class="mt-1 w-full rounded-xl border px-3 py-2"><option value="0">Optional</option>{% for b in branches %}<option value="{{ b.id }}" {% if lead.assigned_branch_id==b.id %}selected{% endif %}>{{ b.name }}</option>{% endfor %}</select></label>
<label class="mt-3 block text-sm">Partner/User<select name="partner_user_id" class="mt-1 w-full rounded-xl border px-3 py-2"><option value="0">Optional</option>{% for p in partners %}<option value="{{ p.id }}" {% if lead.assigned_partner_user_id==p.id %}selected{% endif %}>{{ p.full_name or p.email }}</option>{% endfor %}</select></label>
<label class="mt-3 block text-sm">Notes<textarea name="notes" rows="2" class="mt-1 w-full rounded-xl border px-3 py-2"></textarea></label>
<button class="mt-3 w-full rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Assign</button>
</form>
{% endif %}
{% if can_convert and not lead.converted_client_id and lead.assigned_tenant_id %}
<form method="post" action="/marketplace/leads/{{ lead.id }}/convert-client" class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<h2 class="font-semibold">Convert to Client</h2>
<input type="hidden" name="tenant_id" value="{{ lead.assigned_tenant_id or '' }}">
<label class="mt-3 block text-sm">Branch ID<input name="branch_id" value="{{ lead.assigned_branch_id or '' }}" required class="mt-1 w-full rounded-xl border px-3 py-2"></label>
<label class="mt-3 block text-sm">Partner User ID<input name="partner_user_id" value="{{ lead.assigned_partner_user_id or '' }}" class="mt-1 w-full rounded-xl border px-3 py-2"></label>
<label class="mt-3 block text-sm">Client Code<input name="client_code" value="LEAD-{{ '%05d'|format(lead.id) }}" class="mt-1 w-full rounded-xl border px-3 py-2"></label>
<button class="mt-3 w-full rounded-xl border border-brand-200 bg-white px-4 py-2 text-sm font-semibold text-brand-700">Create Client</button>
</form>
{% elif can_convert and not lead.converted_client_id and not lead.assigned_tenant_id %}
<div class="rounded-2xl border border-amber-200 bg-amber-50 p-5 text-sm text-amber-800">Assign this lead to an Audit Firm before converting it to a client.</div>
{% elif lead.converted_client_id %}
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-5 text-sm text-emerald-800">Converted to Client ID {{ lead.converted_client_id }}</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,22 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="mx-auto max-w-3xl rounded-2xl border border-slate-200 bg-white p-6 shadow-soft">
<h1 class="text-xl font-semibold">New Marketplace Lead</h1>
<form method="post" class="mt-5 grid gap-4 md:grid-cols-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label class="text-sm">Contact Name<input name="lead_name" required class="mt-1 w-full rounded-xl border px-3 py-2"></label>
<label class="text-sm">Business Name<input name="business_name" class="mt-1 w-full rounded-xl border px-3 py-2"></label>
<label class="text-sm">Email<input name="email" type="email" class="mt-1 w-full rounded-xl border px-3 py-2"></label>
<label class="text-sm">Mobile<input name="mobile" class="mt-1 w-full rounded-xl border px-3 py-2"></label>
<label class="text-sm">City<input name="city" class="mt-1 w-full rounded-xl border px-3 py-2"></label>
<label class="text-sm">State<input name="state" class="mt-1 w-full rounded-xl border px-3 py-2"></label>
<label class="text-sm">Category<select name="service_category" class="mt-1 w-full rounded-xl border px-3 py-2"><option value="">Select</option>{% for c in service_categories %}<option value="{{ c }}">{{ c }}</option>{% endfor %}</select></label>
<label class="text-sm">Service Requested<input name="service_requested" required placeholder="GST registration, ITR filing, Tax audit..." class="mt-1 w-full rounded-xl border px-3 py-2"></label>
<label class="text-sm">Source<select name="source" class="mt-1 w-full rounded-xl border px-3 py-2">{% for s in lead_sources %}<option value="{{ s }}">{{ s }}</option>{% endfor %}</select></label>
<label class="text-sm">Priority<select name="priority" class="mt-1 w-full rounded-xl border px-3 py-2">{% for p in lead_priorities %}<option value="{{ p }}" {% if p=='NORMAL' %}selected{% endif %}>{{ p }}</option>{% endfor %}</select></label>
<label class="text-sm">Estimated Value<input name="estimated_value" value="0" class="mt-1 w-full rounded-xl border px-3 py-2"></label>
<label class="text-sm md:col-span-2">Message<textarea name="message" rows="4" class="mt-1 w-full rounded-xl border px-3 py-2"></textarea></label>
<div class="md:col-span-2 flex gap-2"><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Save Lead</button><a href="/marketplace/leads" class="rounded-xl border px-4 py-2 text-sm">Cancel</a></div>
</form>
</div>
{% endblock %}
@@ -0,0 +1,24 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-5">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div><h1 class="text-2xl font-semibold">Marketplace Leads</h1><p class="text-sm text-slate-500">Manage public leads and lead assignments.</p></div>
{% if can_create %}<a href="/marketplace/leads/new" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white shadow-soft">New Lead</a>{% endif %}
</div>
<form method="get" class="flex flex-wrap gap-2 rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
<input name="q" value="{{ q }}" placeholder="Search lead, contact, service" class="min-w-64 rounded-xl border border-slate-300 px-3 py-2 text-sm">
<select name="status" class="rounded-xl border border-slate-300 px-3 py-2 text-sm"><option value="">All Status</option>{% for s in lead_statuses %}<option value="{{ s }}" {% if selected_status == s %}selected{% endif %}>{{ s }}</option>{% endfor %}</select>
<button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium">Filter</button>
</form>
<div class="overflow-x-auto rounded-2xl border border-slate-200 bg-white shadow-soft">
<table class="min-w-full text-sm">
<thead><tr class="border-b bg-slate-50 text-left text-slate-600"><th class="px-4 py-3">Lead No</th><th>Lead</th><th>Service</th><th>Status</th><th>Priority</th><th>Assigned Audit Firm ID</th><th></th></tr></thead>
<tbody>
{% for row in rows %}
<tr class="border-b last:border-0"><td class="px-4 py-3 font-medium">{{ row.lead_no }}</td><td>{{ row.lead_name }}<div class="text-xs text-slate-500">{{ row.business_name or '' }}</div></td><td>{{ row.service_requested }}</td><td>{{ row.status }}</td><td>{{ row.priority }}</td><td>{{ row.assigned_tenant_id or '-' }}</td><td><a href="/marketplace/leads/{{ row.id }}" class="text-brand-700">Open</a></td></tr>
{% else %}<tr><td colspan="7" class="px-4 py-8 text-center text-slate-500">No leads found.</td></tr>{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
@@ -0,0 +1,20 @@
{% extends "modules/marketplace/templates/marketplace/public_base.html" %}
{% block public_content %}
<section class="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div><h1 class="text-3xl font-bold">Audit Firms</h1><p class="mt-2 text-slate-600">Active audit firms available in the marketplace.</p></div>
<a href="/request-service" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Request Service</a>
</div>
<div class="mt-8 grid gap-5 md:grid-cols-2 lg:grid-cols-3">
{% for firm in public_audit_firms %}
<article class="rounded-3xl border border-slate-200 bg-white p-6 shadow-soft">
<div class="text-lg font-bold">{{ firm.name }}</div>
<div class="mt-1 text-sm text-slate-500">{{ firm.firm_type|replace('_',' ')|title if firm.firm_type else 'Audit Firm' }}</div>
<div class="mt-4 rounded-2xl bg-slate-50 p-3 text-xs text-slate-500">Service enquiry can be routed by the marketplace team.</div>
</article>
{% else %}
<div class="col-span-full rounded-3xl border border-dashed border-slate-300 bg-white p-8 text-center text-slate-500">No public audit firm listing is available yet.</div>
{% endfor %}
</div>
</section>
{% endblock %}
@@ -0,0 +1,68 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
{% set brand = get_current_firm_branding(request, none) %}
<title>{{ title or 'Marketplace' }} | {{ brand.firm_name or 'FilingABC' }}</title>
{% if brand.favicon_url %}<link rel="icon" href="{{ brand.favicon_url }}" />{% endif %}
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="/static/css/theme_tokens.css?v=7t4-marketplace" />
<style>
:root {
--af-color-brand-500: {{ brand.primary_color or '#2563eb' }};
--af-color-brand-600: {{ brand.primary_color or '#1d4ed8' }};
--af-color-brand-700: {{ brand.accent_color or '#1e40af' }};
--af-color-brand-800: {{ brand.accent_color or '#1e3a8a' }};
--af-color-brand-900: {{ brand.accent_color or '#172554' }};
}
</style>
</head>
<body class="min-h-screen bg-slate-50 text-slate-900">
<header class="sticky top-0 z-30 border-b border-slate-200 bg-white/90 backdrop-blur">
<div class="mx-auto flex max-w-7xl items-center justify-between px-4 py-4 sm:px-6 lg:px-8">
<a href="/" class="flex items-center gap-3">
{% if brand.logo_url %}
<img src="{{ brand.logo_url }}" alt="{{ brand.firm_name }}" class="h-10 w-10 rounded-2xl object-contain" />
{% else %}
<div class="flex h-10 w-10 items-center justify-center rounded-2xl bg-brand-600 text-sm font-bold text-white">FA</div>
{% endif %}
<div>
<div class="text-sm font-bold tracking-wide text-slate-900">{{ brand.firm_name or 'FilingABC' }}</div>
<div class="text-xs text-slate-500">Marketplace + SaaS</div>
</div>
</a>
<nav class="hidden items-center gap-5 text-sm font-medium text-slate-600 md:flex">
<a href="/audit-firms" class="hover:text-brand-700">Audit Firms</a>
<a href="/consultants" class="hover:text-brand-700">Consultants</a>
<a href="/request-service" class="hover:text-brand-700">Request Service</a>
</nav>
<div class="flex items-center gap-2">
<a href="/login" class="rounded-xl border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Login</a>
<a href="/request-service" class="hidden rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white shadow-soft hover:bg-brand-700 sm:inline-flex">Get Help</a>
</div>
</div>
</header>
<main>
{% block public_content %}{% endblock %}
</main>
<footer class="border-t border-slate-200 bg-white">
<div class="mx-auto grid max-w-7xl gap-6 px-4 py-8 text-sm text-slate-500 sm:px-6 md:grid-cols-3 lg:px-8">
<div>
<div class="font-semibold text-slate-800">{{ brand.firm_name or 'FilingABC' }}</div>
<p class="mt-2">A marketplace layer for audit firms, consultants and clients.</p>
</div>
<div>
<div class="font-semibold text-slate-800">Quick Links</div>
<div class="mt-2 space-y-1"><a href="/audit-firms" class="block hover:text-brand-700">Audit Firms</a><a href="/consultants" class="block hover:text-brand-700">Consultants</a><a href="/request-service" class="block hover:text-brand-700">Request a Service</a></div>
</div>
<div>
<div class="font-semibold text-slate-800">Contact</div>
<div class="mt-2 space-y-1">{% if brand.contact_email %}<div>{{ brand.contact_email }}</div>{% endif %}{% if brand.contact_mobile %}<div>{{ brand.contact_mobile }}</div>{% endif %}<div>{{ brand.domain_name or request.url.hostname }}</div></div>
</div>
</div>
</footer>
</body>
</html>
@@ -0,0 +1,20 @@
{% extends "modules/marketplace/templates/marketplace/public_base.html" %}
{% block public_content %}
<section class="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div><h1 class="text-3xl font-bold">Consultants</h1><p class="mt-2 text-slate-600">Bookkeeping, filing, advisory and referral partners.</p></div>
<a href="/request-service" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Request Service</a>
</div>
<div class="mt-8 grid gap-5 md:grid-cols-2 lg:grid-cols-3">
{% for consultant in public_consultants %}
<article class="rounded-3xl border border-slate-200 bg-white p-6 shadow-soft">
<div class="text-lg font-bold">{{ consultant.firm_name or consultant.contact_person }}</div>
<div class="mt-1 text-sm text-slate-500">{{ consultant.specialisation or consultant.consultant_type|replace('_',' ')|title }}</div>
{% if consultant.mobile or consultant.email %}<div class="mt-4 space-y-1 text-xs text-slate-500">{% if consultant.mobile %}<div>{{ consultant.mobile }}</div>{% endif %}{% if consultant.email %}<div>{{ consultant.email }}</div>{% endif %}</div>{% endif %}
</article>
{% else %}
<div class="col-span-full rounded-3xl border border-dashed border-slate-300 bg-white p-8 text-center text-slate-500">No public consultant listing is available yet.</div>
{% endfor %}
</div>
</section>
{% endblock %}
@@ -0,0 +1,20 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="mx-auto max-w-3xl rounded-2xl border border-slate-200 bg-white p-6 shadow-soft">
<h1 class="text-2xl font-semibold">Request Professional Service</h1>
<p class="mt-1 text-sm text-slate-500">Submit your requirement. The platform team will review and assign it to a suitable Audit Firm.</p>
<form method="post" class="mt-5 grid gap-4 md:grid-cols-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label class="text-sm">Your Name<input name="lead_name" required class="mt-1 w-full rounded-xl border px-3 py-2"></label>
<label class="text-sm">Business Name<input name="business_name" class="mt-1 w-full rounded-xl border px-3 py-2"></label>
<label class="text-sm">Email<input name="email" type="email" class="mt-1 w-full rounded-xl border px-3 py-2"></label>
<label class="text-sm">Mobile<input name="mobile" required class="mt-1 w-full rounded-xl border px-3 py-2"></label>
<label class="text-sm">City<input name="city" class="mt-1 w-full rounded-xl border px-3 py-2"></label>
<label class="text-sm">State<input name="state" class="mt-1 w-full rounded-xl border px-3 py-2"></label>
<label class="text-sm">Category<select name="service_category" class="mt-1 w-full rounded-xl border px-3 py-2"><option value="">Select</option>{% for c in service_categories %}<option value="{{ c }}">{{ c }}</option>{% endfor %}</select></label>
<label class="text-sm">Service Required<input name="service_requested" required class="mt-1 w-full rounded-xl border px-3 py-2"></label>
<label class="text-sm md:col-span-2">Message<textarea name="message" rows="4" class="mt-1 w-full rounded-xl border px-3 py-2"></textarea></label>
<div class="md:col-span-2"><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Submit Request</button></div>
</form>
</div>
{% endblock %}
@@ -0,0 +1,61 @@
{% extends "modules/marketplace/templates/marketplace/public_base.html" %}
{% block public_content %}
<section class="bg-gradient-to-br from-slate-950 via-slate-900 to-brand-900 text-white">
<div class="mx-auto grid max-w-7xl gap-10 px-4 py-16 sm:px-6 lg:grid-cols-[1.15fr_0.85fr] lg:px-8 lg:py-24">
<div>
<div class="inline-flex rounded-full border border-white/15 bg-white/10 px-4 py-1 text-xs font-semibold uppercase tracking-[0.24em] text-brand-100">FilingABC Marketplace</div>
<h1 class="mt-6 max-w-3xl text-4xl font-bold tracking-tight sm:text-5xl">Find audit, GST, income tax, ROC and accounting support from trusted professionals.</h1>
<p class="mt-5 max-w-2xl text-base leading-8 text-slate-200">Clients can request services, consultants can generate leads, and the platform team can route work to the right audit firm or partner.</p>
<div class="mt-8 flex flex-wrap gap-3">
<a href="/request-service" class="rounded-2xl bg-white px-5 py-3 text-sm font-bold text-slate-900 shadow-soft">Request Professional Service</a>
<a href="/audit-firms" class="rounded-2xl border border-white/20 px-5 py-3 text-sm font-bold text-white hover:bg-white/10">Browse Audit Firms</a>
</div>
</div>
<div class="rounded-3xl border border-white/10 bg-white/10 p-6 shadow-2xl backdrop-blur">
<div class="grid gap-4 sm:grid-cols-3 lg:grid-cols-1">
<div class="rounded-2xl bg-white/10 p-5"><div class="text-3xl font-bold">{{ marketplace_counts.audit_firms }}</div><div class="mt-1 text-sm text-slate-200">Audit firms</div></div>
<div class="rounded-2xl bg-white/10 p-5"><div class="text-3xl font-bold">{{ marketplace_counts.consultants }}</div><div class="mt-1 text-sm text-slate-200">Consultants</div></div>
<div class="rounded-2xl bg-white/10 p-5"><div class="text-3xl font-bold">{{ marketplace_counts.service_requests }}</div><div class="mt-1 text-sm text-slate-200">Service requests</div></div>
</div>
</div>
</div>
</section>
<section class="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
<div class="grid gap-6 md:grid-cols-3">
{% for item in [
('GST & Tax Compliance', 'GSTR filing, notices, reconciliation, tax return support and advisory.'),
('Audit & Assurance', 'Statutory audit, internal audit, tax audit and engagement tracking.'),
('ROC & Business Services', 'Company filings, registrations, payroll, accounting and compliance calendars.')
] %}
<div class="rounded-3xl border border-slate-200 bg-white p-6 shadow-soft">
<h2 class="text-lg font-bold text-slate-900">{{ item[0] }}</h2>
<p class="mt-3 text-sm leading-6 text-slate-600">{{ item[1] }}</p>
</div>
{% endfor %}
</div>
</section>
<section class="mx-auto grid max-w-7xl gap-8 px-4 pb-16 sm:px-6 lg:grid-cols-2 lg:px-8">
<div class="rounded-3xl border border-slate-200 bg-white p-6 shadow-soft">
<div class="flex items-center justify-between"><h2 class="text-xl font-bold">Featured Audit Firms</h2><a href="/audit-firms" class="text-sm font-semibold text-brand-700">View all</a></div>
<div class="mt-5 space-y-3">
{% for firm in public_audit_firms %}
<div class="rounded-2xl border border-slate-100 bg-slate-50 p-4"><div class="font-semibold">{{ firm.name }}</div><div class="text-xs text-slate-500">{{ firm.firm_type|replace('_',' ')|title if firm.firm_type else 'Audit Firm' }}</div></div>
{% else %}
<div class="rounded-2xl border border-dashed border-slate-300 p-4 text-sm text-slate-500">Audit firm listings will appear here after activation.</div>
{% endfor %}
</div>
</div>
<div class="rounded-3xl border border-slate-200 bg-white p-6 shadow-soft">
<div class="flex items-center justify-between"><h2 class="text-xl font-bold">Consultant Network</h2><a href="/consultants" class="text-sm font-semibold text-brand-700">View all</a></div>
<div class="mt-5 space-y-3">
{% for consultant in public_consultants %}
<div class="rounded-2xl border border-slate-100 bg-slate-50 p-4"><div class="font-semibold">{{ consultant.firm_name or consultant.contact_person }}</div><div class="text-xs text-slate-500">{{ consultant.specialisation or consultant.consultant_type|replace('_',' ')|title }}</div></div>
{% else %}
<div class="rounded-2xl border border-dashed border-slate-300 p-4 text-sm text-slate-500">Consultant listings will appear here after activation.</div>
{% endfor %}
</div>
</div>
</section>
{% endblock %}
@@ -0,0 +1,22 @@
{% extends "modules/marketplace/templates/marketplace/public_base.html" %}
{% block public_content %}
<section class="mx-auto max-w-4xl px-4 py-12 sm:px-6 lg:px-8">
<div class="rounded-3xl border border-slate-200 bg-white p-6 shadow-soft sm:p-8">
<h1 class="text-3xl font-bold">Request Professional Service</h1>
<p class="mt-2 text-sm text-slate-600">Submit your requirement. The marketplace team will review and route it to the right audit firm or consultant.</p>
<form method="post" class="mt-6 grid gap-4 md:grid-cols-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label class="text-sm font-medium">Your Name<input name="lead_name" required class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></label>
<label class="text-sm font-medium">Business Name<input name="business_name" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></label>
<label class="text-sm font-medium">Email<input name="email" type="email" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></label>
<label class="text-sm font-medium">Mobile<input name="mobile" required class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></label>
<label class="text-sm font-medium">City<input name="city" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></label>
<label class="text-sm font-medium">State<input name="state" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></label>
<label class="text-sm font-medium">Category<select name="service_category" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"><option value="">Select</option>{% for c in service_categories %}<option value="{{ c }}">{{ c }}</option>{% endfor %}</select></label>
<label class="text-sm font-medium">Service Required<input name="service_requested" required placeholder="GST notice reply, ITR filing, audit..." class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></label>
<label class="text-sm font-medium md:col-span-2">Message<textarea name="message" rows="4" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></textarea></label>
<div class="md:col-span-2"><button class="rounded-xl bg-brand-600 px-5 py-3 text-sm font-bold text-white shadow-soft">Submit Request</button></div>
</form>
</div>
</section>
{% endblock %}
@@ -0,0 +1,12 @@
{% extends "modules/marketplace/templates/marketplace/public_base.html" %}
{% block public_content %}
<section class="mx-auto max-w-2xl px-4 py-16 sm:px-6 lg:px-8">
<div class="rounded-3xl border border-emerald-200 bg-white p-8 text-center shadow-soft">
<div class="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-emerald-100 text-2xl text-emerald-700"></div>
<h1 class="mt-5 text-3xl font-bold">Thank you</h1>
<p class="mt-3 text-slate-600">Your service request has been received by the marketplace team.</p>
<p class="mt-4 rounded-2xl bg-slate-50 px-4 py-3 text-sm text-slate-600">Reference: <span class="font-bold text-slate-900">{{ lead.lead_no }}</span></p>
<div class="mt-6"><a href="/" class="rounded-xl bg-brand-600 px-5 py-3 text-sm font-bold text-white">Back to Home</a></div>
</div>
</section>
{% endblock %}
@@ -0,0 +1,8 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="mx-auto max-w-xl rounded-2xl border border-slate-200 bg-white p-8 text-center shadow-soft">
<h1 class="text-2xl font-semibold">Thank you</h1>
<p class="mt-3 text-slate-600">Your service request has been received.</p>
<p class="mt-2 text-sm text-slate-500">Reference: <span class="font-semibold text-slate-800">{{ lead.lead_no }}</span></p>
</div>
{% endblock %}
+343
View File
@@ -0,0 +1,343 @@
from __future__ import annotations
from fastapi import APIRouter, Form, Request
from fastapi.responses import RedirectResponse
from app.core.db.common import CommonSessionLocal
from app.core.security.csrf import get_or_create_csrf_token, validate_csrf
from app.core.security.session_auth import get_current_user
from app.core.templating import templates
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
from app.modules.core.rbac.permission_guard import require_permission
from app.modules.marketplace.services import (
LEAD_PRIORITIES,
LEAD_SOURCES,
LEAD_STATUSES,
SERVICE_CATEGORIES,
assign_marketplace_lead,
convert_lead_to_client,
create_marketplace_lead,
get_marketplace_lead,
get_marketplace_overview_counts,
is_marketplace_domain_request,
list_marketplace_leads,
list_public_marketplace_audit_firms,
list_public_marketplace_consultants,
list_reference_audit_firms,
list_reference_branches,
list_reference_partners,
update_lead_status,
)
router = APIRouter(prefix="/marketplace", tags=["marketplace-ui"])
public_router = APIRouter(tags=["marketplace-public-ui"])
def _public_marketplace_ctx(request: Request, db, **ctx):
base = {
"request": request,
"current_user": None,
"current_user_roles": [],
"current_user_permissions": [],
"csrf_token": get_or_create_csrf_token(request),
"service_categories": SERVICE_CATEGORIES,
"lead_priorities": LEAD_PRIORITIES,
"lead_sources": LEAD_SOURCES,
"marketplace_counts": get_marketplace_overview_counts(db),
"public_audit_firms": list_public_marketplace_audit_firms(db, limit=8),
"public_consultants": list_public_marketplace_consultants(db, limit=8),
}
base.update(ctx)
return base
def _render_public_marketplace(request: Request, template: str, db, **ctx):
return templates.TemplateResponse(template, _public_marketplace_ctx(request, db, **ctx))
@public_router.get("/")
def marketplace_domain_home(request: Request):
"""Marketplace domain landing page.
Only marketplace-mapped domains use this as the public home page. Normal localhost,
tenant domains and consultant domains continue to the normal login flow.
"""
if not is_marketplace_domain_request(request):
return RedirectResponse(url="/login", status_code=303)
db = CommonSessionLocal()
try:
return _render_public_marketplace(
request,
"modules/marketplace/templates/marketplace/public_marketplace_home.html",
db,
title="FilingABC Marketplace",
)
finally:
db.close()
@public_router.get("/audit-firms")
def marketplace_public_audit_firms(request: Request):
if not is_marketplace_domain_request(request):
return RedirectResponse(url="/login", status_code=303)
db = CommonSessionLocal()
try:
return _render_public_marketplace(
request,
"modules/marketplace/templates/marketplace/public_audit_firms.html",
db,
title="Audit Firms",
public_audit_firms=list_public_marketplace_audit_firms(db, limit=50),
)
finally:
db.close()
@public_router.get("/consultants")
def marketplace_public_consultants(request: Request):
if not is_marketplace_domain_request(request):
return RedirectResponse(url="/login", status_code=303)
db = CommonSessionLocal()
try:
return _render_public_marketplace(
request,
"modules/marketplace/templates/marketplace/public_consultants.html",
db,
title="Consultants",
public_consultants=list_public_marketplace_consultants(db, limit=50),
)
finally:
db.close()
@public_router.get("/request-service")
def marketplace_public_request_service(request: Request):
if not is_marketplace_domain_request(request):
return RedirectResponse(url="/marketplace/public-lead", status_code=303)
db = CommonSessionLocal()
try:
return _render_public_marketplace(
request,
"modules/marketplace/templates/marketplace/public_marketplace_lead_form.html",
db,
title="Request a Service",
)
finally:
db.close()
@public_router.post("/request-service")
def marketplace_public_request_service_submit(request: Request, csrf_token: str = Form(...), lead_name: str = Form(...), business_name: str = Form(""), email: str = Form(""), mobile: str = Form(""), city: str = Form(""), state: str = Form(""), service_category: str = Form(""), service_requested: str = Form(...), message: str = Form("")):
if not is_marketplace_domain_request(request):
return RedirectResponse(url="/marketplace/public-lead", status_code=303)
db = CommonSessionLocal()
try:
validate_csrf(request, csrf_token)
lead = create_marketplace_lead(
db,
lead_name=lead_name,
business_name=business_name,
email=email,
mobile=mobile,
city=city,
state=state,
service_category=service_category,
service_requested=service_requested,
message=message,
source="public_website",
priority="NORMAL",
)
return _render_public_marketplace(
request,
"modules/marketplace/templates/marketplace/public_marketplace_thank_you.html",
db,
title="Thank You",
lead=lead,
)
finally:
db.close()
def _redirect_denied():
return RedirectResponse(url="/system-settings", status_code=303)
def _has_perm(db, user, code: str) -> bool:
try:
require_permission(db, user, code)
return True
except Exception:
return False
def _require_user(request: Request, db, permission_code: str):
user = get_current_user(request, db=db)
if not user:
return None, RedirectResponse(url="/login", status_code=303)
try:
require_permission(db, user, permission_code)
except Exception:
# Assigned lead users are allowed if they have assigned-lead permission.
if permission_code == "marketplace_leads.view" and _has_perm(db, user, "marketplace_leads.view_assigned"):
return user, None
return user, _redirect_denied()
return user, None
def _base_ctx(request: Request, db, user=None, **ctx):
base = {
"request": request,
"current_user": user,
"current_user_roles": get_user_roles(db, user.id) if user else [],
"current_user_permissions": get_user_permissions(db, user.id) if user else [],
"csrf_token": get_or_create_csrf_token(request),
"lead_statuses": LEAD_STATUSES,
"lead_priorities": LEAD_PRIORITIES,
"lead_sources": LEAD_SOURCES,
"service_categories": SERVICE_CATEGORIES,
}
base.update(ctx)
return base
def _render(request: Request, template: str, db, user=None, **ctx):
return templates.TemplateResponse(template, _base_ctx(request, db, user, **ctx))
@router.get("")
def dashboard(request: Request):
db = CommonSessionLocal()
try:
user, response = _require_user(request, db, "marketplace_leads.view")
if response:
return response
roles = get_user_roles(db, user.id)
leads = list_marketplace_leads(db, user=user, role_names=roles)
return _render(request, "modules/marketplace/templates/marketplace/dashboard.html", db, user, title="Marketplace Leads", leads=leads[:8], total=len(leads), can_create=_has_perm(db, user, "marketplace_leads.create"), can_assign=_has_perm(db, user, "marketplace_leads.assign"))
finally:
db.close()
@router.get("/leads")
def leads_list(request: Request, q: str = "", status: str = ""):
db = CommonSessionLocal()
try:
user, response = _require_user(request, db, "marketplace_leads.view")
if response:
return response
roles = get_user_roles(db, user.id)
return _render(request, "modules/marketplace/templates/marketplace/leads_list.html", db, user, title="Marketplace Leads", rows=list_marketplace_leads(db, q=q, status=status, user=user, role_names=roles), q=q, selected_status=status, can_create=_has_perm(db, user, "marketplace_leads.create"), can_assign=_has_perm(db, user, "marketplace_leads.assign"))
finally:
db.close()
@router.get("/leads/new")
def lead_new(request: Request):
db = CommonSessionLocal()
try:
user, response = _require_user(request, db, "marketplace_leads.create")
if response:
return response
return _render(request, "modules/marketplace/templates/marketplace/lead_form.html", db, user, title="New Marketplace Lead", public_mode=False)
finally:
db.close()
@router.post("/leads/new")
def lead_create(request: Request, csrf_token: str = Form(...), lead_name: str = Form(...), business_name: str = Form(""), email: str = Form(""), mobile: str = Form(""), city: str = Form(""), state: str = Form(""), service_category: str = Form(""), service_requested: str = Form(...), message: str = Form(""), source: str = Form("manual"), priority: str = Form("NORMAL"), estimated_value: str = Form("0")):
db = CommonSessionLocal()
try:
user, response = _require_user(request, db, "marketplace_leads.create")
if response:
return response
validate_csrf(request, csrf_token)
lead = create_marketplace_lead(db, lead_name=lead_name, business_name=business_name, email=email, mobile=mobile, city=city, state=state, service_category=service_category, service_requested=service_requested, message=message, source=source, priority=priority, estimated_value=estimated_value, created_by_user_id=user.id)
return RedirectResponse(url=f"/marketplace/leads/{lead.id}", status_code=303)
finally:
db.close()
@router.get("/public-lead")
def public_lead_form(request: Request):
db = CommonSessionLocal()
try:
return _render(request, "modules/marketplace/templates/marketplace/public_lead_form.html", db, None, title="Request a Service")
finally:
db.close()
@router.post("/public-lead")
def public_lead_submit(request: Request, csrf_token: str = Form(...), lead_name: str = Form(...), business_name: str = Form(""), email: str = Form(""), mobile: str = Form(""), city: str = Form(""), state: str = Form(""), service_category: str = Form(""), service_requested: str = Form(...), message: str = Form("")):
db = CommonSessionLocal()
try:
validate_csrf(request, csrf_token)
lead = create_marketplace_lead(db, lead_name=lead_name, business_name=business_name, email=email, mobile=mobile, city=city, state=state, service_category=service_category, service_requested=service_requested, message=message, source="public_website", priority="NORMAL")
return _render(request, "modules/marketplace/templates/marketplace/public_thank_you.html", db, None, title="Thank You", lead=lead)
finally:
db.close()
@router.get("/leads/{lead_id}")
def lead_detail(request: Request, lead_id: int):
db = CommonSessionLocal()
try:
user, response = _require_user(request, db, "marketplace_leads.view")
if response:
return response
lead = get_marketplace_lead(db, lead_id)
if not lead:
return RedirectResponse(url="/marketplace/leads", status_code=303)
return _render(request, "modules/marketplace/templates/marketplace/lead_detail.html", db, user, title=f"Lead {lead.lead_no}", lead=lead, audit_firms=list_reference_audit_firms(db), branches=list_reference_branches(db, lead.assigned_tenant_id), partners=list_reference_partners(db, lead.assigned_tenant_id), can_assign=_has_perm(db, user, "marketplace_leads.assign"), can_update=_has_perm(db, user, "marketplace_leads.update"), can_convert=_has_perm(db, user, "marketplace_leads.convert"))
finally:
db.close()
@router.post("/leads/{lead_id}/assign")
def lead_assign(request: Request, lead_id: int, csrf_token: str = Form(...), tenant_id: int = Form(...), branch_id: int = Form(0), partner_user_id: int = Form(0), notes: str = Form("")):
db = CommonSessionLocal()
try:
user, response = _require_user(request, db, "marketplace_leads.assign")
if response:
return response
validate_csrf(request, csrf_token)
lead = get_marketplace_lead(db, lead_id)
if lead:
assign_marketplace_lead(db, lead=lead, tenant_id=tenant_id, branch_id=branch_id or None, partner_user_id=partner_user_id or None, notes=notes, assigned_by_user_id=user.id)
return RedirectResponse(url=f"/marketplace/leads/{lead_id}", status_code=303)
finally:
db.close()
@router.post("/leads/{lead_id}/status")
def lead_status_update(request: Request, lead_id: int, csrf_token: str = Form(...), status: str = Form(...)):
db = CommonSessionLocal()
try:
user, response = _require_user(request, db, "marketplace_leads.update")
if response:
return response
validate_csrf(request, csrf_token)
lead = get_marketplace_lead(db, lead_id)
if lead:
update_lead_status(db, lead=lead, status=status, user_id=user.id)
return RedirectResponse(url=f"/marketplace/leads/{lead_id}", status_code=303)
finally:
db.close()
@router.post("/leads/{lead_id}/convert-client")
def lead_convert_client(request: Request, lead_id: int, csrf_token: str = Form(...), tenant_id: int = Form(...), branch_id: int = Form(...), partner_user_id: int = Form(0), client_code: str = Form("")):
db = CommonSessionLocal()
try:
user, response = _require_user(request, db, "marketplace_leads.convert")
if response:
return response
validate_csrf(request, csrf_token)
lead = get_marketplace_lead(db, lead_id)
if lead:
client = convert_lead_to_client(db, lead=lead, tenant_id=tenant_id, branch_id=branch_id, partner_user_id=partner_user_id or None, client_code=client_code, user_id=user.id)
return RedirectResponse(url=f"/clients/{client.id}", status_code=303)
return RedirectResponse(url="/marketplace/leads", status_code=303)
finally:
db.close()