first commit
This commit is contained in:
commit
51fca0a7de
20
.claude/settings.json
Normal file
20
.claude/settings.json
Normal 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
12
.env
Normal 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
12
.env.example
Normal 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
17
.mcp.json
Normal 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
7
Dockerfile
Normal 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
209
README.md
Normal 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.
|
||||
1
addons/dance_attendance/__init__.py
Normal file
1
addons/dance_attendance/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from . import models
|
||||
17
addons/dance_attendance/__manifest__.py
Normal file
17
addons/dance_attendance/__manifest__.py
Normal 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,
|
||||
}
|
||||
BIN
addons/dance_attendance/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
addons/dance_attendance/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
addons/dance_attendance/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
addons/dance_attendance/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
addons/dance_attendance/__pycache__/__manifest__.cpython-311.pyc
Normal file
BIN
addons/dance_attendance/__pycache__/__manifest__.cpython-311.pyc
Normal file
Binary file not shown.
21
addons/dance_attendance/data/sample_data.xml
Normal file
21
addons/dance_attendance/data/sample_data.xml
Normal 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>
|
||||
2
addons/dance_attendance/models/__init__.py
Normal file
2
addons/dance_attendance/models/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from . import dance_session
|
||||
from . import dance_attendance
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
92
addons/dance_attendance/models/dance_attendance.py
Normal file
92
addons/dance_attendance/models/dance_attendance.py
Normal 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."
|
||||
)
|
||||
230
addons/dance_attendance/models/dance_session.py
Normal file
230
addons/dance_attendance/models/dance_session.py
Normal 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",
|
||||
}
|
||||
3
addons/dance_attendance/security/ir.model.access.csv
Normal file
3
addons/dance_attendance/security/ir.model.access.csv
Normal 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
|
||||
|
72
addons/dance_attendance/views/dance_attendance_views.xml
Normal file
72
addons/dance_attendance/views/dance_attendance_views.xml
Normal 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>
|
||||
126
addons/dance_attendance/views/dance_session_views.xml
Normal file
126
addons/dance_attendance/views/dance_session_views.xml
Normal 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>
|
||||
1
addons/dance_batch/__init__.py
Normal file
1
addons/dance_batch/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from . import models
|
||||
17
addons/dance_batch/__manifest__.py
Normal file
17
addons/dance_batch/__manifest__.py
Normal 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,
|
||||
}
|
||||
BIN
addons/dance_batch/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
addons/dance_batch/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
addons/dance_batch/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
addons/dance_batch/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
addons/dance_batch/__pycache__/__manifest__.cpython-311.pyc
Normal file
BIN
addons/dance_batch/__pycache__/__manifest__.cpython-311.pyc
Normal file
Binary file not shown.
116
addons/dance_batch/data/sample_data.xml
Normal file
116
addons/dance_batch/data/sample_data.xml
Normal 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>
|
||||
2
addons/dance_batch/models/__init__.py
Normal file
2
addons/dance_batch/models/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from . import dance_batch
|
||||
from . import dance_enrollment
|
||||
BIN
addons/dance_batch/models/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
addons/dance_batch/models/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
addons/dance_batch/models/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
addons/dance_batch/models/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
129
addons/dance_batch/models/dance_batch.py
Normal file
129
addons/dance_batch/models/dance_batch.py
Normal 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},
|
||||
}
|
||||
94
addons/dance_batch/models/dance_enrollment.py
Normal file
94
addons/dance_batch/models/dance_enrollment.py
Normal 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 {}
|
||||
3
addons/dance_batch/security/ir.model.access.csv
Normal file
3
addons/dance_batch/security/ir.model.access.csv
Normal 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
|
||||
|
106
addons/dance_batch/views/dance_batch_views.xml
Normal file
106
addons/dance_batch/views/dance_batch_views.xml
Normal 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>
|
||||
67
addons/dance_batch/views/dance_enrollment_views.xml
Normal file
67
addons/dance_batch/views/dance_enrollment_views.xml
Normal 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>
|
||||
1
addons/dance_event/__init__.py
Normal file
1
addons/dance_event/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from . import models
|
||||
18
addons/dance_event/__manifest__.py
Normal file
18
addons/dance_event/__manifest__.py
Normal 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,
|
||||
}
|
||||
BIN
addons/dance_event/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
addons/dance_event/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
addons/dance_event/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
addons/dance_event/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
addons/dance_event/__pycache__/__manifest__.cpython-311.pyc
Normal file
BIN
addons/dance_event/__pycache__/__manifest__.cpython-311.pyc
Normal file
Binary file not shown.
20
addons/dance_event/data/whatsapp_template_data.xml
Normal file
20
addons/dance_event/data/whatsapp_template_data.xml
Normal 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>
|
||||
4
addons/dance_event/models/__init__.py
Normal file
4
addons/dance_event/models/__init__.py
Normal file
@ -0,0 +1,4 @@
|
||||
from . import dance_event
|
||||
from . import dance_event_performance
|
||||
from . import dance_rehearsal
|
||||
from . import dance_whatsapp
|
||||
BIN
addons/dance_event/models/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
addons/dance_event/models/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
addons/dance_event/models/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
addons/dance_event/models/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
153
addons/dance_event/models/dance_event.py
Normal file
153
addons/dance_event/models/dance_event.py
Normal 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)
|
||||
84
addons/dance_event/models/dance_event_performance.py
Normal file
84
addons/dance_event/models/dance_event_performance.py
Normal 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
|
||||
169
addons/dance_event/models/dance_rehearsal.py
Normal file
169
addons/dance_event/models/dance_rehearsal.py
Normal 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."
|
||||
)
|
||||
42
addons/dance_event/models/dance_whatsapp.py
Normal file
42
addons/dance_event/models/dance_whatsapp.py
Normal 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")
|
||||
5
addons/dance_event/security/ir.model.access.csv
Normal file
5
addons/dance_event/security/ir.model.access.csv
Normal 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
|
||||
|
71
addons/dance_event/views/dance_event_performance_views.xml
Normal file
71
addons/dance_event/views/dance_event_performance_views.xml
Normal 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 & 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 & Costumes" parent="menu_dance_event_root" action="action_dance_event_performances" sequence="20"/>
|
||||
</odoo>
|
||||
120
addons/dance_event/views/dance_event_views.xml
Normal file
120
addons/dance_event/views/dance_event_views.xml
Normal 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 & 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 & 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>
|
||||
100
addons/dance_event/views/dance_rehearsal_views.xml
Normal file
100
addons/dance_event/views/dance_rehearsal_views.xml
Normal 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>
|
||||
1
addons/dance_fees/__init__.py
Normal file
1
addons/dance_fees/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from . import models
|
||||
19
addons/dance_fees/__manifest__.py
Normal file
19
addons/dance_fees/__manifest__.py
Normal 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,
|
||||
}
|
||||
BIN
addons/dance_fees/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
addons/dance_fees/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
addons/dance_fees/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
addons/dance_fees/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
addons/dance_fees/__pycache__/__manifest__.cpython-311.pyc
Normal file
BIN
addons/dance_fees/__pycache__/__manifest__.cpython-311.pyc
Normal file
Binary file not shown.
26
addons/dance_fees/data/sample_data.xml
Normal file
26
addons/dance_fees/data/sample_data.xml
Normal 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>
|
||||
4
addons/dance_fees/models/__init__.py
Normal file
4
addons/dance_fees/models/__init__.py
Normal file
@ -0,0 +1,4 @@
|
||||
from . import dance_fee_structure
|
||||
from . import dance_enrollment
|
||||
from . import dance_batch
|
||||
from . import account_move
|
||||
BIN
addons/dance_fees/models/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
addons/dance_fees/models/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
addons/dance_fees/models/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
addons/dance_fees/models/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
addons/dance_fees/models/__pycache__/dance_batch.cpython-310.pyc
Normal file
BIN
addons/dance_fees/models/__pycache__/dance_batch.cpython-310.pyc
Normal file
Binary file not shown.
BIN
addons/dance_fees/models/__pycache__/dance_batch.cpython-311.pyc
Normal file
BIN
addons/dance_fees/models/__pycache__/dance_batch.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
48
addons/dance_fees/models/account_move.py
Normal file
48
addons/dance_fees/models/account_move.py
Normal 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."
|
||||
)
|
||||
57
addons/dance_fees/models/dance_batch.py
Normal file
57
addons/dance_fees/models/dance_batch.py
Normal 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()
|
||||
406
addons/dance_fees/models/dance_enrollment.py
Normal file
406
addons/dance_fees/models/dance_enrollment.py
Normal 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
|
||||
48
addons/dance_fees/models/dance_fee_structure.py
Normal file
48
addons/dance_fees/models/dance_fee_structure.py
Normal 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)
|
||||
7
addons/dance_fees/security/dance_fees_security.xml
Normal file
7
addons/dance_fees/security/dance_fees_security.xml
Normal 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>
|
||||
2
addons/dance_fees/security/ir.model.access.csv
Normal file
2
addons/dance_fees/security/ir.model.access.csv
Normal 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
|
||||
|
38
addons/dance_fees/views/account_move_views.xml
Normal file
38
addons/dance_fees/views/account_move_views.xml
Normal 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>
|
||||
81
addons/dance_fees/views/dance_enrollment_fee_views.xml
Normal file
81
addons/dance_fees/views/dance_enrollment_fee_views.xml
Normal 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 & 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>
|
||||
80
addons/dance_fees/views/dance_fee_structure_views.xml
Normal file
80
addons/dance_fees/views/dance_fee_structure_views.xml
Normal 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>
|
||||
1
addons/dance_guardian/__init__.py
Normal file
1
addons/dance_guardian/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from . import models
|
||||
14
addons/dance_guardian/__manifest__.py
Normal file
14
addons/dance_guardian/__manifest__.py
Normal 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,
|
||||
}
|
||||
BIN
addons/dance_guardian/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
addons/dance_guardian/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
addons/dance_guardian/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
addons/dance_guardian/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
addons/dance_guardian/__pycache__/__manifest__.cpython-311.pyc
Normal file
BIN
addons/dance_guardian/__pycache__/__manifest__.cpython-311.pyc
Normal file
Binary file not shown.
1
addons/dance_guardian/models/__init__.py
Normal file
1
addons/dance_guardian/models/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from . import res_partner
|
||||
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user