238 lines
10 KiB
Python
238 lines
10 KiB
Python
from collections import Counter
|
||
from dateutil.relativedelta import relativedelta
|
||
from odoo import api, fields, models
|
||
|
||
|
||
def _float_to_time(f):
|
||
if not f:
|
||
return "—"
|
||
h = int(f)
|
||
m = int(round((f % 1) * 60))
|
||
return f"{h:02d}:{m:02d}"
|
||
|
||
|
||
class DanceDashboard(models.Model):
|
||
_name = "dance.dashboard"
|
||
_description = "Dance Dashboard"
|
||
|
||
name = fields.Char(default="Dance Dashboard", required=True)
|
||
|
||
# ── Computed fields kept for legacy form view ──────────────────────────────
|
||
total_students = fields.Integer(compute="_compute_kpis")
|
||
active_batches = fields.Integer(compute="_compute_kpis")
|
||
active_enrollments = fields.Integer(compute="_compute_kpis")
|
||
dropped_enrollments = fields.Integer(compute="_compute_kpis")
|
||
monthly_revenue = fields.Monetary(compute="_compute_kpis", currency_field="currency_id")
|
||
pending_fees = fields.Monetary(compute="_compute_kpis", currency_field="currency_id")
|
||
currency_id = fields.Many2one("res.currency", compute="_compute_kpis")
|
||
|
||
@api.depends_context("company")
|
||
def _compute_kpis(self):
|
||
today = fields.Date.context_today(self)
|
||
month_start = today.replace(day=1)
|
||
company = self.env.company
|
||
invoice_model = self.env["account.move"]
|
||
|
||
paid_this_month = invoice_model.search([
|
||
("move_type", "=", "out_invoice"),
|
||
("state", "=", "posted"),
|
||
("payment_state", "=", "paid"),
|
||
("dance_student_id", "!=", False),
|
||
("invoice_date", ">=", month_start),
|
||
("invoice_date", "<=", today),
|
||
("company_id", "=", company.id),
|
||
])
|
||
pending_invoices = invoice_model.search([
|
||
("move_type", "=", "out_invoice"),
|
||
("state", "=", "posted"),
|
||
("payment_state", "in", ["not_paid", "partial"]),
|
||
("dance_student_id", "!=", False),
|
||
("company_id", "=", company.id),
|
||
])
|
||
|
||
values = {
|
||
"total_students": self.env["res.partner"].search_count([("is_student", "=", True)]),
|
||
"active_batches": self.env["dance.batch"].search_count([("status", "=", "active")]),
|
||
"active_enrollments": self.env["dance.enrollment"].search_count([("status", "=", "active")]),
|
||
"dropped_enrollments": self.env["dance.enrollment"].search_count([("status", "=", "dropped")]),
|
||
"monthly_revenue": sum(paid_this_month.mapped("amount_total")),
|
||
"pending_fees": sum(pending_invoices.mapped("amount_residual")),
|
||
"currency_id": company.currency_id.id,
|
||
}
|
||
for dashboard in self:
|
||
dashboard.update(values)
|
||
|
||
# ── Client-action data endpoint ────────────────────────────────────────────
|
||
|
||
@api.model
|
||
def get_dashboard_data(self):
|
||
today = fields.Date.context_today(self)
|
||
company = self.env.company
|
||
invoice_model = self.env["account.move"]
|
||
|
||
# ── Core KPIs ──────────────────────────────────────────────────────────
|
||
month_start = today.replace(day=1)
|
||
|
||
paid_this_month = invoice_model.search([
|
||
("move_type", "=", "out_invoice"),
|
||
("state", "=", "posted"),
|
||
("payment_state", "=", "paid"),
|
||
("dance_student_id", "!=", False),
|
||
("invoice_date", ">=", month_start),
|
||
("invoice_date", "<=", today),
|
||
("company_id", "=", company.id),
|
||
])
|
||
pending_invoices = invoice_model.search([
|
||
("move_type", "=", "out_invoice"),
|
||
("state", "=", "posted"),
|
||
("payment_state", "in", ["not_paid", "partial"]),
|
||
("dance_student_id", "!=", False),
|
||
("company_id", "=", company.id),
|
||
])
|
||
|
||
monthly_revenue = sum(paid_this_month.mapped("amount_total"))
|
||
pending_fees = sum(pending_invoices.mapped("amount_residual"))
|
||
currency = company.currency_id
|
||
|
||
def fmt(amount):
|
||
sym = currency.symbol or "$"
|
||
if amount >= 1_000_000:
|
||
return f"{sym}{amount / 1_000_000:.1f}M"
|
||
if amount >= 1_000:
|
||
return f"{sym}{amount / 1_000:.1f}K"
|
||
return f"{sym}{amount:,.0f}"
|
||
|
||
sessions_today = self.env["dance.session"].search_count([
|
||
("date", "=", today),
|
||
("status", "!=", "cancelled"),
|
||
])
|
||
|
||
kpis = {
|
||
"total_students": self.env["res.partner"].search_count([("is_student", "=", True)]),
|
||
"active_batches": self.env["dance.batch"].search_count([("status", "=", "active")]),
|
||
"active_enrollments": self.env["dance.enrollment"].search_count([("status", "=", "active")]),
|
||
"dropped_enrollments": self.env["dance.enrollment"].search_count([("status", "=", "dropped")]),
|
||
"sessions_today": sessions_today,
|
||
"monthly_revenue": monthly_revenue,
|
||
"monthly_revenue_fmt": fmt(monthly_revenue),
|
||
"pending_fees": pending_fees,
|
||
"pending_fees_fmt": fmt(pending_fees),
|
||
"currency_symbol": currency.symbol or "$",
|
||
}
|
||
|
||
return {
|
||
"kpis": kpis,
|
||
"revenue_chart": self._revenue_last_6_months(today, company, invoice_model),
|
||
"attendance_chart": self._attendance_last_30_days(today),
|
||
"style_distribution": self._style_distribution(),
|
||
"sessions": self._today_sessions(today),
|
||
}
|
||
|
||
def _revenue_last_6_months(self, today, company, invoice_model):
|
||
labels, data = [], []
|
||
for i in range(5, -1, -1):
|
||
month_date = today - relativedelta(months=i)
|
||
ms = month_date.replace(day=1)
|
||
me = (ms + relativedelta(months=1)) - relativedelta(days=1)
|
||
if i == 0:
|
||
me = today
|
||
labels.append(ms.strftime("%b '%y"))
|
||
paid = invoice_model.search([
|
||
("move_type", "=", "out_invoice"),
|
||
("state", "=", "posted"),
|
||
("payment_state", "=", "paid"),
|
||
("dance_student_id", "!=", False),
|
||
("invoice_date", ">=", ms),
|
||
("invoice_date", "<=", me),
|
||
("company_id", "=", company.id),
|
||
])
|
||
data.append(round(sum(paid.mapped("amount_total")), 2))
|
||
return {"labels": labels, "data": data}
|
||
|
||
def _attendance_last_30_days(self, today):
|
||
cutoff = today - relativedelta(days=30)
|
||
att = self.env["dance.attendance"].search([
|
||
("session_id.date", ">=", cutoff),
|
||
("session_id.date", "<=", today),
|
||
])
|
||
statuses = att.mapped("status")
|
||
return {
|
||
"present": statuses.count("present"),
|
||
"absent": statuses.count("absent"),
|
||
"late": statuses.count("late"),
|
||
}
|
||
|
||
def _style_distribution(self):
|
||
enrollments = self.env["dance.enrollment"].search([("status", "=", "active")])
|
||
style_field = self.env["dance.batch"]._fields.get("dance_style")
|
||
style_map = dict(style_field.selection) if style_field else {}
|
||
|
||
counts = Counter(
|
||
e.batch_id.dance_style for e in enrollments if e.batch_id and e.batch_id.dance_style
|
||
)
|
||
if not counts:
|
||
return {"labels": [], "data": []}
|
||
|
||
# Sort by count desc for nicer chart
|
||
sorted_items = sorted(counts.items(), key=lambda x: x[1], reverse=True)
|
||
return {
|
||
"labels": [style_map.get(code, code.title()) for code, _ in sorted_items],
|
||
"data": [cnt for _, cnt in sorted_items],
|
||
}
|
||
|
||
def _today_sessions(self, today):
|
||
sessions = self.env["dance.session"].search(
|
||
[("date", "=", today)], order="start_time asc"
|
||
)
|
||
result = []
|
||
for s in sessions:
|
||
result.append({
|
||
"id": s.id,
|
||
"batch": s.batch_id.name if s.batch_id else "—",
|
||
"time": f"{_float_to_time(s.start_time)} – {_float_to_time(s.end_time)}",
|
||
"room": s.room or "—",
|
||
"instructor": s.instructor_id.name if s.instructor_id else "—",
|
||
"status": s.status,
|
||
})
|
||
return result
|
||
|
||
# ── Navigation actions (legacy stat buttons) ───────────────────────────────
|
||
|
||
def action_view_students(self):
|
||
return {"type": "ir.actions.act_window", "name": "Students",
|
||
"res_model": "res.partner", "domain": [("is_student", "=", True)],
|
||
"view_mode": "tree,form", "context": {"default_is_student": True}}
|
||
|
||
def action_view_batches(self):
|
||
return {"type": "ir.actions.act_window", "name": "Active Batches",
|
||
"res_model": "dance.batch", "domain": [("status", "=", "active")],
|
||
"view_mode": "tree,form"}
|
||
|
||
def action_view_enrollments(self):
|
||
return {"type": "ir.actions.act_window", "name": "Active Enrollments",
|
||
"res_model": "dance.enrollment", "domain": [("status", "=", "active")],
|
||
"view_mode": "tree,form"}
|
||
|
||
def action_view_dropped(self):
|
||
return {"type": "ir.actions.act_window", "name": "Dropped Enrollments",
|
||
"res_model": "dance.enrollment", "domain": [("status", "=", "dropped")],
|
||
"view_mode": "tree,form"}
|
||
|
||
def action_view_revenue(self):
|
||
today = fields.Date.context_today(self)
|
||
month_start = today.replace(day=1)
|
||
return {"type": "ir.actions.act_window", "name": "Revenue This Month",
|
||
"res_model": "account.move",
|
||
"domain": [("move_type", "=", "out_invoice"), ("state", "=", "posted"),
|
||
("payment_state", "=", "paid"), ("dance_student_id", "!=", False),
|
||
("invoice_date", ">=", month_start), ("invoice_date", "<=", today)],
|
||
"view_mode": "tree,form"}
|
||
|
||
def action_view_pending(self):
|
||
return {"type": "ir.actions.act_window", "name": "Outstanding Fees",
|
||
"res_model": "account.move",
|
||
"domain": [("move_type", "=", "out_invoice"), ("state", "=", "posted"),
|
||
("payment_state", "in", ["not_paid", "partial"]),
|
||
("dance_student_id", "!=", False)],
|
||
"view_mode": "tree,form"}
|