Add client groups and family tracking

This commit is contained in:
A R R R Associates
2026-07-25 22:32:35 +05:30
parent 5e6584702c
commit 2dc2a712e8
21 changed files with 444 additions and 161 deletions
+32
View File
@@ -0,0 +1,32 @@
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db.common import CommonBase
class ClientGroup(CommonBase):
__tablename__ = "client_groups"
__table_args__ = (
UniqueConstraint("tenant_id", "group_code", name="uq_client_groups_tenant_code"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
group_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
group_name: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
group_type: Mapped[str] = mapped_column(String(50), nullable=False, default="Family", index=True)
primary_contact_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
primary_contact_mobile: Mapped[str | None] = mapped_column(String(20), nullable=True)
primary_contact_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
assigned_partner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
primary_consultant_id: Mapped[int | None] = mapped_column(ForeignKey("consultant_profiles.id", ondelete="SET NULL"), nullable=True, index=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), 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)
+105
View File
@@ -0,0 +1,105 @@
from __future__ import annotations
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.modules.client_groups.models import ClientGroup
from app.modules.clients.models import Client
GROUP_TYPES = ("Family", "Business Group", "Promoter Group", "Trust Group", "Common Management", "Other")
def normalise_group_code(value: str | None) -> str:
return (value or "").strip().upper()
def list_groups(db: Session, *, tenant_id: int, include_inactive: bool = False):
stmt = (
select(ClientGroup, func.count(Client.id).label("client_count"))
.outerjoin(Client, Client.client_group_id == ClientGroup.id)
.where(ClientGroup.tenant_id == tenant_id)
.group_by(ClientGroup.id)
.order_by(ClientGroup.group_name.asc())
)
if not include_inactive:
stmt = stmt.where(ClientGroup.is_active.is_(True))
return [{"group": group, "client_count": int(count or 0)} for group, count in db.execute(stmt).all()]
def get_group(db: Session, *, tenant_id: int, group_id: int):
return db.execute(select(ClientGroup).where(ClientGroup.id == group_id, ClientGroup.tenant_id == tenant_id)).scalar_one_or_none()
def get_group_by_code(db: Session, *, tenant_id: int, group_code: str):
code = normalise_group_code(group_code)
if not code:
return None
return db.execute(select(ClientGroup).where(ClientGroup.tenant_id == tenant_id, ClientGroup.group_code == code)).scalar_one_or_none()
def list_group_clients(db: Session, *, tenant_id: int, group_id: int):
return db.execute(select(Client).where(Client.tenant_id == tenant_id, Client.client_group_id == group_id, Client.is_archived.is_(False)).order_by(Client.is_group_head.desc(), Client.client_name.asc())).scalars().all()
def create_group(db: Session, *, tenant_id: int, actor_user_id: int, payload: dict):
code = normalise_group_code(payload.get("group_code"))
name = (payload.get("group_name") or "").strip()
if not code or not name:
raise ValueError("Group code and group name are required.")
if get_group_by_code(db, tenant_id=tenant_id, group_code=code):
raise ValueError("Client group code already exists in this firm.")
group_type = (payload.get("group_type") or "Family").strip()
if group_type not in GROUP_TYPES:
raise ValueError("Invalid group type.")
row = ClientGroup(
tenant_id=tenant_id, group_code=code, group_name=name, group_type=group_type,
primary_contact_name=(payload.get("primary_contact_name") or "").strip() or None,
primary_contact_mobile=(payload.get("primary_contact_mobile") or "").strip() or None,
primary_contact_email=(payload.get("primary_contact_email") or "").strip().lower() or None,
assigned_partner_user_id=payload.get("assigned_partner_user_id") or None,
primary_consultant_id=payload.get("primary_consultant_id") or None,
notes=(payload.get("notes") or "").strip() or None,
is_active=bool(payload.get("is_active", True)), created_by_user_id=actor_user_id, updated_by_user_id=actor_user_id,
)
db.add(row); db.commit(); db.refresh(row); return row
def update_group(db: Session, *, row: ClientGroup, actor_user_id: int, payload: dict):
code = normalise_group_code(payload.get("group_code"))
name = (payload.get("group_name") or "").strip()
if not code or not name:
raise ValueError("Group code and group name are required.")
duplicate = get_group_by_code(db, tenant_id=row.tenant_id, group_code=code)
if duplicate and duplicate.id != row.id:
raise ValueError("Client group code already exists in this firm.")
group_type = (payload.get("group_type") or "Family").strip()
if group_type not in GROUP_TYPES:
raise ValueError("Invalid group type.")
for key, value in {
"group_code": code, "group_name": name, "group_type": group_type,
"primary_contact_name": (payload.get("primary_contact_name") or "").strip() or None,
"primary_contact_mobile": (payload.get("primary_contact_mobile") or "").strip() or None,
"primary_contact_email": (payload.get("primary_contact_email") or "").strip().lower() or None,
"assigned_partner_user_id": payload.get("assigned_partner_user_id") or None,
"primary_consultant_id": payload.get("primary_consultant_id") or None,
"notes": (payload.get("notes") or "").strip() or None,
"is_active": bool(payload.get("is_active", False)), "updated_by_user_id": actor_user_id,
}.items(): setattr(row, key, value)
db.add(row); db.commit(); db.refresh(row); return row
def resolve_or_create_group(db: Session, *, tenant_id: int, actor_user_id: int, group_code: str | None, group_name: str | None, group_type: str | None = None):
code = normalise_group_code(group_code)
name = (group_name or "").strip()
if not code and not name:
return None
if not code:
raise ValueError("client_group_code is required when client_group_name is supplied.")
existing = get_group_by_code(db, tenant_id=tenant_id, group_code=code)
if existing:
if name and existing.group_name.strip().lower() != name.lower():
raise ValueError(f"Client group code {code} already exists with name {existing.group_name}.")
return existing
if not name:
raise ValueError(f"Client group {code} does not exist; provide client_group_name to create it.")
return create_group(db, tenant_id=tenant_id, actor_user_id=actor_user_id, payload={"group_code": code, "group_name": name, "group_type": group_type or "Family", "is_active": True})
@@ -0,0 +1 @@
{% extends "ui/templates/base/layout.html" %}{% block content %}<div class="space-y-6"><div class="flex items-center justify-between"><div><div class="text-sm text-slate-500">{{ row.group_code }} · {{ row.group_type }}</div><h2 class="text-2xl font-semibold">{{ row.group_name }}</h2></div><a href="/client-groups/{{ row.id }}/edit" class="rounded-xl border px-4 py-2 text-sm">Edit Group</a></div><div class="grid gap-4 md:grid-cols-3"><div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs uppercase text-slate-500">Primary contact</div><div class="mt-2 font-medium">{{ row.primary_contact_name or '-' }}</div><div class="text-sm text-slate-500">{{ row.primary_contact_mobile or '' }} {{ row.primary_contact_email or '' }}</div></div><div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs uppercase text-slate-500">Members</div><div class="mt-2 text-3xl font-semibold">{{ clients|length }}</div></div><div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs uppercase text-slate-500">Status</div><div class="mt-2 font-medium">{{ 'Active' if row.is_active else 'Inactive' }}</div></div></div><div class="overflow-hidden rounded-2xl bg-white shadow-soft"><table class="min-w-full divide-y divide-slate-200"><thead class="bg-slate-50"><tr><th class="px-4 py-3 text-left text-xs uppercase text-slate-500">Client</th><th class="px-4 py-3 text-left text-xs uppercase text-slate-500">Relationship</th><th class="px-4 py-3 text-left text-xs uppercase text-slate-500">PAN</th><th></th></tr></thead><tbody class="divide-y divide-slate-100">{% for client in clients %}<tr><td class="px-4 py-3 text-sm"><div class="font-medium">{{ client.client_name }}</div><div class="text-xs text-slate-500">{{ client.client_code }}{% if client.is_group_head %} · Group Head{% endif %}</div></td><td class="px-4 py-3 text-sm">{{ client.group_relationship or '-' }}</td><td class="px-4 py-3 text-sm">{{ client.pan or '-' }}</td><td class="px-4 py-3 text-right"><a href="/clients/{{ client.id }}" class="text-brand-700">Open</a></td></tr>{% else %}<tr><td colspan="4" class="px-4 py-8 text-center text-sm text-slate-500">No clients linked.</td></tr>{% endfor %}</tbody></table></div></div>{% endblock %}
@@ -0,0 +1 @@
{% extends "ui/templates/base/layout.html" %}{% block content %}<div class="mx-auto max-w-5xl space-y-6"><div><h2 class="text-2xl font-semibold">{{ title }}</h2></div>{% for error in errors %}<div class="rounded-xl bg-rose-50 px-4 py-3 text-sm text-rose-700">{{ error }}</div>{% endfor %}<form method="post" action="{% if row and row.id %}/client-groups/{{ row.id }}/edit{% else %}/client-groups{% endif %}" class="space-y-6 rounded-2xl bg-white p-6 shadow-soft"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><div class="grid gap-5 md:grid-cols-2"><label class="text-sm font-medium">Group Code<input name="group_code" required value="{{ row.group_code if row else '' }}" class="mt-1 w-full rounded-xl border px-4 py-2"></label><label class="text-sm font-medium">Group Name<input name="group_name" required value="{{ row.group_name if row else '' }}" class="mt-1 w-full rounded-xl border px-4 py-2"></label><label class="text-sm font-medium">Group Type<select name="group_type" class="mt-1 w-full rounded-xl border px-4 py-2">{% for value in group_types %}<option {% if row and row.group_type == value %}selected{% endif %}>{{ value }}</option>{% endfor %}</select></label><label class="text-sm font-medium">Primary Contact<input name="primary_contact_name" value="{{ row.primary_contact_name if row else '' }}" class="mt-1 w-full rounded-xl border px-4 py-2"></label><label class="text-sm font-medium">Mobile<input name="primary_contact_mobile" value="{{ row.primary_contact_mobile if row else '' }}" class="mt-1 w-full rounded-xl border px-4 py-2"></label><label class="text-sm font-medium">Email<input type="email" name="primary_contact_email" value="{{ row.primary_contact_email if row else '' }}" class="mt-1 w-full rounded-xl border px-4 py-2"></label><label class="text-sm font-medium">Assigned Partner<select name="assigned_partner_user_id" class="mt-1 w-full rounded-xl border px-4 py-2"><option value="">-- None --</option>{% for p in partners %}<option value="{{ p.id }}" {% if row and row.assigned_partner_user_id == p.id %}selected{% endif %}>{{ p.full_name or p.email }}</option>{% endfor %}</select></label><label class="text-sm font-medium">Primary Consultant<select name="primary_consultant_id" class="mt-1 w-full rounded-xl border px-4 py-2"><option value="">-- None --</option>{% for c in consultants %}<option value="{{ c.id }}" {% if row and row.primary_consultant_id == c.id %}selected{% endif %}>{{ c.contact_person }}{% if c.firm_name %} — {{ c.firm_name }}{% endif %}</option>{% endfor %}</select></label></div><label class="block text-sm font-medium">Notes<textarea name="notes" rows="4" class="mt-1 w-full rounded-xl border px-4 py-2">{{ row.notes if row else '' }}</textarea></label><label class="inline-flex items-center gap-2"><input type="checkbox" name="is_active" {% if not row or row.is_active %}checked{% endif %}> Active</label><div><button class="rounded-xl bg-brand-600 px-5 py-2 text-white">Save Group</button></div></form></div>{% endblock %}
@@ -0,0 +1 @@
{% extends "ui/templates/base/layout.html" %}{% block content %}<div class="space-y-6"><div class="flex items-center justify-between"><div><h2 class="text-2xl font-semibold text-slate-900">Client Groups</h2><p class="text-sm text-slate-500">Track family, promoter and related business clients together.</p></div><a href="/client-groups/new" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white">Add Group</a></div><div class="overflow-hidden rounded-2xl bg-white shadow-soft"><table class="min-w-full divide-y divide-slate-200"><thead class="bg-slate-50"><tr><th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Code</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Group</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Type</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Clients</th><th></th></tr></thead><tbody class="divide-y divide-slate-100">{% for item in groups %}<tr><td class="px-4 py-3 text-sm font-medium">{{ item.group.group_code }}</td><td class="px-4 py-3 text-sm"><div class="font-medium">{{ item.group.group_name }}</div><div class="text-xs text-slate-500">{{ item.group.primary_contact_name or '-' }}</div></td><td class="px-4 py-3 text-sm">{{ item.group.group_type }}</td><td class="px-4 py-3 text-sm">{{ item.client_count }}</td><td class="px-4 py-3 text-right"><a class="text-brand-700" href="/client-groups/{{ item.group.id }}">Open</a></td></tr>{% else %}<tr><td colspan="5" class="px-4 py-8 text-center text-sm text-slate-500">No client groups created.</td></tr>{% endfor %}</tbody></table></div></div>{% endblock %}
+91
View File
@@ -0,0 +1,91 @@
from __future__ import annotations
from fastapi import APIRouter, Request
from fastapi.responses import RedirectResponse
from app.core.db import CommonSessionLocal
from app.core.security.csrf import get_or_create_csrf_token, validate_csrf
from app.core.security.deps import get_current_user
from app.core.templating import templates
from app.modules.core.rbac.service import get_user_permissions, get_user_roles, require_permission
from app.modules.client_groups.service import GROUP_TYPES, create_group, get_group, list_group_clients, list_groups, update_group
from app.modules.clients.access import build_scope
from app.modules.clients import repository
from app.modules.consultants.service import list_consultants
router = APIRouter(prefix="/client-groups", tags=["client-groups-ui"])
def _ctx(request, user, db, **extra):
data={"request":request,"current_user":user,"current_user_roles":get_user_roles(db,user.id),"current_user_permissions":get_user_permissions(db,user.id),"csrf_token":get_or_create_csrf_token(request)}; data.update(extra); return data
def _payload(form):
def opt_int(name):
value=form.get(name); return int(value) if value not in (None,"","None") else None
return {"group_code":form.get("group_code"),"group_name":form.get("group_name"),"group_type":form.get("group_type") or "Family","primary_contact_name":form.get("primary_contact_name"),"primary_contact_mobile":form.get("primary_contact_mobile"),"primary_contact_email":form.get("primary_contact_email"),"assigned_partner_user_id":opt_int("assigned_partner_user_id"),"primary_consultant_id":opt_int("primary_consultant_id"),"notes":form.get("notes"),"is_active":form.get("is_active") in ("1","on","true","yes")}
@router.get("")
def groups_page(request: Request):
db=CommonSessionLocal()
try:
user=get_current_user(request,db=db)
if not user: return RedirectResponse('/login',303)
require_permission(db,user,'clients.view'); scope=build_scope(request,user,lambda code: True)
return templates.TemplateResponse('modules/client_groups/templates/client_groups/list.html',_ctx(request,user,db,title='Client Groups',groups=list_groups(db,tenant_id=scope.tenant_id,include_inactive=True)))
finally: db.close()
@router.get("/new")
def group_new(request: Request):
db=CommonSessionLocal()
try:
user=get_current_user(request,db=db)
if not user: return RedirectResponse('/login',303)
require_permission(db,user,'clients.create'); scope=build_scope(request,user,lambda code: True)
return templates.TemplateResponse('modules/client_groups/templates/client_groups/form.html',_ctx(request,user,db,title='Add Client Group',row=None,group_types=GROUP_TYPES,partners=repository.list_partners_for_scope(db,tenant_id=scope.tenant_id,branch_id=None),consultants=list_consultants(db,tenant_id=scope.tenant_id,include_inactive=False),errors=[]))
finally: db.close()
@router.post("")
async def group_create(request: Request):
db=CommonSessionLocal()
try:
user=get_current_user(request,db=db); form=await request.form(); validate_csrf(request,form.get('csrf_token'))
if not user: return RedirectResponse('/login',303)
require_permission(db,user,'clients.create'); scope=build_scope(request,user,lambda code: True)
try: row=create_group(db,tenant_id=scope.tenant_id,actor_user_id=user.id,payload=_payload(form)); return RedirectResponse(f'/client-groups/{row.id}',303)
except Exception as exc: return templates.TemplateResponse('modules/client_groups/templates/client_groups/form.html',_ctx(request,user,db,title='Add Client Group',row=_payload(form),group_types=GROUP_TYPES,partners=repository.list_partners_for_scope(db,tenant_id=scope.tenant_id,branch_id=None),consultants=list_consultants(db,tenant_id=scope.tenant_id,include_inactive=False),errors=[str(exc)]),status_code=400)
finally: db.close()
@router.get("/{group_id}")
def group_detail(request: Request, group_id:int):
db=CommonSessionLocal()
try:
user=get_current_user(request,db=db)
if not user: return RedirectResponse('/login',303)
require_permission(db,user,'clients.view'); scope=build_scope(request,user,lambda code: True); row=get_group(db,tenant_id=scope.tenant_id,group_id=group_id)
if not row: return RedirectResponse('/client-groups',303)
return templates.TemplateResponse('modules/client_groups/templates/client_groups/detail.html',_ctx(request,user,db,title=row.group_name,row=row,clients=list_group_clients(db,tenant_id=scope.tenant_id,group_id=row.id)))
finally: db.close()
@router.get("/{group_id}/edit")
def group_edit(request: Request, group_id:int):
db=CommonSessionLocal()
try:
user=get_current_user(request,db=db)
if not user: return RedirectResponse('/login',303)
require_permission(db,user,'clients.edit'); scope=build_scope(request,user,lambda code: True); row=get_group(db,tenant_id=scope.tenant_id,group_id=group_id)
if not row: return RedirectResponse('/client-groups',303)
return templates.TemplateResponse('modules/client_groups/templates/client_groups/form.html',_ctx(request,user,db,title='Edit Client Group',row=row,group_types=GROUP_TYPES,partners=repository.list_partners_for_scope(db,tenant_id=scope.tenant_id,branch_id=None),consultants=list_consultants(db,tenant_id=scope.tenant_id,include_inactive=False),errors=[]))
finally: db.close()
@router.post("/{group_id}/edit")
async def group_update(request: Request, group_id:int):
db=CommonSessionLocal()
try:
user=get_current_user(request,db=db); form=await request.form(); validate_csrf(request,form.get('csrf_token'))
if not user: return RedirectResponse('/login',303)
require_permission(db,user,'clients.edit'); scope=build_scope(request,user,lambda code: True); row=get_group(db,tenant_id=scope.tenant_id,group_id=group_id)
if not row: return RedirectResponse('/client-groups',303)
try: update_group(db,row=row,actor_user_id=user.id,payload=_payload(form)); return RedirectResponse(f'/client-groups/{row.id}',303)
except Exception as exc: return templates.TemplateResponse('modules/client_groups/templates/client_groups/form.html',_ctx(request,user,db,title='Edit Client Group',row=row,group_types=GROUP_TYPES,partners=repository.list_partners_for_scope(db,tenant_id=scope.tenant_id,branch_id=None),consultants=list_consultants(db,tenant_id=scope.tenant_id,include_inactive=False),errors=[str(exc)]),status_code=400)
finally: db.close()