first commit

This commit is contained in:
srikanth01234 2026-09-28 11:03:02 +05:30
commit 51fca0a7de
652 changed files with 218123 additions and 0 deletions

20
.claude/settings.json Normal file
View File

@ -0,0 +1,20 @@
{
"permissions": {
"allow": [
"PowerShell(docker restart odoo_dance 2>&1)",
"PowerShell(docker exec odoo_dance odoo -c /etc/odoo/odoo.conf -d dance_school_erp --update dance_theme --stop-after-init 2>&1)",
"PowerShell(docker restart odoo_dance)",
"mcp__odoo-mcp__bulk_delete_records",
"Bash(ls -la dance_odoo)",
"Bash(ls -la dance-school-saas)",
"Bash(docker compose *)",
"Bash(curl -s -o /dev/null -w \"/enroll -> %{http_code}\\\\n\" http://localhost:11000/enroll)",
"Bash(curl -s -o /dev/null -w \"/ -> %{http_code}\\\\n\" http://localhost:11000/)",
"Bash(curl -s -o /dev/null -w \"/enroll/thank-you -> %{http_code}\\\\n\" http://localhost:11000/enroll/thank-you)",
"Bash(curl -s http://localhost:11000/enroll)",
"Bash(curl -s -o /dev/null -w \"%{http_code} %{redirect_url}\\\\n\" http://localhost:11000/parent)",
"Bash(curl -s -o /dev/null -w \"%{http_code} %{redirect_url}\\\\n\" http://localhost:11000/teacher)",
"Bash(curl -s -o /dev/null -w \"%{http_code} %{redirect_url}\\\\n\" http://localhost:11000/dashboard)"
]
}
}

12
.env Normal file
View File

@ -0,0 +1,12 @@
POSTGRES_DB=postgres
POSTGRES_USER=odoo
POSTGRES_PASSWORD=odoo
ODOO_DB_HOST=db
ODOO_HTTP_PORT=11000
ODOO_LIST_DB=True
ODOO_ADMIN_PASSWORD=admin
ODOO_CONTAINER_NAME=odoo_dance
DB_CONTAINER_NAME=odoo_dance_db
ODOO_DATABASE=Odoo_dance

12
.env.example Normal file
View File

@ -0,0 +1,12 @@
POSTGRES_DB=postgres
POSTGRES_USER=odoo
POSTGRES_PASSWORD=change_me
ODOO_DB_HOST=db
ODOO_HTTP_PORT=11000
ODOO_LIST_DB=True
ODOO_ADMIN_PASSWORD=change_me
ODOO_CONTAINER_NAME=odoo_dance
DB_CONTAINER_NAME=odoo_dance_db
ODOO_DATABASE=Odoo_dance

17
.mcp.json Normal file
View File

@ -0,0 +1,17 @@
{
"mcpServers": {
"odoo-mcp": {
"type": "stdio",
"command": "py",
"args": ["-3.11", "-m", "odoo_mcp.server"],
"cwd": "${workspaceFolder}",
"env": {
"PYTHONPATH": "E:/MCS_Repos/2026/ODOO-MCP/src",
"ODOO_URL": "http://localhost:11000/",
"ODOO_DATABASE": "sri__odoo",
"ODOO_USERNAME": "srikanth1522003@gmail.com",
"ODOO_PASSWORD": "123456"
}
}
}
}

7
Dockerfile Normal file
View File

@ -0,0 +1,7 @@
FROM odoo:17.0
USER root
RUN pip3 install openpyxl==3.1.5
USER odoo

209
README.md Normal file
View File

@ -0,0 +1,209 @@
# Dance Odoo
Dance ERP built on Odoo 17.
## Current Scope
The first implemented modules are `dance_student` and `dance_batch`. Students extend `res.partner` instead of using a separate student table, so they remain compatible with Odoo contacts, invoices, messages, activities, and future accounting flows.
## Run Locally
```powershell
docker compose up -d
```
Odoo runs at:
```text
http://localhost:11000
```
The configured database is:
```text
Odoo_dance
```
## Student Module
Installed addon:
```text
addons/dance_student
```
It adds:
- `is_student`
- `student_code`
- `guardian_id`
- `guardian_ids`
- `guardian_phone`
- `date_of_birth`
- `age`
- `gender`
- `dance_style`
- `level`
- `student_status`
- `join_date`
- `medical_notes`
Students are available from:
```text
Dance ERP > Students
```
## Batch Module
Installed addon:
```text
addons/dance_batch
```
It adds:
- `dance.batch`
- `dance.enrollment`
The relationship is:
```text
Student -> Enrollment -> Batch
```
This allows one student to join multiple batches and one batch to contain many students. The module also tracks instructor, room, dates, timing, capacity, current strength, and enrollment status.
Batches are available from:
```text
Dance ERP > Batches
```
Enrollments are available from:
```text
Dance ERP > Enrollments
```
## Session And Attendance Module
Installed addon:
```text
addons/dance_attendance
```
It adds:
- `dance.session`
- `dance.attendance`
The relationship is:
```text
Batch -> Session -> Attendance -> Student
```
Sessions are available from:
```text
Dance ERP > Sessions
```
Attendance records are available from:
```text
Dance ERP > Attendance
```
Daily flow:
```text
Open today's session
Load Students
Mark Present / Absent / Late
Complete session
```
## Fees Module
Installed addon:
```text
addons/dance_fees
```
It adds:
- `dance.fee.structure`
- Fee links on batches
- Manual invoice generation from enrollments and batches
- Dance metadata on Odoo invoices
The relationship is:
```text
Student -> Enrollment -> Batch -> Fee Structure -> Invoice -> Payment
```
Main places:
```text
Dance ERP > Fee Structures
Dance ERP > Batches
Dance ERP > Enrollments
Accounting > Customers > Invoices
```
Manual billing flow:
```text
Set a fee structure on the batch
Open an active enrollment
Click Create Invoice
Register payment in Odoo
```
## Useful Scripts
Update the student module:
```powershell
.\scripts\update_module.ps1 dance_student
```
Update the batch module:
```powershell
.\scripts\update_module.ps1 dance_batch
```
Update the attendance module:
```powershell
.\scripts\update_module.ps1 dance_attendance
```
Update the fees module:
```powershell
.\scripts\update_module.ps1 dance_fees
```
Create a database backup:
```powershell
.\scripts\backup_db.ps1
```
Restore a database backup:
```powershell
.\scripts\restore_db.ps1 -BackupFile .\backups\Odoo_dance_YYYYMMDD_HHMMSS.sql
```
## Nginx
`nginx/odoo.conf` contains a reverse proxy configuration for Odoo. It is not enabled as a Docker service yet, so the current local port setup stays simple and unchanged.

View File

@ -0,0 +1 @@
from . import models

View File

@ -0,0 +1,17 @@
{
"name": "Dance Session Attendance",
"summary": "Class sessions and attendance tracking for dance batches",
"version": "17.0.1.0.0",
"category": "Education",
"author": "Metetroncube",
"license": "LGPL-3",
"depends": ["dance_batch"],
"data": [
"security/ir.model.access.csv",
"data/sample_data.xml",
"views/dance_session_views.xml",
"views/dance_attendance_views.xml",
],
"application": False,
"installable": True,
}

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="session_bharatanatyam_2026_05_02" model="dance.session">
<field name="batch_id" ref="dance_batch.batch_bharatanatyam_beginner_sat"/>
<field name="date">2026-05-02</field>
<field name="start_time">10.0</field>
<field name="end_time">11.0</field>
<field name="instructor_id" ref="dance_batch.employee_instructor_ananya"/>
<field name="status">scheduled</field>
</record>
<record id="session_hiphop_2026_05_04" model="dance.session">
<field name="batch_id" ref="dance_batch.batch_hiphop_intermediate_monwed"/>
<field name="date">2026-05-04</field>
<field name="start_time">18.0</field>
<field name="end_time">19.0</field>
<field name="instructor_id" ref="dance_batch.employee_instructor_rahul"/>
<field name="status">scheduled</field>
</record>
</odoo>

View File

@ -0,0 +1,2 @@
from . import dance_session
from . import dance_attendance

View File

@ -0,0 +1,92 @@
from odoo import api, fields, models
from odoo.exceptions import ValidationError
class DanceAttendance(models.Model):
_name = "dance.attendance"
_description = "Dance Attendance"
_order = "session_id desc, student_id"
student_id = fields.Many2one(
"res.partner",
required=True,
domain="[('is_student', '=', True)]",
ondelete="restrict",
)
session_id = fields.Many2one(
"dance.session",
required=True,
ondelete="cascade",
)
batch_id = fields.Many2one(
related="session_id.batch_id",
store=True,
readonly=True,
)
date = fields.Date(related="session_id.date", store=True, readonly=True)
status = fields.Selection(
[
("present", "Present"),
("absent", "Absent"),
("late", "Late"),
],
default="present",
required=True,
)
attendance_percent = fields.Float(
string="Attendance %",
compute="_compute_report_measures",
store=True,
group_operator="avg",
)
present_count = fields.Integer(
string="Present/Late",
compute="_compute_report_measures",
store=True,
)
absent_count = fields.Integer(
string="Absentees",
compute="_compute_report_measures",
store=True,
)
total_count = fields.Integer(
string="Sessions",
compute="_compute_report_measures",
store=True,
)
remarks = fields.Text()
_sql_constraints = [
(
"unique_attendance",
"unique(student_id, session_id)",
"Attendance is already marked for this student in this session.",
),
]
@api.depends("status")
def _compute_report_measures(self):
for attendance in self:
is_absent = attendance.status == "absent"
attendance.attendance_percent = 0.0 if is_absent else 100.0
attendance.present_count = 0 if is_absent else 1
attendance.absent_count = 1 if is_absent else 0
attendance.total_count = 1
@api.constrains("student_id", "session_id")
def _check_student_in_batch(self):
for attendance in self:
if not attendance.student_id or not attendance.session_id:
continue
enrollment = self.env["dance.enrollment"].search(
[
("student_id", "=", attendance.student_id.id),
("batch_id", "=", attendance.session_id.batch_id.id),
("status", "=", "active"),
],
limit=1,
)
if not enrollment:
raise ValidationError(
"Attendance can only be marked for active students in this batch."
)

View File

@ -0,0 +1,230 @@
from odoo import api, fields, models
from odoo.exceptions import ValidationError
class DanceSession(models.Model):
_name = "dance.session"
_description = "Dance Class Session"
_order = "date desc, start_time desc, id desc"
name = fields.Char(compute="_compute_name", store=True)
batch_id = fields.Many2one("dance.batch", required=True, ondelete="restrict")
date = fields.Date(required=True, default=fields.Date.context_today)
start_time = fields.Float()
end_time = fields.Float()
room = fields.Char()
instructor_id = fields.Many2one("hr.employee", string="Instructor")
substitute_instructor_id = fields.Many2one(
"hr.employee",
string="Substitute Instructor",
)
is_cancelled = fields.Boolean(string="Cancelled")
cancellation_reason = fields.Text()
makeup_for_id = fields.Many2one(
"dance.session",
string="Makeup For",
domain="[('status', '=', 'cancelled')]",
ondelete="set null",
)
makeup_session_ids = fields.One2many(
"dance.session",
"makeup_for_id",
string="Makeup Sessions",
)
status = fields.Selection(
[
("scheduled", "Scheduled"),
("completed", "Completed"),
("cancelled", "Cancelled"),
],
default="scheduled",
required=True,
)
notes = fields.Text()
attendance_ids = fields.One2many(
"dance.attendance",
"session_id",
string="Attendance",
)
attendance_count = fields.Integer(compute="_compute_attendance_counts")
present_count = fields.Integer(compute="_compute_attendance_counts")
absent_count = fields.Integer(compute="_compute_attendance_counts")
late_count = fields.Integer(compute="_compute_attendance_counts")
@api.depends("batch_id", "date", "start_time")
def _compute_name(self):
for session in self:
if session.batch_id and session.date:
session.name = "%s - %s" % (session.batch_id.name, session.date)
else:
session.name = "New Session"
@api.depends("attendance_ids.status")
def _compute_attendance_counts(self):
for session in self:
session.attendance_count = len(session.attendance_ids)
session.present_count = len(
session.attendance_ids.filtered(lambda line: line.status == "present")
)
session.absent_count = len(
session.attendance_ids.filtered(lambda line: line.status == "absent")
)
session.late_count = len(
session.attendance_ids.filtered(lambda line: line.status == "late")
)
@api.onchange("batch_id")
def _onchange_batch_id(self):
for session in self:
if session.batch_id:
session.instructor_id = session.batch_id.instructor_id
session.start_time = session.batch_id.start_time
session.end_time = session.batch_id.end_time
session.room = session.batch_id.room
@api.constrains("batch_id", "date")
def _check_date_inside_batch(self):
for session in self:
batch = session.batch_id
if not batch or not session.date:
continue
if batch.start_date and session.date < batch.start_date:
raise ValidationError("Session date cannot be before batch start date.")
if batch.end_date and session.date > batch.end_date:
raise ValidationError("Session date cannot be after batch end date.")
@api.constrains("start_time", "end_time")
def _check_times(self):
for session in self:
if session.end_time and session.start_time and session.end_time <= session.start_time:
raise ValidationError("End time must be after start time.")
@api.constrains("date", "start_time", "end_time", "room", "instructor_id", "substitute_instructor_id", "status")
def _check_room_and_instructor_conflicts(self):
for session in self:
if (
session.status == "cancelled"
or not session.date
or not session.start_time
or not session.end_time
):
continue
overlap_domain = [
("id", "!=", session.id),
("date", "=", session.date),
("status", "!=", "cancelled"),
("start_time", "<", session.end_time),
("end_time", ">", session.start_time),
]
if session.room:
room_conflict = self.search(overlap_domain + [("room", "=", session.room)], limit=1)
if room_conflict:
raise ValidationError(
"Room conflict: %s is already booked for %s from %.2f to %.2f."
% (
session.room,
room_conflict.name,
room_conflict.start_time,
room_conflict.end_time,
)
)
teaching_instructor = session.substitute_instructor_id or session.instructor_id
if teaching_instructor:
instructor_conflict = self.search(
overlap_domain
+ [
"|",
("instructor_id", "=", teaching_instructor.id),
("substitute_instructor_id", "=", teaching_instructor.id),
],
limit=1,
)
if instructor_conflict:
raise ValidationError(
"Instructor conflict: %s already has %s from %.2f to %.2f."
% (
teaching_instructor.name,
instructor_conflict.name,
instructor_conflict.start_time,
instructor_conflict.end_time,
)
)
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get("batch_id"):
batch = self.env["dance.batch"].browse(vals["batch_id"])
vals.setdefault("instructor_id", batch.instructor_id.id)
vals.setdefault("start_time", batch.start_time)
vals.setdefault("end_time", batch.end_time)
vals.setdefault("room", batch.room)
return super().create(vals_list)
def write(self, vals):
if vals.get("is_cancelled"):
vals = dict(vals, status="cancelled")
elif vals.get("status") == "cancelled":
vals = dict(vals, is_cancelled=True)
return super().write(vals)
def action_load_students(self):
for session in self:
active_enrollments = self.env["dance.enrollment"].search(
[
("batch_id", "=", session.batch_id.id),
("status", "=", "active"),
]
)
existing_student_ids = set(session.attendance_ids.mapped("student_id").ids)
attendance_vals = []
for enrollment in active_enrollments:
if enrollment.student_id.id in existing_student_ids:
continue
attendance_vals.append(
{
"session_id": session.id,
"student_id": enrollment.student_id.id,
"status": "present",
}
)
if attendance_vals:
self.env["dance.attendance"].create(attendance_vals)
return True
def action_mark_all_present(self):
for session in self:
if not session.attendance_ids:
session.action_load_students()
session.attendance_ids.write({"status": "present"})
return True
def action_complete(self):
self.write({"status": "completed"})
return True
def action_cancel(self):
self.write({"status": "cancelled", "is_cancelled": True})
return True
def action_create_makeup_session(self):
self.ensure_one()
makeup = self.copy(
{
"date": fields.Date.context_today(self),
"status": "scheduled",
"is_cancelled": False,
"cancellation_reason": False,
"makeup_for_id": self.id,
"attendance_ids": False,
}
)
return {
"name": "Makeup Session",
"type": "ir.actions.act_window",
"res_model": "dance.session",
"res_id": makeup.id,
"view_mode": "form",
}

View File

@ -0,0 +1,3 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_dance_session_user,dance.session user,model_dance_session,base.group_user,1,1,1,1
access_dance_attendance_user,dance.attendance user,model_dance_attendance,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_dance_session_user dance.session user model_dance_session base.group_user 1 1 1 1
3 access_dance_attendance_user dance.attendance user model_dance_attendance base.group_user 1 1 1 1

View File

@ -0,0 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_dance_attendance_tree" model="ir.ui.view">
<field name="name">dance.attendance.tree</field>
<field name="model">dance.attendance</field>
<field name="arch" type="xml">
<tree string="Attendance">
<field name="date"/>
<field name="batch_id"/>
<field name="session_id"/>
<field name="student_id"/>
<field name="status"/>
<field name="remarks"/>
</tree>
</field>
</record>
<record id="view_dance_attendance_form" model="ir.ui.view">
<field name="name">dance.attendance.form</field>
<field name="model">dance.attendance</field>
<field name="arch" type="xml">
<form string="Attendance">
<sheet>
<group>
<group string="Session">
<field name="session_id"/>
<field name="batch_id" readonly="1"/>
<field name="date" readonly="1"/>
</group>
<group string="Student">
<field name="student_id"/>
<field name="status"/>
</group>
</group>
<group string="Remarks">
<field name="remarks" nolabel="1"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_dance_attendance_search" model="ir.ui.view">
<field name="name">dance.attendance.search</field>
<field name="model">dance.attendance</field>
<field name="arch" type="xml">
<search string="Attendance">
<field name="student_id"/>
<field name="batch_id"/>
<field name="session_id"/>
<filter name="present_attendance" string="Present" domain="[('status', '=', 'present')]"/>
<filter name="absent_attendance" string="Absent" domain="[('status', '=', 'absent')]"/>
<filter name="late_attendance" string="Late" domain="[('status', '=', 'late')]"/>
<group expand="0" string="Group By">
<filter name="group_by_student" string="Student" context="{'group_by': 'student_id'}"/>
<filter name="group_by_batch" string="Batch" context="{'group_by': 'batch_id'}"/>
<filter name="group_by_status" string="Status" context="{'group_by': 'status'}"/>
<filter name="group_by_date" string="Date" context="{'group_by': 'date'}"/>
</group>
</search>
</field>
</record>
<record id="action_dance_attendance" model="ir.actions.act_window">
<field name="name">Attendance</field>
<field name="res_model">dance.attendance</field>
<field name="view_mode">tree,form</field>
<field name="search_view_id" ref="view_dance_attendance_search"/>
</record>
<menuitem id="menu_dance_attendance" name="Attendance" parent="dance_student.menu_dance_root" action="action_dance_attendance" sequence="50"/>
</odoo>

View File

@ -0,0 +1,126 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_dance_session_tree" model="ir.ui.view">
<field name="name">dance.session.tree</field>
<field name="model">dance.session</field>
<field name="arch" type="xml">
<tree string="Sessions">
<field name="date"/>
<field name="batch_id"/>
<field name="start_time" widget="float_time"/>
<field name="end_time" widget="float_time"/>
<field name="room"/>
<field name="instructor_id"/>
<field name="attendance_count"/>
<field name="present_count"/>
<field name="absent_count"/>
<field name="late_count"/>
<field name="status"/>
</tree>
</field>
</record>
<record id="view_dance_session_form" model="ir.ui.view">
<field name="name">dance.session.form</field>
<field name="model">dance.session</field>
<field name="arch" type="xml">
<form string="Session">
<header>
<button name="action_load_students" type="object" string="Load Students" class="btn-primary" invisible="status == 'cancelled'"/>
<button name="action_mark_all_present" type="object" string="Mark All Present" invisible="status == 'cancelled'"/>
<button name="action_complete" type="object" string="Complete" invisible="status != 'scheduled'"/>
<button name="action_cancel" type="object" string="Cancel" invisible="status == 'cancelled'"/>
<button name="action_create_makeup_session" type="object" string="Create Makeup" invisible="status != 'cancelled'"/>
<field name="status" widget="statusbar" statusbar_visible="scheduled,completed,cancelled"/>
</header>
<sheet>
<group>
<group string="Session">
<field name="batch_id"/>
<field name="date"/>
<field name="start_time" widget="float_time"/>
<field name="end_time" widget="float_time"/>
<field name="room"/>
</group>
<group string="Instructor">
<field name="instructor_id"/>
<field name="substitute_instructor_id"/>
<field name="attendance_count" readonly="1"/>
<field name="present_count" readonly="1"/>
<field name="absent_count" readonly="1"/>
<field name="late_count" readonly="1"/>
</group>
</group>
<notebook>
<page string="Cancellation">
<group>
<field name="is_cancelled"/>
<field name="cancellation_reason" placeholder="Example: Instructor unavailable"/>
<field name="makeup_for_id"/>
<field name="makeup_session_ids" readonly="1"/>
</group>
</page>
<page string="Attendance">
<field name="attendance_ids" context="{'default_session_id': id}">
<tree editable="bottom">
<field name="student_id"/>
<field name="status"/>
<field name="remarks"/>
</tree>
</field>
</page>
<page string="Notes">
<field name="notes" nolabel="1"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="view_dance_session_calendar" model="ir.ui.view">
<field name="name">dance.session.calendar</field>
<field name="model">dance.session</field>
<field name="arch" type="xml">
<calendar string="Sessions" date_start="date" color="batch_id" mode="month">
<field name="batch_id"/>
<field name="instructor_id"/>
<field name="status"/>
</calendar>
</field>
</record>
<record id="view_dance_session_search" model="ir.ui.view">
<field name="name">dance.session.search</field>
<field name="model">dance.session</field>
<field name="arch" type="xml">
<search string="Sessions">
<field name="batch_id"/>
<field name="instructor_id"/>
<filter name="today_sessions" string="Today"
domain="[('date', '=', context_today().strftime('%Y-%m-%d'))]"/>
<filter name="upcoming_sessions" string="Upcoming"
domain="[('date', '>=', context_today().strftime('%Y-%m-%d')), ('status', '=', 'scheduled')]"/>
<separator/>
<filter name="scheduled_sessions" string="Scheduled" domain="[('status', '=', 'scheduled')]"/>
<filter name="completed_sessions" string="Completed" domain="[('status', '=', 'completed')]"/>
<filter name="cancelled_sessions" string="Cancelled" domain="[('status', '=', 'cancelled')]"/>
<group expand="0" string="Group By">
<filter name="group_by_batch" string="Batch" context="{'group_by': 'batch_id'}"/>
<filter name="group_by_instructor" string="Instructor" context="{'group_by': 'instructor_id'}"/>
<filter name="group_by_status" string="Status" context="{'group_by': 'status'}"/>
<filter name="group_by_date" string="Date" context="{'group_by': 'date'}"/>
</group>
</search>
</field>
</record>
<record id="action_dance_sessions" model="ir.actions.act_window">
<field name="name">Sessions</field>
<field name="res_model">dance.session</field>
<field name="view_mode">tree,form,calendar</field>
<field name="search_view_id" ref="view_dance_session_search"/>
</record>
<menuitem id="menu_dance_sessions" name="Sessions" parent="dance_student.menu_dance_root" action="action_dance_sessions" sequence="40"/>
</odoo>

View File

@ -0,0 +1 @@
from . import models

View File

@ -0,0 +1,17 @@
{
"name": "Dance Batch Management",
"summary": "Batches and enrollments for dance classes",
"version": "17.0.1.0.0",
"category": "Education",
"author": "Metetroncube",
"license": "LGPL-3",
"depends": ["dance_student", "hr"],
"data": [
"security/ir.model.access.csv",
"data/sample_data.xml",
"views/dance_batch_views.xml",
"views/dance_enrollment_views.xml",
],
"application": False,
"installable": True,
}

View File

@ -0,0 +1,116 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="partner_instructor_ananya" model="res.partner">
<field name="name">Ananya Rao</field>
<field name="phone">+91 98765 10001</field>
<field name="email">ananya.rao@example.com</field>
</record>
<record id="employee_instructor_ananya" model="hr.employee">
<field name="name">Ananya Rao</field>
<field name="work_phone">+91 98765 10001</field>
<field name="work_email">ananya.rao@example.com</field>
<field name="address_id" ref="partner_instructor_ananya"/>
</record>
<record id="partner_instructor_rahul" model="res.partner">
<field name="name">Rahul Menon</field>
<field name="phone">+91 98765 10002</field>
<field name="email">rahul.menon@example.com</field>
</record>
<record id="employee_instructor_rahul" model="hr.employee">
<field name="name">Rahul Menon</field>
<field name="work_phone">+91 98765 10002</field>
<field name="work_email">rahul.menon@example.com</field>
<field name="address_id" ref="partner_instructor_rahul"/>
</record>
<record id="guardian_lakshmi" model="res.partner">
<field name="name">Lakshmi Iyer</field>
<field name="phone">+91 98765 20001</field>
<field name="email">lakshmi.iyer@example.com</field>
</record>
<record id="guardian_priya" model="res.partner">
<field name="name">Priya Nair</field>
<field name="phone">+91 98765 20002</field>
<field name="email">priya.nair@example.com</field>
</record>
<record id="student_meera" model="res.partner">
<field name="name">Meera Iyer</field>
<field name="phone">+91 98765 30001</field>
<field name="email">meera.iyer@example.com</field>
<field name="is_student">True</field>
<field name="student_code">STU-DEMO-001</field>
<field name="guardian_id" ref="guardian_lakshmi"/>
<field name="guardian_phone">+91 98765 20001</field>
<field name="date_of_birth">2014-08-12</field>
<field name="gender">female</field>
<field name="dance_style">bharatanatyam</field>
<field name="level">beginner</field>
<field name="student_status">active</field>
<field name="join_date">2026-05-01</field>
</record>
<record id="student_arjun" model="res.partner">
<field name="name">Arjun Nair</field>
<field name="phone">+91 98765 30002</field>
<field name="email">arjun.nair@example.com</field>
<field name="is_student">True</field>
<field name="student_code">STU-DEMO-002</field>
<field name="guardian_id" ref="guardian_priya"/>
<field name="guardian_phone">+91 98765 20002</field>
<field name="date_of_birth">2012-11-04</field>
<field name="gender">male</field>
<field name="dance_style">hiphop</field>
<field name="level">intermediate</field>
<field name="student_status">active</field>
<field name="join_date">2026-05-01</field>
</record>
<record id="batch_bharatanatyam_beginner_sat" model="dance.batch">
<field name="name">Bharatanatyam Beginner - Sat 10AM</field>
<field name="dance_style">bharatanatyam</field>
<field name="level">beginner</field>
<field name="instructor_id" ref="employee_instructor_ananya"/>
<field name="room">Studio A</field>
<field name="capacity">15</field>
<field name="start_date">2026-05-02</field>
<field name="end_date">2026-08-29</field>
<field name="start_time">10.0</field>
<field name="end_time">11.0</field>
<field name="schedule_days">Sat</field>
<field name="status">active</field>
</record>
<record id="batch_hiphop_intermediate_monwed" model="dance.batch">
<field name="name">Hip-Hop Intermediate - Mon/Wed 6PM</field>
<field name="dance_style">hiphop</field>
<field name="level">intermediate</field>
<field name="instructor_id" ref="employee_instructor_rahul"/>
<field name="room">Studio B</field>
<field name="capacity">20</field>
<field name="start_date">2026-05-04</field>
<field name="end_date">2026-08-31</field>
<field name="start_time">18.0</field>
<field name="end_time">19.0</field>
<field name="schedule_days">Mon/Wed</field>
<field name="status">active</field>
</record>
<record id="enrollment_meera_bharatanatyam" model="dance.enrollment">
<field name="student_id" ref="student_meera"/>
<field name="batch_id" ref="batch_bharatanatyam_beginner_sat"/>
<field name="enrollment_date">2026-05-02</field>
<field name="status">active</field>
</record>
<record id="enrollment_arjun_hiphop" model="dance.enrollment">
<field name="student_id" ref="student_arjun"/>
<field name="batch_id" ref="batch_hiphop_intermediate_monwed"/>
<field name="enrollment_date">2026-05-04</field>
<field name="status">active</field>
</record>
</odoo>

View File

@ -0,0 +1,2 @@
from . import dance_batch
from . import dance_enrollment

View File

@ -0,0 +1,129 @@
from odoo import api, fields, models
from odoo.exceptions import ValidationError
class DanceBatch(models.Model):
_name = "dance.batch"
_description = "Dance Batch"
_order = "start_date desc, name"
name = fields.Char(required=True)
dance_style = fields.Selection(
[
("bharatanatyam", "Bharatanatyam"),
("kathak", "Kathak"),
("hiphop", "Hip-Hop"),
("contemporary", "Contemporary"),
("zumba", "Zumba"),
],
string="Dance Style",
required=True,
)
level = fields.Selection(
[
("beginner", "Beginner"),
("intermediate", "Intermediate"),
("advanced", "Advanced"),
],
required=True,
)
instructor_id = fields.Many2one("hr.employee", string="Instructor", required=True)
room = fields.Char()
capacity = fields.Integer(default=20)
active_enrollment_count = fields.Integer(
string="Current Strength",
compute="_compute_enrollment_counts",
store=True,
)
enrollment_count = fields.Integer(compute="_compute_enrollment_counts", store=True)
available_seats = fields.Integer(compute="_compute_enrollment_counts", store=True)
is_almost_full = fields.Boolean(compute="_compute_capacity_warning")
capacity_warning = fields.Char(compute="_compute_capacity_warning")
start_date = fields.Date(required=True)
end_date = fields.Date()
start_time = fields.Float(string="Start Time")
end_time = fields.Float(string="End Time")
schedule_days = fields.Char(
string="Schedule Days",
help="Example: Sat or Mon/Wed",
)
status = fields.Selection(
[
("upcoming", "Upcoming"),
("active", "Active"),
("completed", "Completed"),
("cancelled", "Cancelled"),
],
default="upcoming",
required=True,
)
enrollment_ids = fields.One2many(
"dance.enrollment",
"batch_id",
string="Enrollments",
)
@api.depends("enrollment_ids.status", "capacity")
def _compute_enrollment_counts(self):
for batch in self:
active_enrollments = batch.enrollment_ids.filtered(
lambda enrollment: enrollment.status == "active"
)
batch.active_enrollment_count = len(active_enrollments)
batch.enrollment_count = len(batch.enrollment_ids)
batch.available_seats = max(batch.capacity - len(active_enrollments), 0)
@api.depends("active_enrollment_count", "capacity", "available_seats")
def _compute_capacity_warning(self):
for batch in self:
batch.is_almost_full = bool(
batch.capacity
and batch.active_enrollment_count < batch.capacity
and batch.available_seats <= 2
)
if batch.is_almost_full:
batch.capacity_warning = "Batch almost full (%s/%s)" % (
batch.active_enrollment_count,
batch.capacity,
)
elif batch.capacity and batch.active_enrollment_count >= batch.capacity:
batch.capacity_warning = "Batch is full (%s/%s)" % (
batch.active_enrollment_count,
batch.capacity,
)
else:
batch.capacity_warning = False
@api.constrains("capacity")
def _check_capacity(self):
for batch in self:
if batch.capacity < 0:
raise ValidationError("Capacity cannot be negative.")
if batch.capacity and batch.active_enrollment_count > batch.capacity:
raise ValidationError("Capacity cannot be less than current strength.")
@api.constrains("start_date", "end_date")
def _check_dates(self):
for batch in self:
if batch.end_date and batch.start_date and batch.end_date < batch.start_date:
raise ValidationError("End date cannot be before start date.")
@api.constrains("start_time", "end_time")
def _check_times(self):
for batch in self:
if batch.end_time and batch.start_time and batch.end_time <= batch.start_time:
raise ValidationError("End time must be after start time.")
def action_view_enrollments(self):
self.ensure_one()
return {
"name": "Enrollments",
"type": "ir.actions.act_window",
"res_model": "dance.enrollment",
"view_mode": "tree,form",
"domain": [("batch_id", "=", self.id)],
"context": {"default_batch_id": self.id},
}

View File

@ -0,0 +1,94 @@
from odoo import api, fields, models
from odoo.exceptions import ValidationError
class DanceEnrollment(models.Model):
_name = "dance.enrollment"
_description = "Dance Enrollment"
_order = "enrollment_date desc, id desc"
name = fields.Char(compute="_compute_name", store=True)
student_id = fields.Many2one(
"res.partner",
required=True,
domain="[('is_student', '=', True)]",
ondelete="restrict",
)
batch_id = fields.Many2one(
"dance.batch",
required=True,
ondelete="restrict",
)
enrollment_date = fields.Date(default=fields.Date.context_today, required=True)
status = fields.Selection(
[
("active", "Active"),
("hold", "Hold"),
("dropped", "Dropped"),
("completed", "Completed"),
],
default="active",
required=True,
)
notes = fields.Text()
_sql_constraints = [
(
"student_batch_unique",
"unique(student_id, batch_id)",
"This student is already enrolled in this batch.",
),
]
@api.depends("student_id", "batch_id")
def _compute_name(self):
for enrollment in self:
if enrollment.student_id and enrollment.batch_id:
enrollment.name = "%s - %s" % (
enrollment.student_id.display_name,
enrollment.batch_id.name,
)
else:
enrollment.name = "New Enrollment"
@api.constrains("batch_id", "status")
def _check_batch_capacity(self):
for enrollment in self:
if enrollment.status != "active" or not enrollment.batch_id.capacity:
continue
active_count = self.search_count(
[
("batch_id", "=", enrollment.batch_id.id),
("status", "=", "active"),
("id", "!=", enrollment.id),
]
)
if active_count + 1 > enrollment.batch_id.capacity:
raise ValidationError("This batch is already at full capacity.")
@api.constrains("student_id")
def _check_student_flag(self):
for enrollment in self:
if enrollment.student_id and not enrollment.student_id.is_student:
raise ValidationError("Only student contacts can be enrolled in a batch.")
@api.onchange("batch_id", "status")
def _onchange_batch_capacity_warning(self):
if (
self.batch_id
and self.status == "active"
and self.batch_id.capacity
and self.batch_id.active_enrollment_count < self.batch_id.capacity
and self.batch_id.available_seats <= 2
):
return {
"warning": {
"title": "Batch almost full",
"message": "Batch almost full (%s/%s)."
% (
self.batch_id.active_enrollment_count,
self.batch_id.capacity,
),
}
}
return {}

View File

@ -0,0 +1,3 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_dance_batch_user,dance.batch user,model_dance_batch,base.group_user,1,1,1,1
access_dance_enrollment_user,dance.enrollment user,model_dance_enrollment,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_dance_batch_user dance.batch user model_dance_batch base.group_user 1 1 1 1
3 access_dance_enrollment_user dance.enrollment user model_dance_enrollment base.group_user 1 1 1 1

View File

@ -0,0 +1,106 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_dance_batch_tree" model="ir.ui.view">
<field name="name">dance.batch.tree</field>
<field name="model">dance.batch</field>
<field name="arch" type="xml">
<tree string="Batches">
<field name="name"/>
<field name="dance_style"/>
<field name="level"/>
<field name="instructor_id"/>
<field name="schedule_days"/>
<field name="room"/>
<field name="active_enrollment_count"/>
<field name="capacity"/>
<field name="status"/>
</tree>
</field>
</record>
<record id="view_dance_batch_form" model="ir.ui.view">
<field name="name">dance.batch.form</field>
<field name="model">dance.batch</field>
<field name="arch" type="xml">
<form string="Batch">
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_view_enrollments" type="object" class="oe_stat_button" icon="fa-users">
<field name="active_enrollment_count" widget="statinfo" string="Students"/>
</button>
</div>
<group>
<group string="Basic">
<field name="name"/>
<field name="dance_style"/>
<field name="level"/>
<field name="status"/>
</group>
<group string="Instructor">
<field name="instructor_id"/>
<field name="room"/>
</group>
</group>
<group>
<group string="Schedule">
<field name="start_date"/>
<field name="end_date"/>
<field name="schedule_days"/>
<field name="start_time" widget="float_time"/>
<field name="end_time" widget="float_time"/>
</group>
<group string="Capacity">
<field name="capacity"/>
<field name="active_enrollment_count" readonly="1"/>
<field name="available_seats" readonly="1"/>
<field name="enrollment_count" readonly="1"/>
<field name="is_almost_full" invisible="1"/>
<field name="capacity_warning" readonly="1" invisible="not capacity_warning"/>
</group>
</group>
<notebook>
<page string="Students">
<field name="enrollment_ids" context="{'default_batch_id': id}">
<tree editable="bottom">
<field name="student_id"/>
<field name="enrollment_date"/>
<field name="status"/>
</tree>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="view_dance_batch_search" model="ir.ui.view">
<field name="name">dance.batch.search</field>
<field name="model">dance.batch</field>
<field name="arch" type="xml">
<search string="Batches">
<field name="name"/>
<field name="instructor_id"/>
<field name="room"/>
<filter name="active_batches" string="Active" domain="[('status', '=', 'active')]"/>
<filter name="upcoming_batches" string="Upcoming" domain="[('status', '=', 'upcoming')]"/>
<filter name="completed_batches" string="Completed" domain="[('status', '=', 'completed')]"/>
<group expand="0" string="Group By">
<filter name="group_by_style" string="Dance Style" context="{'group_by': 'dance_style'}"/>
<filter name="group_by_level" string="Level" context="{'group_by': 'level'}"/>
<filter name="group_by_instructor" string="Instructor" context="{'group_by': 'instructor_id'}"/>
<filter name="group_by_status" string="Status" context="{'group_by': 'status'}"/>
</group>
</search>
</field>
</record>
<record id="action_dance_batches" model="ir.actions.act_window">
<field name="name">Batches</field>
<field name="res_model">dance.batch</field>
<field name="view_mode">tree,form</field>
<field name="search_view_id" ref="view_dance_batch_search"/>
</record>
<menuitem id="menu_dance_batches" name="Batches" parent="dance_student.menu_dance_root" action="action_dance_batches" sequence="20"/>
</odoo>

View File

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_dance_enrollment_tree" model="ir.ui.view">
<field name="name">dance.enrollment.tree</field>
<field name="model">dance.enrollment</field>
<field name="arch" type="xml">
<tree string="Enrollments">
<field name="student_id"/>
<field name="batch_id"/>
<field name="enrollment_date"/>
<field name="status"/>
</tree>
</field>
</record>
<record id="view_dance_enrollment_form" model="ir.ui.view">
<field name="name">dance.enrollment.form</field>
<field name="model">dance.enrollment</field>
<field name="arch" type="xml">
<form string="Enrollment">
<sheet>
<group>
<group string="Student">
<field name="student_id"/>
<field name="enrollment_date"/>
</group>
<group string="Batch">
<field name="batch_id"/>
<field name="status"/>
</group>
</group>
<group string="Notes">
<field name="notes" nolabel="1"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_dance_enrollment_search" model="ir.ui.view">
<field name="name">dance.enrollment.search</field>
<field name="model">dance.enrollment</field>
<field name="arch" type="xml">
<search string="Enrollments">
<field name="student_id"/>
<field name="batch_id"/>
<filter name="active_enrollments" string="Active" domain="[('status', '=', 'active')]"/>
<filter name="hold_enrollments" string="Hold" domain="[('status', '=', 'hold')]"/>
<filter name="dropped_enrollments" string="Dropped" domain="[('status', '=', 'dropped')]"/>
<group expand="0" string="Group By">
<filter name="group_by_batch" string="Batch" context="{'group_by': 'batch_id'}"/>
<filter name="group_by_status" string="Status" context="{'group_by': 'status'}"/>
<filter name="group_by_student" string="Student" context="{'group_by': 'student_id'}"/>
</group>
</search>
</field>
</record>
<record id="action_dance_enrollments" model="ir.actions.act_window">
<field name="name">Enrollments</field>
<field name="res_model">dance.enrollment</field>
<field name="view_mode">tree,form</field>
<field name="search_view_id" ref="view_dance_enrollment_search"/>
</record>
<menuitem id="menu_dance_enrollments" name="Enrollments" parent="dance_student.menu_dance_root" action="action_dance_enrollments" sequence="30"/>
</odoo>

View File

@ -0,0 +1 @@
from . import models

View File

@ -0,0 +1,18 @@
{
"name": "Dance Event & Recital Management",
"summary": "Events, performances, rehearsals, costumes, and recital WhatsApp updates",
"version": "17.0.1.0.0",
"category": "Education",
"author": "Metetroncube",
"license": "LGPL-3",
"depends": ["dance_batch", "dance_guardian", "dance_whatsapp"],
"data": [
"security/ir.model.access.csv",
"data/whatsapp_template_data.xml",
"views/dance_event_views.xml",
"views/dance_event_performance_views.xml",
"views/dance_rehearsal_views.xml",
],
"application": False,
"installable": True,
}

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="template_event_notification" model="dance.whatsapp.template">
<field name="name">Event Notification</field>
<field name="trigger">event_notification</field>
<field name="body">{event} is scheduled on {date} at {venue}. Please keep {student} ready for participation.</field>
</record>
<record id="template_rehearsal_reminder" model="dance.whatsapp.template">
<field name="name">Rehearsal Reminder</field>
<field name="trigger">rehearsal_reminder</field>
<field name="body">{event} rehearsal is on {rehearsal_date} at {rehearsal_time} in {room}. Please ensure {student} attends.</field>
</record>
<record id="template_costume_update" model="dance.whatsapp.template">
<field name="name">Costume Update</field>
<field name="trigger">costume_update</field>
<field name="body">{event} costume update for {student}. Please contact the academy for fitting or collection details.</field>
</record>
</odoo>

View File

@ -0,0 +1,4 @@
from . import dance_event
from . import dance_event_performance
from . import dance_rehearsal
from . import dance_whatsapp

View File

@ -0,0 +1,153 @@
from odoo import api, fields, models
class DanceEvent(models.Model):
_name = "dance.event"
_description = "Dance Event"
_order = "date desc, name"
name = fields.Char(string="Event Name", required=True)
event_type = fields.Selection(
[
("annual_day", "Annual Day"),
("competition", "Competition"),
("workshop", "Workshop"),
("audition", "Audition"),
("recital", "Recital"),
("other", "Other"),
],
string="Type",
default="annual_day",
required=True,
)
date = fields.Date(required=True)
venue = fields.Char()
state = fields.Selection(
[
("draft", "Draft"),
("planning", "Planning"),
("rehearsal", "Rehearsal"),
("done", "Done"),
("cancelled", "Cancelled"),
],
default="draft",
required=True,
)
batch_ids = fields.Many2many(
"dance.batch",
"dance_event_batch_rel",
"event_id",
"batch_id",
string="Participating Batches",
)
student_ids = fields.Many2many(
"res.partner",
compute="_compute_student_ids",
string="Participating Students",
)
performance_ids = fields.One2many(
"dance.event.performance",
"event_id",
string="Performances",
)
rehearsal_ids = fields.One2many("dance.rehearsal", "event_id", string="Rehearsals")
performance_count = fields.Integer(compute="_compute_counts")
rehearsal_count = fields.Integer(compute="_compute_counts")
participant_count = fields.Integer(compute="_compute_student_ids")
notes = fields.Text()
@api.depends("batch_ids.enrollment_ids.status", "batch_ids.enrollment_ids.student_id")
def _compute_student_ids(self):
for event in self:
students = event.batch_ids.enrollment_ids.filtered(
lambda enrollment: enrollment.status == "active"
).mapped("student_id")
event.student_ids = students
event.participant_count = len(students)
@api.depends("performance_ids", "rehearsal_ids")
def _compute_counts(self):
for event in self:
event.performance_count = len(event.performance_ids)
event.rehearsal_count = len(event.rehearsal_ids)
def action_start_planning(self):
self.write({"state": "planning"})
return True
def action_start_rehearsals(self):
self.write({"state": "rehearsal"})
return True
def action_done(self):
self.write({"state": "done"})
return True
def action_cancel(self):
self.write({"state": "cancelled"})
return True
def action_create_performance_lines(self):
for event in self:
existing_student_ids = set(event.performance_ids.mapped("student_id").ids)
vals_list = []
for student in event.student_ids:
if student.id in existing_student_ids:
continue
vals_list.append(
{
"event_id": event.id,
"student_id": student.id,
"role": "group",
}
)
if vals_list:
self.env["dance.event.performance"].create(vals_list)
return True
def action_send_event_notification(self):
for event in self:
event._create_whatsapp_logs(
"event_notification",
event.performance_ids.mapped("student_id") or event.student_ids,
)
return True
def _create_whatsapp_logs(self, trigger, students, rehearsal=False, message=False):
template = self.env["dance.whatsapp.template"].search(
[("trigger", "=", trigger), ("active", "=", True)],
limit=1,
)
logs = []
for student in students:
guardians = student.guardian_ids | student.guardian_id
for guardian in guardians:
phone = guardian.mobile or guardian.phone or student.guardian_phone
if not phone:
continue
values = {
"event": self.name,
"student": student.display_name,
"date": self.date or "",
"venue": self.venue or "",
"rehearsal": rehearsal.name if rehearsal else "",
"rehearsal_date": rehearsal.date if rehearsal else "",
"rehearsal_time": rehearsal.display_time if rehearsal else "",
"room": rehearsal.room if rehearsal else "",
}
body = message or (template.render_message(values) if template else self.name)
logs.append(
{
"template_id": template.id if template else False,
"trigger": trigger,
"student_id": student.id,
"guardian_id": guardian.id,
"phone": phone,
"message": body,
"event_id": self.id,
"rehearsal_id": rehearsal.id if rehearsal else False,
}
)
if logs:
self.env["dance.whatsapp.log"].create(logs)
return len(logs)

View File

@ -0,0 +1,84 @@
from odoo import api, fields, models
from odoo.exceptions import ValidationError
class DanceEventPerformance(models.Model):
_name = "dance.event.performance"
_description = "Dance Event Performance"
_order = "event_id, sequence, student_id"
sequence = fields.Integer(default=10)
name = fields.Char(compute="_compute_name", store=True)
event_id = fields.Many2one("dance.event", required=True, ondelete="cascade")
student_id = fields.Many2one(
"res.partner",
required=True,
domain="[('is_student', '=', True)]",
ondelete="restrict",
)
role = fields.Selection(
[
("lead", "Lead Dancer"),
("solo", "Solo"),
("group", "Group Dancer"),
("support", "Support"),
],
default="group",
required=True,
)
costume_needed = fields.Char(string="Costume Needed")
costume_status = fields.Selection(
[
("not_required", "Not Required"),
("needed", "Needed"),
("ordered", "Ordered"),
("fitted", "Fitted"),
("issued", "Issued"),
("returned", "Returned"),
],
default="needed",
required=True,
)
notes = fields.Text()
_sql_constraints = [
(
"event_student_unique",
"unique(event_id, student_id)",
"This student is already assigned to this event.",
),
]
@api.depends("event_id", "student_id", "role")
def _compute_name(self):
for performance in self:
if performance.event_id and performance.student_id:
performance.name = "%s - %s" % (
performance.event_id.name,
performance.student_id.display_name,
)
else:
performance.name = "New Performance"
@api.constrains("event_id", "student_id")
def _check_student_participates(self):
for performance in self:
if performance.student_id and performance.event_id.batch_ids:
if performance.student_id not in performance.event_id.student_ids:
raise ValidationError(
"Only active students from participating batches can be assigned."
)
def action_send_costume_update(self):
for performance in self:
message = "%s costume update for %s: %s." % (
performance.event_id.name,
performance.student_id.display_name,
performance.costume_needed or performance.costume_status,
)
performance.event_id._create_whatsapp_logs(
"costume_update",
performance.student_id,
message=message,
)
return True

View File

@ -0,0 +1,169 @@
from odoo import api, fields, models
from odoo.exceptions import ValidationError
class DanceRehearsal(models.Model):
_name = "dance.rehearsal"
_description = "Dance Event Rehearsal"
_order = "date desc, start_time desc, id desc"
name = fields.Char(compute="_compute_name", store=True)
event_id = fields.Many2one("dance.event", required=True, ondelete="cascade")
date = fields.Date(required=True, default=fields.Date.context_today)
start_time = fields.Float(string="Start Time", required=True)
end_time = fields.Float(string="End Time")
display_time = fields.Char(compute="_compute_display_time")
room = fields.Char()
status = fields.Selection(
[
("scheduled", "Scheduled"),
("completed", "Completed"),
("cancelled", "Cancelled"),
],
default="scheduled",
required=True,
)
attendance_ids = fields.One2many(
"dance.rehearsal.attendance",
"rehearsal_id",
string="Attendance",
)
attendance_count = fields.Integer(compute="_compute_attendance_counts")
present_count = fields.Integer(compute="_compute_attendance_counts")
absent_count = fields.Integer(compute="_compute_attendance_counts")
late_count = fields.Integer(compute="_compute_attendance_counts")
notes = fields.Text()
@api.depends("event_id", "date", "start_time")
def _compute_name(self):
for rehearsal in self:
if rehearsal.event_id and rehearsal.date:
rehearsal.name = "%s rehearsal - %s" % (
rehearsal.event_id.name,
rehearsal.date,
)
else:
rehearsal.name = "New Rehearsal"
@api.depends("start_time", "end_time")
def _compute_display_time(self):
for rehearsal in self:
rehearsal.display_time = rehearsal._float_to_time(rehearsal.start_time)
if rehearsal.end_time:
rehearsal.display_time += " - %s" % rehearsal._float_to_time(rehearsal.end_time)
@api.depends("attendance_ids.status")
def _compute_attendance_counts(self):
for rehearsal in self:
rehearsal.attendance_count = len(rehearsal.attendance_ids)
rehearsal.present_count = len(
rehearsal.attendance_ids.filtered(lambda line: line.status == "present")
)
rehearsal.absent_count = len(
rehearsal.attendance_ids.filtered(lambda line: line.status == "absent")
)
rehearsal.late_count = len(
rehearsal.attendance_ids.filtered(lambda line: line.status == "late")
)
@api.constrains("start_time", "end_time")
def _check_times(self):
for rehearsal in self:
if rehearsal.end_time and rehearsal.start_time and rehearsal.end_time <= rehearsal.start_time:
raise ValidationError("End time must be after start time.")
def action_load_students(self):
for rehearsal in self:
students = rehearsal.event_id.performance_ids.mapped("student_id") or rehearsal.event_id.student_ids
existing_student_ids = set(rehearsal.attendance_ids.mapped("student_id").ids)
vals_list = []
for student in students:
if student.id in existing_student_ids:
continue
vals_list.append(
{
"rehearsal_id": rehearsal.id,
"student_id": student.id,
"status": "present",
}
)
if vals_list:
self.env["dance.rehearsal.attendance"].create(vals_list)
return True
def action_mark_all_present(self):
for rehearsal in self:
if not rehearsal.attendance_ids:
rehearsal.action_load_students()
rehearsal.attendance_ids.write({"status": "present"})
return True
def action_complete(self):
self.write({"status": "completed"})
return True
def action_cancel(self):
self.write({"status": "cancelled"})
return True
def action_send_rehearsal_reminder(self):
for rehearsal in self:
students = rehearsal.attendance_ids.mapped("student_id") or rehearsal.event_id.performance_ids.mapped("student_id") or rehearsal.event_id.student_ids
rehearsal.event_id._create_whatsapp_logs(
"rehearsal_reminder",
students,
rehearsal=rehearsal,
)
return True
def _float_to_time(self, value):
hours = int(value or 0)
minutes = int(round(((value or 0) - hours) * 60))
suffix = "AM" if hours < 12 else "PM"
display_hours = hours % 12 or 12
return "%d:%02d %s" % (display_hours, minutes, suffix)
class DanceRehearsalAttendance(models.Model):
_name = "dance.rehearsal.attendance"
_description = "Dance Rehearsal Attendance"
_order = "rehearsal_id desc, student_id"
rehearsal_id = fields.Many2one("dance.rehearsal", required=True, ondelete="cascade")
event_id = fields.Many2one(related="rehearsal_id.event_id", store=True, readonly=True)
student_id = fields.Many2one(
"res.partner",
required=True,
domain="[('is_student', '=', True)]",
ondelete="restrict",
)
date = fields.Date(related="rehearsal_id.date", store=True, readonly=True)
status = fields.Selection(
[
("present", "Present"),
("absent", "Absent"),
("late", "Late"),
],
default="present",
required=True,
)
remarks = fields.Text()
_sql_constraints = [
(
"unique_rehearsal_attendance",
"unique(student_id, rehearsal_id)",
"Attendance is already marked for this student in this rehearsal.",
),
]
@api.constrains("student_id", "rehearsal_id")
def _check_student_in_event(self):
for attendance in self:
if attendance.student_id and attendance.rehearsal_id:
event = attendance.rehearsal_id.event_id
event_students = event.performance_ids.mapped("student_id") or event.student_ids
if attendance.student_id not in event_students:
raise ValidationError(
"Attendance can only be marked for students participating in this event."
)

View File

@ -0,0 +1,42 @@
from odoo import fields, models
class DanceWhatsappTemplate(models.Model):
_inherit = "dance.whatsapp.template"
trigger = fields.Selection(
selection_add=[
("event_notification", "Event Notification"),
("rehearsal_reminder", "Rehearsal Reminder"),
("costume_update", "Costume Update"),
],
ondelete={
"event_notification": "cascade",
"rehearsal_reminder": "cascade",
"costume_update": "cascade",
},
)
class DanceWhatsappLog(models.Model):
_inherit = "dance.whatsapp.log"
trigger = fields.Selection(
selection_add=[
("event_notification", "Event Notification"),
("rehearsal_reminder", "Rehearsal Reminder"),
("costume_update", "Costume Update"),
],
ondelete={
"event_notification": "cascade",
"rehearsal_reminder": "cascade",
"costume_update": "cascade",
},
)
event_id = fields.Many2one("dance.event", string="Event", ondelete="set null")
performance_id = fields.Many2one(
"dance.event.performance",
string="Performance",
ondelete="set null",
)
rehearsal_id = fields.Many2one("dance.rehearsal", string="Rehearsal", ondelete="set null")

View File

@ -0,0 +1,5 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_dance_event_user,dance.event user,model_dance_event,base.group_user,1,1,1,1
access_dance_event_performance_user,dance.event.performance user,model_dance_event_performance,base.group_user,1,1,1,1
access_dance_rehearsal_user,dance.rehearsal user,model_dance_rehearsal,base.group_user,1,1,1,1
access_dance_rehearsal_attendance_user,dance.rehearsal.attendance user,model_dance_rehearsal_attendance,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_dance_event_user dance.event user model_dance_event base.group_user 1 1 1 1
3 access_dance_event_performance_user dance.event.performance user model_dance_event_performance base.group_user 1 1 1 1
4 access_dance_rehearsal_user dance.rehearsal user model_dance_rehearsal base.group_user 1 1 1 1
5 access_dance_rehearsal_attendance_user dance.rehearsal.attendance user model_dance_rehearsal_attendance base.group_user 1 1 1 1

View File

@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_dance_event_performance_tree" model="ir.ui.view">
<field name="name">dance.event.performance.tree</field>
<field name="model">dance.event.performance</field>
<field name="arch" type="xml">
<tree string="Performances">
<field name="event_id"/>
<field name="student_id"/>
<field name="role"/>
<field name="costume_needed"/>
<field name="costume_status"/>
</tree>
</field>
</record>
<record id="view_dance_event_performance_form" model="ir.ui.view">
<field name="name">dance.event.performance.form</field>
<field name="model">dance.event.performance</field>
<field name="arch" type="xml">
<form string="Performance">
<header>
<button name="action_send_costume_update" string="Send Costume Update" type="object" class="btn-secondary"/>
</header>
<sheet>
<group>
<group>
<field name="event_id"/>
<field name="student_id"/>
<field name="role"/>
</group>
<group>
<field name="costume_needed" placeholder="Classical costume, hip-hop jacket, props"/>
<field name="costume_status"/>
</group>
</group>
<group>
<field name="notes"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_dance_event_performance_search" model="ir.ui.view">
<field name="name">dance.event.performance.search</field>
<field name="model">dance.event.performance</field>
<field name="arch" type="xml">
<search string="Performances">
<field name="event_id"/>
<field name="student_id"/>
<field name="costume_needed"/>
<filter name="costume_needed" string="Costume Needed" domain="[('costume_status', 'in', ['needed', 'ordered'])]"/>
<group expand="0" string="Group By">
<filter name="group_by_event" string="Event" context="{'group_by': 'event_id'}"/>
<filter name="group_by_role" string="Role" context="{'group_by': 'role'}"/>
<filter name="group_by_costume" string="Costume Status" context="{'group_by': 'costume_status'}"/>
</group>
</search>
</field>
</record>
<record id="action_dance_event_performances" model="ir.actions.act_window">
<field name="name">Performances &amp; Costumes</field>
<field name="res_model">dance.event.performance</field>
<field name="view_mode">tree,form</field>
<field name="search_view_id" ref="view_dance_event_performance_search"/>
</record>
<menuitem id="menu_dance_event_performances" name="Performances &amp; Costumes" parent="menu_dance_event_root" action="action_dance_event_performances" sequence="20"/>
</odoo>

View File

@ -0,0 +1,120 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_dance_event_tree" model="ir.ui.view">
<field name="name">dance.event.tree</field>
<field name="model">dance.event</field>
<field name="arch" type="xml">
<tree string="Events" decoration-success="state == 'done'" decoration-muted="state == 'cancelled'">
<field name="name"/>
<field name="event_type"/>
<field name="date"/>
<field name="venue"/>
<field name="participant_count"/>
<field name="performance_count"/>
<field name="rehearsal_count"/>
<field name="state"/>
</tree>
</field>
</record>
<record id="view_dance_event_form" model="ir.ui.view">
<field name="name">dance.event.form</field>
<field name="model">dance.event</field>
<field name="arch" type="xml">
<form string="Dance Event">
<header>
<button name="action_start_planning" string="Start Planning" type="object" class="btn-primary" invisible="state != 'draft'"/>
<button name="action_start_rehearsals" string="Start Rehearsals" type="object" class="btn-primary" invisible="state not in ('draft', 'planning')"/>
<button name="action_done" string="Mark Done" type="object" invisible="state not in ('planning', 'rehearsal')"/>
<button name="action_cancel" string="Cancel" type="object" invisible="state in ('done', 'cancelled')"/>
<button name="action_send_event_notification" string="Send Event Notification" type="object" class="btn-secondary"/>
<field name="state" widget="statusbar" statusbar_visible="draft,planning,rehearsal,done"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button class="oe_stat_button" type="object" name="action_create_performance_lines" icon="fa-users">
<field name="participant_count" widget="statinfo" string="Students"/>
</button>
<button class="oe_stat_button" type="object" name="action_create_performance_lines" icon="fa-star">
<field name="performance_count" widget="statinfo" string="Performances"/>
</button>
</div>
<div class="oe_title">
<label for="name"/>
<h1><field name="name" placeholder="Annual Day 2026"/></h1>
</div>
<group>
<group>
<field name="event_type"/>
<field name="date"/>
<field name="venue" placeholder="Auditorium or studio"/>
</group>
<group>
<field name="batch_ids" widget="many2many_tags"/>
<field name="student_ids" widget="many2many_tags" readonly="1"/>
</group>
</group>
<notebook>
<page string="Performances">
<button name="action_create_performance_lines" string="Load Students from Batches" type="object" class="btn-secondary"/>
<field name="performance_ids" context="{'default_event_id': id}">
<tree editable="bottom">
<field name="sequence" widget="handle"/>
<field name="student_id"/>
<field name="role"/>
<field name="costume_needed"/>
<field name="costume_status"/>
</tree>
</field>
</page>
<page string="Rehearsals">
<field name="rehearsal_ids" context="{'default_event_id': id}">
<tree>
<field name="date"/>
<field name="display_time"/>
<field name="room"/>
<field name="attendance_count"/>
<field name="absent_count"/>
<field name="status"/>
</tree>
</field>
</page>
<page string="Notes">
<field name="notes" placeholder="Competition rules, stage plan, call time, props, or backstage notes."/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="view_dance_event_search" model="ir.ui.view">
<field name="name">dance.event.search</field>
<field name="model">dance.event</field>
<field name="arch" type="xml">
<search string="Events">
<field name="name"/>
<field name="venue"/>
<field name="batch_ids"/>
<filter name="draft" string="Draft" domain="[('state', '=', 'draft')]"/>
<filter name="rehearsal" string="In Rehearsal" domain="[('state', '=', 'rehearsal')]"/>
<filter name="done" string="Done" domain="[('state', '=', 'done')]"/>
<group expand="0" string="Group By">
<filter name="group_by_type" string="Type" context="{'group_by': 'event_type'}"/>
<filter name="group_by_state" string="Status" context="{'group_by': 'state'}"/>
<filter name="group_by_date" string="Date" context="{'group_by': 'date'}"/>
</group>
</search>
</field>
</record>
<record id="action_dance_events" model="ir.actions.act_window">
<field name="name">Events &amp; Recitals</field>
<field name="res_model">dance.event</field>
<field name="view_mode">tree,form</field>
<field name="search_view_id" ref="view_dance_event_search"/>
</record>
<menuitem id="menu_dance_event_root" name="Events &amp; Recitals" parent="dance_student.menu_dance_root" sequence="35"/>
<menuitem id="menu_dance_events" name="Events" parent="menu_dance_event_root" action="action_dance_events" sequence="10"/>
</odoo>

View File

@ -0,0 +1,100 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_dance_rehearsal_tree" model="ir.ui.view">
<field name="name">dance.rehearsal.tree</field>
<field name="model">dance.rehearsal</field>
<field name="arch" type="xml">
<tree string="Rehearsals" decoration-success="status == 'completed'" decoration-muted="status == 'cancelled'">
<field name="event_id"/>
<field name="date"/>
<field name="display_time"/>
<field name="room"/>
<field name="attendance_count"/>
<field name="present_count"/>
<field name="absent_count"/>
<field name="status"/>
</tree>
</field>
</record>
<record id="view_dance_rehearsal_form" model="ir.ui.view">
<field name="name">dance.rehearsal.form</field>
<field name="model">dance.rehearsal</field>
<field name="arch" type="xml">
<form string="Rehearsal">
<header>
<button name="action_load_students" string="Load Students" type="object" class="btn-secondary"/>
<button name="action_mark_all_present" string="Mark All Present" type="object" class="btn-secondary"/>
<button name="action_send_rehearsal_reminder" string="Send Reminder" type="object" class="btn-secondary"/>
<button name="action_complete" string="Complete" type="object" class="btn-primary" invisible="status != 'scheduled'"/>
<button name="action_cancel" string="Cancel" type="object" invisible="status == 'cancelled'"/>
<field name="status" widget="statusbar" statusbar_visible="scheduled,completed"/>
</header>
<sheet>
<div class="oe_title">
<label for="event_id"/>
<h1><field name="event_id"/></h1>
</div>
<group>
<group>
<field name="date"/>
<field name="start_time" widget="float_time"/>
<field name="end_time" widget="float_time"/>
</group>
<group>
<field name="room"/>
<field name="display_time" readonly="1"/>
</group>
</group>
<notebook>
<page string="Attendance">
<group>
<field name="attendance_count" readonly="1"/>
<field name="present_count" readonly="1"/>
<field name="absent_count" readonly="1"/>
<field name="late_count" readonly="1"/>
</group>
<field name="attendance_ids" context="{'default_rehearsal_id': id}">
<tree editable="bottom" decoration-danger="status == 'absent'" decoration-warning="status == 'late'">
<field name="student_id"/>
<field name="status"/>
<field name="remarks"/>
</tree>
</field>
</page>
<page string="Notes">
<field name="notes"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="view_dance_rehearsal_search" model="ir.ui.view">
<field name="name">dance.rehearsal.search</field>
<field name="model">dance.rehearsal</field>
<field name="arch" type="xml">
<search string="Rehearsals">
<field name="event_id"/>
<field name="room"/>
<filter name="scheduled" string="Scheduled" domain="[('status', '=', 'scheduled')]"/>
<filter name="completed" string="Completed" domain="[('status', '=', 'completed')]"/>
<group expand="0" string="Group By">
<filter name="group_by_event" string="Event" context="{'group_by': 'event_id'}"/>
<filter name="group_by_date" string="Date" context="{'group_by': 'date'}"/>
<filter name="group_by_status" string="Status" context="{'group_by': 'status'}"/>
</group>
</search>
</field>
</record>
<record id="action_dance_rehearsals" model="ir.actions.act_window">
<field name="name">Rehearsals</field>
<field name="res_model">dance.rehearsal</field>
<field name="view_mode">tree,form</field>
<field name="search_view_id" ref="view_dance_rehearsal_search"/>
</record>
<menuitem id="menu_dance_rehearsals" name="Rehearsals" parent="menu_dance_event_root" action="action_dance_rehearsals" sequence="30"/>
</odoo>

View File

@ -0,0 +1 @@
from . import models

View File

@ -0,0 +1,19 @@
{
"name": "Dance Fees Management",
"summary": "Fee structures and student invoice flow for dance batches",
"version": "17.0.1.0.0",
"category": "Education",
"author": "Metetroncube",
"license": "LGPL-3",
"depends": ["dance_batch", "account"],
"data": [
"security/dance_fees_security.xml",
"security/ir.model.access.csv",
"data/sample_data.xml",
"views/dance_fee_structure_views.xml",
"views/dance_enrollment_fee_views.xml",
"views/account_move_views.xml",
],
"application": False,
"installable": True,
}

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="fee_structure_bharatanatyam_beginner_monthly" model="dance.fee.structure">
<field name="name">Bharatanatyam Beginner Monthly</field>
<field name="dance_style">bharatanatyam</field>
<field name="level">beginner</field>
<field name="amount">2000.0</field>
<field name="duration">monthly</field>
</record>
<record id="fee_structure_hiphop_intermediate_monthly" model="dance.fee.structure">
<field name="name">Hip-Hop Intermediate Monthly</field>
<field name="dance_style">hiphop</field>
<field name="level">intermediate</field>
<field name="amount">2500.0</field>
<field name="duration">monthly</field>
</record>
<record id="dance_batch.batch_bharatanatyam_beginner_sat" model="dance.batch">
<field name="fee_structure_id" ref="fee_structure_bharatanatyam_beginner_monthly"/>
</record>
<record id="dance_batch.batch_hiphop_intermediate_monwed" model="dance.batch">
<field name="fee_structure_id" ref="fee_structure_hiphop_intermediate_monthly"/>
</record>
</odoo>

View File

@ -0,0 +1,4 @@
from . import dance_fee_structure
from . import dance_enrollment
from . import dance_batch
from . import account_move

View File

@ -0,0 +1,48 @@
from odoo import api, fields, models
from odoo.exceptions import ValidationError
class AccountMove(models.Model):
_inherit = "account.move"
dance_student_id = fields.Many2one("res.partner", string="Student", ondelete="restrict")
dance_batch_id = fields.Many2one("dance.batch", string="Batch", ondelete="restrict")
dance_enrollment_id = fields.Many2one(
"dance.enrollment",
string="Enrollment",
ondelete="restrict",
)
dance_fee_structure_id = fields.Many2one(
"dance.fee.structure",
string="Fee Structure",
ondelete="restrict",
)
dance_period_start = fields.Date(string="Fee Period Start")
dance_period_end = fields.Date(string="Fee Period End")
@api.constrains("move_type", "dance_enrollment_id", "dance_period_start", "dance_period_end", "state")
def _check_duplicate_fee_invoice(self):
for move in self:
if (
move.move_type != "out_invoice"
or not move.dance_enrollment_id
or not move.dance_period_start
or not move.dance_period_end
or move.state == "cancel"
):
continue
duplicate = self.search(
[
("id", "!=", move.id),
("move_type", "=", "out_invoice"),
("dance_enrollment_id", "=", move.dance_enrollment_id.id),
("dance_period_start", "=", move.dance_period_start),
("dance_period_end", "=", move.dance_period_end),
("state", "!=", "cancel"),
],
limit=1,
)
if duplicate:
raise ValidationError(
"Only one active invoice is allowed per enrollment and fee period."
)

View File

@ -0,0 +1,57 @@
from odoo import fields, models
from odoo.exceptions import ValidationError
class DanceBatch(models.Model):
_inherit = "dance.batch"
fee_structure_id = fields.Many2one(
"dance.fee.structure",
string="Fee Structure",
ondelete="restrict",
)
invoice_count = fields.Integer(compute="_compute_invoice_count")
def _compute_invoice_count(self):
grouped = self.env["account.move"].read_group(
[
("dance_batch_id", "in", self.ids),
("move_type", "=", "out_invoice"),
],
["dance_batch_id"],
["dance_batch_id"],
)
count_map = {
record["dance_batch_id"][0]: record["dance_batch_id_count"]
for record in grouped
}
for batch in self:
batch.invoice_count = count_map.get(batch.id, 0)
def action_view_invoices(self):
self.ensure_one()
return {
"name": "Invoices",
"type": "ir.actions.act_window",
"res_model": "account.move",
"view_mode": "tree,form",
"domain": [
("dance_batch_id", "=", self.id),
("move_type", "=", "out_invoice"),
],
"context": {
"default_move_type": "out_invoice",
"default_dance_batch_id": self.id,
},
}
def action_generate_batch_invoices(self):
for batch in self:
active_enrollments = batch.enrollment_ids.filtered(lambda rec: rec.status == "active")
if not batch.fee_structure_id:
raise ValidationError(
"Set a fee structure on the batch before generating invoices."
)
for enrollment in active_enrollments:
enrollment.action_create_fee_invoice()
return self.action_view_invoices()

View File

@ -0,0 +1,406 @@
from datetime import date
from calendar import monthrange
from odoo import api, fields, models
from odoo.exceptions import ValidationError
class DanceEnrollment(models.Model):
_inherit = "dance.enrollment"
invoice_count = fields.Integer(compute="_compute_invoice_count")
last_invoice_id = fields.Many2one("account.move", compute="_compute_invoice_count")
discount_type = fields.Selection(
[
("none", "No Discount"),
("sibling", "Sibling"),
("scholarship", "Scholarship"),
("custom", "Custom"),
],
default="none",
required=True,
)
discount_percent = fields.Float(string="Discount %")
apply_prorata = fields.Boolean(string="Apply Pro-rata", default=True)
waiver_state = fields.Selection(
[
("none", "No Waiver"),
("requested", "Requested"),
("approved", "Approved"),
("rejected", "Rejected"),
],
default="none",
required=True,
)
waiver_amount = fields.Monetary(currency_field="currency_id")
waiver_reason = fields.Text()
waiver_requested_by_id = fields.Many2one(
"res.users",
string="Waiver Requested By",
readonly=True,
)
waiver_requested_date = fields.Datetime(string="Waiver Requested On", readonly=True)
waiver_approved_by_id = fields.Many2one(
"res.users",
string="Waiver Approved By",
readonly=True,
)
waiver_approved_date = fields.Datetime(string="Waiver Approved On", readonly=True)
waiver_rejected_by_id = fields.Many2one(
"res.users",
string="Waiver Rejected By",
readonly=True,
)
waiver_rejected_date = fields.Datetime(string="Waiver Rejected On", readonly=True)
currency_id = fields.Many2one(
"res.currency",
related="batch_id.fee_structure_id.currency_id",
readonly=True,
)
base_fee_amount = fields.Monetary(
compute="_compute_fee_preview",
currency_field="currency_id",
)
discount_amount = fields.Monetary(
compute="_compute_fee_preview",
currency_field="currency_id",
)
prorata_amount = fields.Monetary(
compute="_compute_fee_preview",
currency_field="currency_id",
)
approved_waiver_amount = fields.Monetary(
compute="_compute_fee_preview",
currency_field="currency_id",
)
final_fee_amount = fields.Monetary(
compute="_compute_fee_preview",
currency_field="currency_id",
)
@api.depends(
"batch_id.fee_structure_id.amount",
"discount_percent",
"apply_prorata",
"enrollment_date",
"waiver_state",
"waiver_amount",
)
def _compute_fee_preview(self):
for enrollment in self:
if not enrollment.batch_id.fee_structure_id:
enrollment.base_fee_amount = 0.0
enrollment.discount_amount = 0.0
enrollment.prorata_amount = 0.0
enrollment.approved_waiver_amount = 0.0
enrollment.final_fee_amount = 0.0
continue
amounts = enrollment._get_invoice_amounts()
enrollment.base_fee_amount = amounts["base_amount"]
enrollment.discount_amount = amounts["discount_amount"]
enrollment.prorata_amount = amounts["prorata_amount"]
enrollment.approved_waiver_amount = amounts["waiver_amount"]
enrollment.final_fee_amount = amounts["final_amount"]
@api.onchange("discount_type")
def _onchange_discount_type(self):
defaults = {
"none": 0.0,
"sibling": 10.0,
"scholarship": 50.0,
}
if self.discount_type in defaults:
self.discount_percent = defaults[self.discount_type]
@api.constrains("discount_percent", "waiver_amount")
def _check_discount_and_waiver_amounts(self):
for enrollment in self:
if enrollment.discount_percent < 0 or enrollment.discount_percent > 100:
raise ValidationError("Discount percentage must be between 0 and 100.")
if enrollment.waiver_amount < 0:
raise ValidationError("Waiver amount cannot be negative.")
@api.constrains("discount_type", "student_id")
def _check_sibling_discount(self):
for enrollment in self:
if (
enrollment.discount_type == "sibling"
and enrollment.student_id
and not enrollment.student_id.sibling_ids
):
raise ValidationError(
"Sibling discount can only be used for students with linked siblings."
)
def _compute_invoice_count(self):
grouped = self.env["account.move"].read_group(
[
("dance_enrollment_id", "in", self.ids),
("move_type", "=", "out_invoice"),
],
["dance_enrollment_id"],
["dance_enrollment_id"],
)
count_map = {
record["dance_enrollment_id"][0]: record["dance_enrollment_id_count"]
for record in grouped
if record["dance_enrollment_id"]
}
latest_invoice_map = {}
invoices = self.env["account.move"].search(
[
("dance_enrollment_id", "in", self.ids),
("move_type", "=", "out_invoice"),
],
order="invoice_date desc, id desc",
)
for invoice in invoices:
latest_invoice_map.setdefault(invoice.dance_enrollment_id.id, invoice.id)
for enrollment in self:
enrollment.invoice_count = count_map.get(enrollment.id, 0)
enrollment.last_invoice_id = latest_invoice_map.get(enrollment.id)
def action_view_invoices(self):
self.ensure_one()
return {
"name": "Invoices",
"type": "ir.actions.act_window",
"res_model": "account.move",
"view_mode": "tree,form",
"domain": [
("dance_enrollment_id", "=", self.id),
("move_type", "=", "out_invoice"),
],
"context": {
"default_move_type": "out_invoice",
"default_partner_id": self.student_id.id,
"default_dance_batch_id": self.batch_id.id,
"default_dance_student_id": self.student_id.id,
"default_dance_enrollment_id": self.id,
},
}
def action_create_fee_invoice(self):
self.ensure_one()
if self.status != "active":
raise ValidationError("Only active enrollments can generate invoices.")
if not self.batch_id.fee_structure_id:
raise ValidationError("This batch does not have a fee structure.")
self._check_sibling_discount_before_invoice()
if self.waiver_state == "requested":
raise ValidationError("Approve or reject the fee waiver before creating an invoice.")
period_start, period_end = self._get_invoice_period()
existing = self.env["account.move"].search(
[
("move_type", "=", "out_invoice"),
("dance_enrollment_id", "=", self.id),
("dance_period_start", "=", period_start),
("dance_period_end", "=", period_end),
("state", "!=", "cancel"),
],
limit=1,
)
if existing:
raise ValidationError("An invoice already exists for this enrollment and period.")
fee_structure = self.batch_id.fee_structure_id
amounts = self._get_invoice_amounts(period_start, period_end)
income_account = self._get_income_account()
sales_journal = self._get_sales_journal()
invoice = self.env["account.move"].create(
{
"move_type": "out_invoice",
"journal_id": sales_journal.id,
"partner_id": self.student_id.id,
"invoice_date": fields.Date.today(),
"dance_student_id": self.student_id.id,
"dance_batch_id": self.batch_id.id,
"dance_enrollment_id": self.id,
"dance_fee_structure_id": fee_structure.id,
"dance_period_start": period_start,
"dance_period_end": period_end,
"invoice_line_ids": self._get_invoice_line_values(amounts, income_account),
}
)
return {
"name": "Invoice",
"type": "ir.actions.act_window",
"res_model": "account.move",
"res_id": invoice.id,
"view_mode": "form",
}
def action_request_fee_waiver(self):
self.write(
{
"waiver_state": "requested",
"waiver_requested_by_id": self.env.user.id,
"waiver_requested_date": fields.Datetime.now(),
"waiver_approved_by_id": False,
"waiver_approved_date": False,
"waiver_rejected_by_id": False,
"waiver_rejected_date": False,
}
)
return True
def action_approve_fee_waiver(self):
self._check_fee_waiver_approval_access()
for enrollment in self:
if not enrollment.waiver_amount:
raise ValidationError("Set a waiver amount before approving.")
self.write(
{
"waiver_state": "approved",
"waiver_approved_by_id": self.env.user.id,
"waiver_approved_date": fields.Datetime.now(),
"waiver_rejected_by_id": False,
"waiver_rejected_date": False,
}
)
return True
def action_reject_fee_waiver(self):
self._check_fee_waiver_approval_access()
self.write(
{
"waiver_state": "rejected",
"waiver_rejected_by_id": self.env.user.id,
"waiver_rejected_date": fields.Datetime.now(),
"waiver_approved_by_id": False,
"waiver_approved_date": False,
}
)
return True
def _check_fee_waiver_approval_access(self):
if not (
self.env.user.has_group("dance_fees.group_dance_fee_owner")
or self.env.user.has_group("account.group_account_manager")
):
raise ValidationError("Only a fee owner or accounting manager can approve waivers.")
def _check_sibling_discount_before_invoice(self):
self.ensure_one()
if self.discount_type == "sibling" and not self.student_id.sibling_ids:
raise ValidationError(
"Sibling discount can only be used for students with linked siblings."
)
def _get_invoice_period(self):
self.ensure_one()
today = fields.Date.context_today(self)
fee_structure = self.batch_id.fee_structure_id
if fee_structure.duration == "quarterly":
quarter = ((today.month - 1) // 3) * 3 + 1
period_start = date(today.year, quarter, 1)
period_end = date(today.year, quarter + 2, monthrange(today.year, quarter + 2)[1])
return period_start, period_end
period_start = date(today.year, today.month, 1)
period_end = date(today.year, today.month, monthrange(today.year, today.month)[1])
return period_start, period_end
def _get_invoice_amounts(self, period_start=False, period_end=False):
self.ensure_one()
if not period_start or not period_end:
period_start, period_end = self._get_invoice_period()
fee_structure = self.batch_id.fee_structure_id
currency = fee_structure.currency_id or self.env.company.currency_id
base_amount = fee_structure.amount
discount_amount = currency.round(base_amount * (self.discount_percent / 100.0))
after_discount = max(base_amount - discount_amount, 0.0)
prorata_amount = 0.0
prorata_factor = 1.0
if (
self.apply_prorata
and self.enrollment_date
and period_start < self.enrollment_date <= period_end
):
total_days = (period_end - period_start).days + 1
remaining_days = (period_end - self.enrollment_date).days + 1
prorata_factor = remaining_days / total_days if total_days else 1.0
prorated_total = currency.round(after_discount * prorata_factor)
prorata_amount = currency.round(after_discount - prorated_total)
after_discount = prorated_total
waiver_amount = 0.0
if self.waiver_state == "approved":
waiver_amount = min(self.waiver_amount, after_discount)
final_amount = currency.round(max(after_discount - waiver_amount, 0.0))
return {
"base_amount": currency.round(base_amount),
"discount_amount": discount_amount,
"prorata_amount": prorata_amount,
"waiver_amount": currency.round(waiver_amount),
"final_amount": final_amount,
"prorata_factor": prorata_factor,
}
def _get_invoice_line_name(self, amounts):
self.ensure_one()
fee_structure = self.batch_id.fee_structure_id
parts = ["%s - %s fee" % (self.batch_id.name, fee_structure.name)]
if self.discount_amount:
parts.append("%s%% %s discount" % (self.discount_percent, self.discount_type))
if amounts["prorata_amount"]:
parts.append("pro-rata applied")
if amounts["waiver_amount"]:
parts.append("approved waiver applied")
return " | ".join(parts)
def _get_invoice_line_values(self, amounts, income_account):
self.ensure_one()
fee_structure = self.batch_id.fee_structure_id
tax_commands = [(6, 0, fee_structure.tax_ids.ids)] if fee_structure.tax_ids else False
def line_values(name, price_unit):
values = {
"name": name,
"quantity": 1,
"price_unit": price_unit,
"account_id": income_account.id,
}
if tax_commands:
values["tax_ids"] = tax_commands
return (0, 0, values)
lines = [
line_values(
"%s - %s fee" % (self.batch_id.name, fee_structure.name),
amounts["base_amount"],
)
]
if amounts["discount_amount"]:
lines.append(
line_values(
"%s%% %s discount" % (self.discount_percent, self.discount_type),
-amounts["discount_amount"],
)
)
if amounts["prorata_amount"]:
lines.append(line_values("Pro-rata adjustment", -amounts["prorata_amount"]))
if amounts["waiver_amount"]:
lines.append(line_values("Approved fee waiver", -amounts["waiver_amount"]))
return lines
def _get_sales_journal(self):
journal = self.env["account.journal"].search(
[("type", "=", "sale"), ("company_id", "=", self.env.company.id)],
limit=1,
)
if not journal:
raise ValidationError("Create a sales journal before generating invoices.")
return journal
def _get_income_account(self):
account = self.env["account.account"].search(
[
("account_type", "=", "income"),
("deprecated", "=", False),
("company_id", "=", self.env.company.id),
],
limit=1,
)
if not account:
raise ValidationError("Create an income account before generating invoices.")
return account

View File

@ -0,0 +1,48 @@
from odoo import fields, models
class DanceFeeStructure(models.Model):
_name = "dance.fee.structure"
_description = "Dance Fee Structure"
_order = "name"
name = fields.Char(required=True)
dance_style = fields.Selection(
[
("bharatanatyam", "Bharatanatyam"),
("kathak", "Kathak"),
("hiphop", "Hip-Hop"),
("contemporary", "Contemporary"),
("zumba", "Zumba"),
],
string="Dance Style",
required=True,
)
level = fields.Selection(
[
("beginner", "Beginner"),
("intermediate", "Intermediate"),
("advanced", "Advanced"),
],
required=True,
)
amount = fields.Float(required=True)
tax_ids = fields.Many2many(
"account.tax",
string="Taxes",
domain="[('type_tax_use', '=', 'sale')]",
)
duration = fields.Selection(
[
("monthly", "Monthly"),
("quarterly", "Quarterly"),
],
default="monthly",
required=True,
)
currency_id = fields.Many2one(
"res.currency",
default=lambda self: self.env.company.currency_id.id,
required=True,
)
active = fields.Boolean(default=True)

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="group_dance_fee_owner" model="res.groups">
<field name="name">Dance Fee Owner</field>
<field name="category_id" ref="base.module_category_accounting"/>
</record>
</odoo>

View File

@ -0,0 +1,2 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_dance_fee_structure_user,dance.fee.structure user,model_dance_fee_structure,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_dance_fee_structure_user dance.fee.structure user model_dance_fee_structure base.group_user 1 1 1 1

View File

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_move_form_dance_fee" model="ir.ui.view">
<field name="name">account.move.form.dance.fee</field>
<field name="model">account.move</field>
<field name="inherit_id" ref="account.view_move_form"/>
<field name="arch" type="xml">
<xpath expr="//sheet/group" position="after">
<group string="Dance Fee Details" invisible="move_type != 'out_invoice'">
<group>
<field name="dance_student_id"/>
<field name="dance_batch_id"/>
<field name="dance_enrollment_id"/>
</group>
<group>
<field name="dance_fee_structure_id"/>
<field name="dance_period_start"/>
<field name="dance_period_end"/>
</group>
</group>
</xpath>
</field>
</record>
<record id="view_out_invoice_tree_dance_fee" model="ir.ui.view">
<field name="name">account.move.out.invoice.tree.dance.fee</field>
<field name="model">account.move</field>
<field name="inherit_id" ref="account.view_out_invoice_tree"/>
<field name="arch" type="xml">
<xpath expr="//tree" position="inside">
<field name="dance_student_id" optional="hide"/>
<field name="dance_batch_id" optional="hide"/>
<field name="dance_period_start" optional="hide"/>
<field name="dance_period_end" optional="hide"/>
</xpath>
</field>
</record>
</odoo>

View File

@ -0,0 +1,81 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_dance_batch_form_fee" model="ir.ui.view">
<field name="name">dance.batch.form.fee</field>
<field name="model">dance.batch</field>
<field name="inherit_id" ref="dance_batch.view_dance_batch_form"/>
<field name="arch" type="xml">
<xpath expr="//div[@name='button_box']" position="inside">
<button name="action_view_invoices" type="object" class="oe_stat_button" icon="fa-file-text-o">
<field name="invoice_count" widget="statinfo" string="Invoices"/>
</button>
</xpath>
<xpath expr="//notebook" position="before">
<group string="Billing">
<field name="fee_structure_id"/>
<button name="action_generate_batch_invoices" type="object" string="Generate Invoices" class="btn-primary" invisible="not fee_structure_id"/>
</group>
</xpath>
</field>
</record>
<record id="view_dance_enrollment_tree_fee" model="ir.ui.view">
<field name="name">dance.enrollment.tree.fee</field>
<field name="model">dance.enrollment</field>
<field name="inherit_id" ref="dance_batch.view_dance_enrollment_tree"/>
<field name="arch" type="xml">
<xpath expr="//tree" position="inside">
<field name="invoice_count"/>
</xpath>
</field>
</record>
<record id="view_dance_enrollment_form_fee" model="ir.ui.view">
<field name="name">dance.enrollment.form.fee</field>
<field name="model">dance.enrollment</field>
<field name="inherit_id" ref="dance_batch.view_dance_enrollment_form"/>
<field name="arch" type="xml">
<xpath expr="//sheet" position="before">
<header>
<button name="action_create_fee_invoice" type="object" string="Create Invoice" class="btn-primary" invisible="status != 'active'"/>
<button name="action_view_invoices" type="object" string="View Invoices" invisible="invoice_count == 0"/>
<button name="action_request_fee_waiver" type="object" string="Request Waiver" invisible="waiver_state != 'none'"/>
<button name="action_approve_fee_waiver" type="object" string="Approve Waiver" class="btn-primary" groups="dance_fees.group_dance_fee_owner,account.group_account_manager" invisible="waiver_state != 'requested'"/>
<button name="action_reject_fee_waiver" type="object" string="Reject Waiver" groups="dance_fees.group_dance_fee_owner,account.group_account_manager" invisible="waiver_state != 'requested'"/>
</header>
</xpath>
<xpath expr="//field[@name='status']" position="after">
<field name="invoice_count" readonly="1"/>
<field name="last_invoice_id" readonly="1"/>
</xpath>
<xpath expr="//field[@name='notes']/ancestor::group[1]" position="before">
<group string="Discounts &amp; Advanced Fees">
<group string="Discount">
<field name="discount_type"/>
<field name="discount_percent"/>
<field name="apply_prorata"/>
</group>
<group string="Fee Waiver">
<field name="waiver_state" readonly="1"/>
<field name="waiver_amount"/>
<field name="waiver_reason" placeholder="Reason for owner approval"/>
<field name="waiver_requested_by_id" readonly="1"/>
<field name="waiver_requested_date" readonly="1"/>
<field name="waiver_approved_by_id" readonly="1"/>
<field name="waiver_approved_date" readonly="1"/>
<field name="waiver_rejected_by_id" readonly="1"/>
<field name="waiver_rejected_date" readonly="1"/>
</group>
</group>
<group string="Fee Preview">
<field name="currency_id" invisible="1"/>
<field name="base_fee_amount" readonly="1"/>
<field name="discount_amount" readonly="1"/>
<field name="prorata_amount" readonly="1"/>
<field name="approved_waiver_amount" readonly="1"/>
<field name="final_fee_amount" readonly="1"/>
</group>
</xpath>
</field>
</record>
</odoo>

View File

@ -0,0 +1,80 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_dance_fee_structure_tree" model="ir.ui.view">
<field name="name">dance.fee.structure.tree</field>
<field name="model">dance.fee.structure</field>
<field name="arch" type="xml">
<tree string="Fee Structures">
<field name="name"/>
<field name="dance_style"/>
<field name="level"/>
<field name="duration"/>
<field name="amount"/>
<field name="tax_ids" widget="many2many_tags" optional="hide"/>
<field name="currency_id" optional="hide"/>
</tree>
</field>
</record>
<record id="view_dance_fee_structure_form" model="ir.ui.view">
<field name="name">dance.fee.structure.form</field>
<field name="model">dance.fee.structure</field>
<field name="arch" type="xml">
<form string="Fee Structure">
<sheet>
<group>
<group string="Fee">
<field name="name"/>
<field name="dance_style"/>
<field name="level"/>
</group>
<group string="Amount">
<field name="duration"/>
<field name="amount"/>
<field name="tax_ids" widget="many2many_tags"/>
<field name="currency_id"/>
<field name="active"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_dance_fee_structure_search" model="ir.ui.view">
<field name="name">dance.fee.structure.search</field>
<field name="model">dance.fee.structure</field>
<field name="arch" type="xml">
<search string="Fee Structures">
<field name="name"/>
<filter name="monthly" string="Monthly" domain="[('duration', '=', 'monthly')]"/>
<filter name="quarterly" string="Quarterly" domain="[('duration', '=', 'quarterly')]"/>
<group expand="0" string="Group By">
<filter name="group_by_style" string="Dance Style" context="{'group_by': 'dance_style'}"/>
<filter name="group_by_level" string="Level" context="{'group_by': 'level'}"/>
<filter name="group_by_duration" string="Duration" context="{'group_by': 'duration'}"/>
</group>
</search>
</field>
</record>
<record id="action_dance_fee_structures" model="ir.actions.act_window">
<field name="name">Fee Structures</field>
<field name="res_model">dance.fee.structure</field>
<field name="view_mode">tree,form</field>
<field name="search_view_id" ref="view_dance_fee_structure_search"/>
</record>
<record id="view_dance_batch_fee_structure_form" model="ir.ui.view">
<field name="name">dance.batch.fee.structure.form</field>
<field name="model">dance.batch</field>
<field name="inherit_id" ref="dance_batch.view_dance_batch_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='room']" position="after">
<field name="fee_structure_id"/>
</xpath>
</field>
</record>
<menuitem id="menu_dance_fee_structures" name="Fee Structures" parent="dance_student.menu_dance_root" action="action_dance_fee_structures" sequence="60"/>
</odoo>

View File

@ -0,0 +1 @@
from . import models

View File

@ -0,0 +1,14 @@
{
"name": "Dance Guardian Management",
"summary": "Guardian contacts and student linking for dance school portal",
"version": "17.0.1.0.0",
"category": "Education",
"author": "Metetroncube",
"license": "LGPL-3",
"depends": ["dance_student"],
"data": [
"views/res_partner_guardian_views.xml",
],
"application": False,
"installable": True,
}

View File

@ -0,0 +1 @@
from . import res_partner

Some files were not shown because too many files have changed in this diff Show More