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