2026-09-28 11:03:02 +05:30

238 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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