22 changed files with 1445 additions and 102 deletions
@ -0,0 +1,12 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<odoo> |
|||
<record id="ir_cron_expire_memberships" model="ir.cron"> |
|||
<field name="name">Auto Expire Gym Memberships</field> |
|||
<field name="model_id" ref="model_gym_membership"/> |
|||
<field name="state">code</field> |
|||
<field name="code">model._cron_expire_memberships()</field> |
|||
<field name="interval_number">5</field> |
|||
<field name="interval_type">minutes</field> |
|||
<field name="active" eval="True"/> |
|||
</record> |
|||
</odoo> |
@ -0,0 +1,57 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<odoo> |
|||
<record id="skill_type_gym" model="hr.skill.type"> |
|||
<field name="name">Gym</field> |
|||
<field name="is_gym_skill">True</field> |
|||
</record> |
|||
|
|||
<record id="skill_strength_training" model="hr.skill"> |
|||
<field name="name">Strength Training</field> |
|||
<field name="skill_type_id" ref="skill_type_gym"/> |
|||
</record> |
|||
|
|||
<record id="skill_cardio_training" model="hr.skill"> |
|||
<field name="name">Cardio Training</field> |
|||
<field name="skill_type_id" ref="skill_type_gym"/> |
|||
</record> |
|||
|
|||
<record id="skill_yoga" model="hr.skill"> |
|||
<field name="name">Yoga</field> |
|||
<field name="skill_type_id" ref="skill_type_gym"/> |
|||
</record> |
|||
|
|||
<record id="skill_crossfit" model="hr.skill"> |
|||
<field name="name">CrossFit</field> |
|||
<field name="skill_type_id" ref="skill_type_gym"/> |
|||
</record> |
|||
|
|||
<record id="skill_pilates" model="hr.skill"> |
|||
<field name="name">Pilates</field> |
|||
<field name="skill_type_id" ref="skill_type_gym"/> |
|||
</record> |
|||
|
|||
<record id="skill_martial_arts" model="hr.skill"> |
|||
<field name="name">Martial Arts</field> |
|||
<field name="skill_type_id" ref="skill_type_gym"/> |
|||
</record> |
|||
|
|||
<record id="skill_swimming" model="hr.skill"> |
|||
<field name="name">Swimming</field> |
|||
<field name="skill_type_id" ref="skill_type_gym"/> |
|||
</record> |
|||
|
|||
<record id="skill_nutrition_coaching" model="hr.skill"> |
|||
<field name="name">Nutrition Coaching</field> |
|||
<field name="skill_type_id" ref="skill_type_gym"/> |
|||
</record> |
|||
|
|||
<record id="skill_rehabilitation" model="hr.skill"> |
|||
<field name="name">Rehabilitation Training</field> |
|||
<field name="skill_type_id" ref="skill_type_gym"/> |
|||
</record> |
|||
|
|||
<record id="skill_group_fitness" model="hr.skill"> |
|||
<field name="name">Group Fitness</field> |
|||
<field name="skill_type_id" ref="skill_type_gym"/> |
|||
</record> |
|||
</odoo> |
@ -0,0 +1,66 @@ |
|||
# -*- coding: utf-8 -*- |
|||
############################################################################# |
|||
# |
|||
# Cybrosys Technologies Pvt. Ltd. |
|||
# |
|||
# Copyright (C) 2023-TODAY Cybrosys Technologies(<https://www.cybrosys.com>) |
|||
# Author: Sahla Sherin (<https://www.cybrosys.com>) |
|||
# |
|||
# You can modify it under the terms of the GNU LESSER |
|||
# GENERAL PUBLIC LICENSE (AGPL v3), Version 3. |
|||
# |
|||
# This program is distributed in the hope that it will be useful, |
|||
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
# GNU LESSER GENERAL PUBLIC LICENSE (AGPL v3) for more details. |
|||
# |
|||
# You should have received a copy of the GNU LESSER GENERAL PUBLIC LICENSE |
|||
# (AGPL v3) along with this program. |
|||
# If not, see <http://www.gnu.org/licenses/>. |
|||
# |
|||
############################################################################# |
|||
from odoo import models |
|||
|
|||
|
|||
class AccountMove(models.Model): |
|||
_inherit = "account.move" |
|||
|
|||
def _activate_gym_memberships(self): |
|||
"""Activate gym memberships when invoice is paid""" |
|||
for move in self: |
|||
if move.move_type == "out_invoice" and move.payment_state == "paid": |
|||
sale_order = None |
|||
|
|||
if move.invoice_origin: |
|||
sale_order = self.env["sale.order"].search([ |
|||
("name", "=", move.invoice_origin) |
|||
], limit=1) |
|||
|
|||
if not sale_order: |
|||
sale_lines = move.invoice_line_ids.mapped('sale_line_ids') |
|||
if sale_lines: |
|||
sale_order = sale_lines[0].order_id |
|||
|
|||
if sale_order: |
|||
memberships = self.env["gym.membership"].search([ |
|||
("sale_order_id", "=", sale_order.id), |
|||
("state", "=", "confirm") |
|||
]) |
|||
|
|||
for membership in memberships: |
|||
membership.state = "active" |
|||
|
|||
class AccountMoveLine(models.Model): |
|||
_inherit = "account.move.line" |
|||
|
|||
def reconcile(self): |
|||
"""Check for membership activation after payment reconciliation""" |
|||
res = super().reconcile() |
|||
|
|||
invoice_moves = self.mapped('move_id').filtered( |
|||
lambda m: m.move_type == 'out_invoice' and m.payment_state == 'paid' |
|||
) |
|||
|
|||
invoice_moves._activate_gym_memberships() |
|||
|
|||
return res |
@ -0,0 +1,64 @@ |
|||
# -*- coding: utf-8 -*- |
|||
############################################################################# |
|||
# |
|||
# Cybrosys Technologies Pvt. Ltd. |
|||
# |
|||
# Copyright (C) 2023-TODAY Cybrosys Technologies(<https://www.cybrosys.com>) |
|||
# Author: Sahla Sherin (<https://www.cybrosys.com>) |
|||
# |
|||
# You can modify it under the terms of the GNU LESSER |
|||
# GENERAL PUBLIC LICENSE (AGPL v3), Version 3. |
|||
# |
|||
# This program is distributed in the hope that it will be useful, |
|||
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
# GNU LESSER GENERAL PUBLIC LICENSE (AGPL v3) for more details. |
|||
# |
|||
# You should have received a copy of the GNU LESSER GENERAL PUBLIC LICENSE |
|||
# (AGPL v3) along with this program. |
|||
# If not, see <http://www.gnu.org/licenses/>. |
|||
# |
|||
############################################################################# |
|||
from odoo import models |
|||
import logging |
|||
|
|||
_logger = logging.getLogger(__name__) |
|||
|
|||
|
|||
class AccountPayment(models.Model): |
|||
_inherit = "account.payment" |
|||
|
|||
def action_post(self): |
|||
"""Check for membership activation after payment is posted""" |
|||
res = super().action_post() |
|||
|
|||
for payment in self: |
|||
if payment.partner_type == 'customer' and payment.state == 'posted': |
|||
_logger.info(f"Payment {payment.name} posted, checking for memberships to activate") |
|||
|
|||
reconciled_invoice_lines = payment.line_ids.mapped( |
|||
'matched_debit_ids.debit_move_id') + payment.line_ids.mapped('matched_credit_ids.credit_move_id') |
|||
invoices = reconciled_invoice_lines.mapped('move_id').filtered(lambda m: m.move_type == 'out_invoice') |
|||
|
|||
for invoice in invoices: |
|||
if invoice.payment_state == 'paid': |
|||
_logger.info(f"Invoice {invoice.name} is now fully paid") |
|||
|
|||
sale_order = None |
|||
if invoice.invoice_origin: |
|||
sale_order = self.env["sale.order"].search([ |
|||
("name", "=", invoice.invoice_origin) |
|||
], limit=1) |
|||
|
|||
if sale_order: |
|||
memberships = self.env["gym.membership"].search([ |
|||
("sale_order_id", "=", sale_order.id), |
|||
("state", "=", "confirm") |
|||
]) |
|||
|
|||
for membership in memberships: |
|||
membership.state = "active" |
|||
_logger.info( |
|||
f"Activated membership {membership.reference} due to payment {payment.name}") |
|||
|
|||
return res |
@ -0,0 +1,218 @@ |
|||
# -*- coding: utf-8 -*- |
|||
############################################################################# |
|||
# |
|||
# Cybrosys Technologies Pvt. Ltd. |
|||
# |
|||
# Copyright (C) 2023-TODAY Cybrosys Technologies(<https://www.cybrosys.com>) |
|||
# Author: Sahla Sherin (<https://www.cybrosys.com>) |
|||
# |
|||
# You can modify it under the terms of the GNU LESSER |
|||
# GENERAL PUBLIC LICENSE (AGPL v3), Version 3. |
|||
# |
|||
# This program is distributed in the hope that it will be useful, |
|||
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
# GNU LESSER GENERAL PUBLIC LICENSE (AGPL v3) for more details. |
|||
# |
|||
# You should have received a copy of the GNU LESSER GENERAL PUBLIC LICENSE |
|||
# (AGPL v3) along with this program. |
|||
# If not, see <http://www.gnu.org/licenses/>. |
|||
# |
|||
############################################################################# |
|||
from odoo import api, fields, models, _ |
|||
from odoo.exceptions import UserError |
|||
|
|||
|
|||
class GymAttendance(models.Model): |
|||
"""Simple Gym Attendance Model""" |
|||
_name = 'gym.attendance' |
|||
_description = 'Gym Attendance' |
|||
_order = 'check_in desc' |
|||
_rec_name = 'member_id' |
|||
|
|||
member_id = fields.Many2one('res.partner', string='Member', required=True, |
|||
domain="[('gym_member', '=', True)]") |
|||
check_in = fields.Datetime(string='Check In', required=True, |
|||
default=fields.Datetime.now) |
|||
check_out = fields.Datetime(string='Check Out') |
|||
|
|||
duration = fields.Float(string='Duration (Hours)', |
|||
compute='_compute_duration', store=True) |
|||
|
|||
state = fields.Selection([ |
|||
('checked_in', 'Checked In'), |
|||
('checked_out', 'Checked Out') |
|||
], string='State', compute='_compute_state', store=True) |
|||
|
|||
@api.depends('check_out') |
|||
def _compute_state(self): |
|||
for record in self: |
|||
record.state = 'checked_out' if record.check_out else 'checked_in' |
|||
|
|||
@api.depends('check_in', 'check_out') |
|||
def _compute_duration(self): |
|||
for record in self: |
|||
if record.check_in and record.check_out: |
|||
delta = record.check_out - record.check_in |
|||
record.duration = delta.total_seconds() / 3600 |
|||
else: |
|||
record.duration = 0.0 |
|||
|
|||
@api.model |
|||
def create(self, vals): |
|||
"""Override create to validate BEFORE creating the record""" |
|||
if 'member_id' in vals: |
|||
member_id = vals['member_id'] |
|||
|
|||
existing_checkin = self.search([ |
|||
('member_id', '=', member_id), |
|||
('check_out', '=', False) |
|||
]) |
|||
|
|||
if existing_checkin: |
|||
member_name = self.env['res.partner'].browse(member_id).name |
|||
raise UserError(_('%s is already checked in at %s. Please check out first.') % |
|||
(member_name, existing_checkin.check_in.strftime('%Y-%m-%d %H:%M:%S'))) |
|||
|
|||
member = self.env['res.partner'].browse(member_id) |
|||
validation = self._validate_member_can_checkin(member) |
|||
if not validation['can_checkin']: |
|||
raise UserError(validation['message']) |
|||
|
|||
return super(GymAttendance, self).create(vals) |
|||
|
|||
def write(self, vals): |
|||
"""Override write to validate member changes""" |
|||
if 'member_id' in vals: |
|||
existing_checkin = self.env['gym.attendance'].search([ |
|||
('member_id', '=', vals['member_id']), |
|||
('check_out', '=', False), |
|||
('id', 'not in', self.ids) # Exclude current records |
|||
]) |
|||
|
|||
if existing_checkin: |
|||
member_name = self.env['res.partner'].browse(vals['member_id']).name |
|||
raise UserError(_('%s is already checked in. Cannot change to this member.') % member_name) |
|||
|
|||
return super(GymAttendance, self).write(vals) |
|||
|
|||
def action_check_in(self): |
|||
"""Check in a member - simplified since validation is now in create()""" |
|||
self.ensure_one() |
|||
|
|||
return { |
|||
'type': 'ir.actions.client', |
|||
'tag': 'display_notification', |
|||
'params': { |
|||
'message': f'Welcome {self.member_id.name}! Check-in successful.', |
|||
'type': 'success', |
|||
'sticky': False, |
|||
} |
|||
} |
|||
|
|||
def _validate_member_can_checkin(self, member): |
|||
"""Validate if member can check in - returns dict with can_checkin boolean and message""" |
|||
|
|||
active_membership = self.env['gym.membership'].search([ |
|||
('member_id', '=', member.id), |
|||
('state', '=', 'active') |
|||
], order='effective_end_date desc', limit=1) |
|||
|
|||
if active_membership: |
|||
if active_membership.effective_end_date and active_membership.effective_end_date < fields.Date.today(): |
|||
return { |
|||
'can_checkin': False, |
|||
'message': _( |
|||
'Cannot check in. Your active membership expired on %s.\n' |
|||
'Please renew your membership.' |
|||
) % active_membership.effective_end_date.strftime('%Y-%m-%d') |
|||
} |
|||
|
|||
return { |
|||
'can_checkin': True, |
|||
'message': _('Check-in allowed with active membership.') |
|||
} |
|||
|
|||
membership = self.env['gym.membership'].search([ |
|||
('member_id', '=', member.id), |
|||
('state', 'in', ['paused', 'expired', 'confirmed']) |
|||
], order='id desc', limit=1) |
|||
|
|||
if not membership: |
|||
return { |
|||
'can_checkin': False, |
|||
'message': _('No membership found for this member.') |
|||
} |
|||
|
|||
if membership.state == 'paused': |
|||
return { |
|||
'can_checkin': False, |
|||
'message': _( |
|||
'Cannot check in. Membership is currently PAUSED.\n' |
|||
'Please resume your membership to check in.' |
|||
) |
|||
} |
|||
elif membership.state == 'expired': |
|||
return { |
|||
'can_checkin': False, |
|||
'message': _( |
|||
'Cannot check in. Membership has EXPIRED.\n' |
|||
'Please renew your membership to continue.' |
|||
) |
|||
} |
|||
elif membership.state == 'confirmed': |
|||
return { |
|||
'can_checkin': False, |
|||
'message': _( |
|||
'Cannot check in. Membership is confirmed but not yet active.\n' |
|||
'Please contact administration to activate your membership.' |
|||
) |
|||
} |
|||
else: |
|||
return { |
|||
'can_checkin': False, |
|||
'message': _( |
|||
'Cannot check in. Membership is not active.\n' |
|||
'Current status: %s' |
|||
) % membership.state.title() |
|||
} |
|||
|
|||
def action_check_out(self): |
|||
"""Check out manually""" |
|||
self.ensure_one() |
|||
if self.check_out: |
|||
raise UserError(_('Already checked out.')) |
|||
|
|||
self.check_out = fields.Datetime.now() |
|||
|
|||
return { |
|||
'type': 'ir.actions.client', |
|||
'tag': 'display_notification', |
|||
'params': { |
|||
'message': f'Goodbye {self.member_id.name}! Duration: {self.duration:.2f} hours', |
|||
'type': 'success', |
|||
'sticky': False, |
|||
} |
|||
} |
|||
|
|||
@api.model |
|||
def quick_checkin(self, member_id): |
|||
"""Method for quick check-in from external calls""" |
|||
member = self.env['res.partner'].browse(member_id) |
|||
if not member.exists(): |
|||
raise UserError(_('Member not found.')) |
|||
|
|||
attendance = self.create({ |
|||
'member_id': member_id, |
|||
'check_in': fields.Datetime.now(), |
|||
}) |
|||
|
|||
return { |
|||
'type': 'ir.actions.client', |
|||
'tag': 'display_notification', |
|||
'params': { |
|||
'message': f'Welcome {member.name}! Check-in successful.', |
|||
'type': 'success', |
|||
'sticky': False, |
|||
} |
|||
} |
|
@ -0,0 +1,107 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<odoo> |
|||
<record id="gym_attendance_view_form" model="ir.ui.view"> |
|||
<field name="name">gym.attendance.view.form</field> |
|||
<field name="model">gym.attendance</field> |
|||
<field name="arch" type="xml"> |
|||
<form> |
|||
<header> |
|||
<button name="action_check_in" string="Check In" type="object" |
|||
class="btn-primary" attrs="{'invisible': [('check_in', '!=', False)]}"/> |
|||
<button name="action_check_out" string="Check Out" type="object" |
|||
class="btn-warning" |
|||
attrs="{'invisible': [('state', '=', 'checked_out')]}"/> |
|||
|
|||
<field name="state" widget="statusbar"/> |
|||
</header> |
|||
<sheet> |
|||
<group> |
|||
<group> |
|||
<field name="member_id"/> |
|||
<field name="check_in"/> |
|||
</group> |
|||
<group> |
|||
<field name="check_out"/> |
|||
<field name="duration" widget="float_time"/> |
|||
</group> |
|||
</group> |
|||
</sheet> |
|||
</form> |
|||
</field> |
|||
</record> |
|||
|
|||
<record id="gym_attendance_view_tree" model="ir.ui.view"> |
|||
<field name="name">gym.attendance.view.tree</field> |
|||
<field name="model">gym.attendance</field> |
|||
<field name="arch" type="xml"> |
|||
<tree> |
|||
<field name="member_id"/> |
|||
<field name="check_in"/> |
|||
<field name="check_out"/> |
|||
<field name="duration" widget="float_time"/> |
|||
<field name="state" decoration-success="state == 'checked_out'" |
|||
decoration-info="state == 'checked_in'"/> |
|||
<button name="action_check_out" type="object" string="Check Out" |
|||
class="btn-sm btn-warning" icon="fa-sign-out" |
|||
attrs="{'invisible': [('check_out', '!=', False)]}"/> |
|||
</tree> |
|||
</field> |
|||
</record> |
|||
|
|||
<record id="gym_attendance_quick_checkin_form" model="ir.ui.view"> |
|||
<field name="name">gym.attendance.quick.checkin.form</field> |
|||
<field name="model">gym.attendance</field> |
|||
<field name="arch" type="xml"> |
|||
<form> |
|||
<sheet> |
|||
<div class="oe_title"> |
|||
<h1>Quick Check-In</h1> |
|||
</div> |
|||
<group> |
|||
<field name="member_id" placeholder="Select Member..."/> |
|||
</group> |
|||
<footer> |
|||
<button name="action_check_in" string="Check In" type="object" class="btn-primary"/> |
|||
<button string="Cancel" special="cancel" class="btn-secondary"/> |
|||
</footer> |
|||
</sheet> |
|||
</form> |
|||
</field> |
|||
</record> |
|||
|
|||
<record id="gym_attendance_action" model="ir.actions.act_window"> |
|||
<field name="name">Gym Attendance</field> |
|||
<field name="type">ir.actions.act_window</field> |
|||
<field name="res_model">gym.attendance</field> |
|||
<field name="view_mode">tree,form</field> |
|||
</record> |
|||
|
|||
<record id="gym_attendance_quick_checkin_action" model="ir.actions.act_window"> |
|||
<field name="name">Quick Check-In</field> |
|||
<field name="type">ir.actions.act_window</field> |
|||
<field name="res_model">gym.attendance</field> |
|||
<field name="view_mode">form</field> |
|||
<field name="view_id" ref="gym_attendance_quick_checkin_form"/> |
|||
<field name="target">new</field> |
|||
</record> |
|||
|
|||
<menuitem |
|||
id="gym_attendance_menu" |
|||
name="Attendance" |
|||
parent="gym_member_root" |
|||
sequence="45"/> |
|||
|
|||
<menuitem |
|||
id="gym_attendance_records_menu" |
|||
name="Attendance Records" |
|||
parent="gym_attendance_menu" |
|||
action="gym_attendance_action" |
|||
sequence="10"/> |
|||
|
|||
<menuitem |
|||
id="gym_attendance_quick_checkin_menu" |
|||
name="Quick Check-In" |
|||
parent="gym_attendance_menu" |
|||
action="gym_attendance_quick_checkin_action" |
|||
sequence="5"/> |
|||
</odoo> |
@ -0,0 +1,6 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<odoo> |
|||
<menuitem id="gym_member_root" |
|||
name="Gym Management" |
|||
sequence="50"/> |
|||
</odoo> |
@ -1,46 +1,94 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<odoo> |
|||
<!-- Action for gym trainer --> |
|||
<record id="gym_trainer_emp_action" model="ir.actions.act_window"> |
|||
<record id="view_employee_form_trainer" model="ir.ui.view"> |
|||
<field name="name">hr.employee.view.form.inherited.gym.trainer</field> |
|||
<field name="model">hr.employee</field> |
|||
<field name="inherit_id" ref="hr.view_employee_form"/> |
|||
<field name="arch" type="xml"> |
|||
<xpath expr="//field[@name='mobile_phone']" position="after"> |
|||
<field name="is_trainer"/> |
|||
</xpath> |
|||
|
|||
<xpath expr="//page[@name='hr_settings']" position="after"> |
|||
<page name="trainer_skills" string="Trainer Skills" attrs="{'invisible': [('is_trainer', '=', False)]}"> |
|||
<group string="Current Gym Skills"> |
|||
<field name="gym_skill_ids" widget="many2many_tags" readonly="1"/> |
|||
</group> |
|||
|
|||
<group string="All Employee Skills"> |
|||
<field name="skill_ids" nolabel="1" context="{'default_skill_type_id': False}"/> |
|||
</group> |
|||
</page> |
|||
</xpath> |
|||
</field> |
|||
</record> |
|||
|
|||
<record id="hr_employee_trainer_action" model="ir.actions.act_window"> |
|||
<field name="name">Trainers</field> |
|||
<field name="type">ir.actions.act_window</field> |
|||
<field name="res_model">hr.employee</field> |
|||
<field name="view_mode">kanban,tree,form</field> |
|||
<field name="domain">[('trainer','=','True')]</field> |
|||
<field name="view_mode">kanban,list,form</field> |
|||
<field name="domain">[('is_trainer','=',True)]</field> |
|||
<field name="context">{'default_is_trainer': True}</field> |
|||
<field name="help" type="html"> |
|||
<p class="o_view_nocontent_smiling_face"> |
|||
Create your first Trainer! |
|||
</p> |
|||
</field> |
|||
</record> |
|||
<!--form view for trainer --> |
|||
<record id="view_employee_form" model="ir.ui.view"> |
|||
<field name="name">hr.employee.view.form.inherited.gym.mgmt.system</field> |
|||
<field name="model">hr.employee</field> |
|||
<field name="inherit_id" ref="hr.view_employee_form"/> |
|||
<field name="arch" type="xml"> |
|||
<xpath expr="//field[@name='mobile_phone']" position="after"> |
|||
<field name="trainer"/> |
|||
<field name="exercise_for_ids" widget="many2many_tags" |
|||
attrs="{'invisible': [('trainer', '=', False)]}"/> |
|||
</xpath> |
|||
</field> |
|||
</record> |
|||
<!-- Menu and Submenu --> |
|||
<menuitem |
|||
id="gym_trainer_root" |
|||
|
|||
<!-- Menu items --> |
|||
<menuitem id="gym_trainer_root" |
|||
name="Trainers" |
|||
parent="gym_mgmt_system_menu_root" |
|||
sequence="20"/> |
|||
<menuitem |
|||
id="gym_trainer_menu" |
|||
|
|||
<menuitem id="gym_trainer_menu" |
|||
name="Trainers" |
|||
parent="gym_trainer_root" |
|||
action="gym_trainer_emp_action" |
|||
action="hr_employee_trainer_action" |
|||
sequence="10"/> |
|||
<menuitem id="gym_trainer_skill_menu" |
|||
name="Trainer skill" |
|||
|
|||
<record id="gym_skills_action" model="ir.actions.act_window"> |
|||
<field name="name">Trainer Skills</field> |
|||
<field name="res_model">hr.skill</field> |
|||
<field name="view_mode">list,form</field> |
|||
<field name="domain">[('skill_type_id.is_gym_skill', '=', True)]</field> |
|||
<field name="context">{}</field> |
|||
</record> |
|||
|
|||
<menuitem id="gym_skills_menu" |
|||
name="Trainer Skills" |
|||
parent="gym_trainer_root" |
|||
action="trainer_skill_action" |
|||
action="gym_skills_action" |
|||
sequence="20"/> |
|||
|
|||
<record id="view_gym_skill_level_tree" model="ir.ui.view"> |
|||
<field name="name">hr.skill.level.view.tree.gym</field> |
|||
<field name="model">hr.skill.level</field> |
|||
<field name="arch" type="xml"> |
|||
<list string="Trainer Skill Levels" editable="bottom"> |
|||
<field name="name"/> |
|||
<field name="skill_type_id"/> |
|||
<field name="level_progress" widget="progressbar" options="{'editable': true}"/> |
|||
<field name="default_level" widget="boolean_toggle"/> |
|||
</list> |
|||
</field> |
|||
</record> |
|||
|
|||
|
|||
|
|||
<record id="gym_skill_levels_action" model="ir.actions.act_window"> |
|||
<field name="name">Trainer Skill Levels</field> |
|||
<field name="res_model">hr.skill.level</field> |
|||
<field name="view_mode">list,form</field> |
|||
<field name="view_id" ref="view_gym_skill_level_tree"/> |
|||
<field name="context">{}</field> |
|||
</record> |
|||
|
|||
<menuitem id="gym_skill_levels_menu" |
|||
name="Trainer Skill Levels" |
|||
parent="gym_trainer_root" |
|||
action="gym_skill_levels_action" |
|||
sequence="30"/> |
|||
</odoo> |
@ -0,0 +1,212 @@ |
|||
# -*- coding: utf-8 -*- |
|||
############################################################################# |
|||
# |
|||
# Cybrosys Technologies Pvt. Ltd. |
|||
# |
|||
# Copyright (C) 2023-TODAY Cybrosys Technologies(<https://www.cybrosys.com>) |
|||
# Author: Sahla Sherin (<https://www.cybrosys.com>) |
|||
# |
|||
# You can modify it under the terms of the GNU LESSER |
|||
# GENERAL PUBLIC LICENSE (AGPL v3), Version 3. |
|||
# |
|||
# This program is distributed in the hope that it will be useful, |
|||
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
# GNU LESSER GENERAL PUBLIC LICENSE (AGPL v3) for more details. |
|||
# |
|||
# You should have received a copy of the GNU LESSER GENERAL PUBLIC LICENSE |
|||
# (AGPL v3) along with this program. |
|||
# If not, see <http://www.gnu.org/licenses/>. |
|||
# |
|||
############################################################################# |
|||
from odoo import api, fields, models, _ |
|||
from odoo.exceptions import UserError |
|||
from datetime import timedelta |
|||
|
|||
|
|||
class GymMembershipExtendWizard(models.TransientModel): |
|||
"""Wizard to extend gym membership""" |
|||
_name = 'gym.membership.extend.wizard' |
|||
_description = 'Extend Gym Membership Wizard' |
|||
|
|||
membership_id = fields.Many2one('gym.membership', string='Membership', required=True) |
|||
member_id = fields.Many2one('res.partner', string='Member', required=True) |
|||
current_end_date = fields.Date(string='Current End Date', related='membership_id.effective_end_date') |
|||
extension_type = fields.Selection([ |
|||
('same_plan', 'Extend with Same Plan Duration'), |
|||
('custom_days', 'Custom Number of Days'), |
|||
('new_plan', 'Change to New Membership Plan') |
|||
], string='Extension Type', required=True, default='same_plan') |
|||
same_plan_duration = fields.Integer(string='Plan Duration (Days)', compute='_compute_same_plan_duration', |
|||
store=True) |
|||
custom_days = fields.Integer(string='Number of Days to Extend', default=30) |
|||
new_membership_plan_id = fields.Many2one('product.product', string='New Membership Plan', |
|||
domain="[('membership_date_from', '!=', False)]") |
|||
new_plan_duration = fields.Integer(string='New Plan Duration (Days)') |
|||
extension_days = fields.Integer(string='Total Extension Days', compute='_compute_extension_details', store=True) |
|||
new_end_date = fields.Date(string='New End Date', compute='_compute_extension_details', store=True) |
|||
extension_amount = fields.Float(string='Extension Amount', compute='_compute_extension_details', store=True) |
|||
notes = fields.Text(string='Notes') |
|||
|
|||
@api.depends('membership_id.membership_duration') |
|||
def _compute_same_plan_duration(self): |
|||
"""Compute the same plan duration from membership""" |
|||
for wizard in self: |
|||
wizard.same_plan_duration = wizard.membership_id.membership_duration or 0 |
|||
|
|||
@api.depends('extension_type', 'custom_days', 'new_membership_plan_id', 'same_plan_duration') |
|||
def _compute_extension_details(self): |
|||
"""Compute extension days, new end date and amount""" |
|||
for wizard in self: |
|||
extension_days = 0 |
|||
extension_amount = 0.0 |
|||
|
|||
if wizard.extension_type == 'same_plan': |
|||
if wizard.membership_id.membership_scheme_id: |
|||
original_plan = wizard.membership_id.membership_scheme_id |
|||
if original_plan.membership_date_from and original_plan.membership_date_to: |
|||
extension_days = (original_plan.membership_date_to - original_plan.membership_date_from).days |
|||
extension_amount = original_plan.list_price |
|||
|
|||
elif wizard.extension_type == 'custom_days': |
|||
extension_days = wizard.custom_days |
|||
if wizard.membership_id.membership_scheme_id and extension_days > 0: |
|||
original_plan = wizard.membership_id.membership_scheme_id |
|||
if original_plan.membership_date_from and original_plan.membership_date_to: |
|||
original_days = (original_plan.membership_date_to - original_plan.membership_date_from).days |
|||
if original_days > 0: |
|||
daily_rate = original_plan.list_price / original_days |
|||
extension_amount = daily_rate * extension_days |
|||
|
|||
elif wizard.extension_type == 'new_plan': |
|||
if wizard.new_membership_plan_id: |
|||
new_plan = wizard.new_membership_plan_id |
|||
if new_plan.membership_date_from and new_plan.membership_date_to: |
|||
extension_days = (new_plan.membership_date_to - new_plan.membership_date_from).days |
|||
extension_amount = new_plan.list_price |
|||
|
|||
wizard.extension_days = extension_days |
|||
wizard.extension_amount = extension_amount |
|||
|
|||
if wizard.current_end_date and extension_days > 0: |
|||
wizard.new_end_date = wizard.current_end_date + timedelta(days=extension_days) |
|||
else: |
|||
wizard.new_end_date = wizard.current_end_date |
|||
|
|||
@api.onchange('new_membership_plan_id') |
|||
def _onchange_new_membership_plan_id(self): |
|||
"""Update new plan duration when plan changes""" |
|||
if self.new_membership_plan_id: |
|||
plan = self.new_membership_plan_id |
|||
if plan.membership_date_from and plan.membership_date_to: |
|||
self.new_plan_duration = (plan.membership_date_to - plan.membership_date_from).days |
|||
|
|||
def action_extend_membership(self): |
|||
"""Process the membership extension""" |
|||
self.ensure_one() |
|||
|
|||
if self.extension_days <= 0: |
|||
raise UserError(_('Extension days must be greater than 0.')) |
|||
|
|||
if self.extension_amount <= 0: |
|||
raise UserError(_('Extension amount must be greater than 0.')) |
|||
|
|||
# Create sale order for extension payment |
|||
sale_order = self._create_extension_sale_order() |
|||
|
|||
# Complete the extension on existing membership |
|||
self.membership_id.complete_extension( |
|||
days_extended=self.extension_days, |
|||
extension_amount=self.extension_amount, |
|||
sale_order_id=sale_order.id |
|||
) |
|||
|
|||
# CASE 1: New plan → replace membership_scheme_id |
|||
if self.extension_type == 'new_plan' and self.new_membership_plan_id: |
|||
self.membership_id.membership_scheme_id = self.new_membership_plan_id.id |
|||
|
|||
# CASE 2: Custom days → create/update a custom plan product |
|||
elif self.extension_type == 'custom_days' and self.custom_days > 0: |
|||
custom_plan_name = f"Custom Plan - {self.custom_days} days" |
|||
custom_plan = self.env['product.product'].search([ |
|||
('name', '=', custom_plan_name), |
|||
('detailed_type', '=', 'service') |
|||
], limit=1) |
|||
|
|||
if not custom_plan: |
|||
custom_plan = self.env['product.product'].create({ |
|||
'name': custom_plan_name, |
|||
'type': 'service', |
|||
'detailed_type': 'service', |
|||
'list_price': self.extension_amount, |
|||
'membership_date_from': fields.Date.today(), |
|||
'membership_date_to': fields.Date.today() + timedelta(days=self.custom_days), |
|||
'sale_ok': False, |
|||
'purchase_ok': False, |
|||
}) |
|||
|
|||
self.membership_id.membership_scheme_id = custom_plan.id |
|||
|
|||
return { |
|||
'name': _('Extension Sale Order'), |
|||
'type': 'ir.actions.act_window', |
|||
'res_model': 'sale.order', |
|||
'res_id': sale_order.id, |
|||
'view_mode': 'form', |
|||
'target': 'current', |
|||
} |
|||
|
|||
def _create_extension_sale_order(self): |
|||
"""Create sale order for membership extension without auto-creating membership""" |
|||
|
|||
extension_product = self._get_or_create_extension_service_product() |
|||
|
|||
if self.extension_type == 'new_plan' and self.new_membership_plan_id: |
|||
product_name = f"Membership Extension - {self.new_membership_plan_id.name} ({self.extension_days} days)" |
|||
elif self.extension_type == 'same_plan': |
|||
product_name = f"Membership Extension - {self.membership_id.membership_scheme_id.name} ({self.extension_days} days)" |
|||
else: |
|||
product_name = f"Membership Extension - {self.extension_days} days" |
|||
|
|||
sale_order_vals = { |
|||
'partner_id': self.member_id.id, |
|||
'date_order': fields.Datetime.now(), |
|||
'origin': f'Extension of {self.membership_id.reference}', |
|||
'order_line': [(0, 0, { |
|||
'product_id': extension_product.id, |
|||
'name': product_name, |
|||
'product_uom_qty': 1, |
|||
'price_unit': self.extension_amount, |
|||
})] |
|||
} |
|||
|
|||
sale_order = self.env['sale.order'].create(sale_order_vals) |
|||
|
|||
sale_order.action_confirm() |
|||
|
|||
return sale_order |
|||
|
|||
def _get_or_create_extension_service_product(self): |
|||
"""Get or create a service product for membership extensions""" |
|||
|
|||
extension_product = self.env['product.product'].search([ |
|||
('name', '=', 'Membership Extension Service'), |
|||
('type', '=', 'service'), |
|||
('detailed_type', '=', 'service') |
|||
], limit=1) |
|||
|
|||
if not extension_product: |
|||
extension_product = self.env['product.product'].create({ |
|||
'name': 'Membership Extension Service', |
|||
'type': 'service', |
|||
'detailed_type': 'service', |
|||
'categ_id': self.env.ref('product.product_category_all').id, |
|||
'list_price': 0.0, |
|||
'sale_ok': True, |
|||
'purchase_ok': False, |
|||
'invoice_policy': 'order', |
|||
'membership_date_from': False, |
|||
'membership_date_to': False, |
|||
}) |
|||
|
|||
return extension_product |
@ -0,0 +1,57 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<odoo> |
|||
<record id="gym_membership_extend_wizard_view_form" model="ir.ui.view"> |
|||
<field name="name">gym.membership.extend.wizard.view.form</field> |
|||
<field name="model">gym.membership.extend.wizard</field> |
|||
<field name="arch" type="xml"> |
|||
<form string="Extend Membership"> |
|||
<group> |
|||
<group> |
|||
<field name="membership_id" readonly="1"/> |
|||
<field name="member_id" readonly="1"/> |
|||
<field name="current_end_date" readonly="1"/> |
|||
</group> |
|||
<group> |
|||
<field name="extension_type" widget="radio"/> |
|||
</group> |
|||
</group> |
|||
|
|||
<group> |
|||
<group attrs="{'invisible': [('extension_type','!=','same_plan')]}"> |
|||
<field name="same_plan_duration" readonly="1" string="Plan Duration (Days)"/> |
|||
</group> |
|||
|
|||
<group attrs="{'invisible': [('extension_type','!=','custom_days')]}"> |
|||
<field name="custom_days"/> |
|||
</group> |
|||
|
|||
<group attrs="{'invisible': [('extension_type','!=','new_plan')]}"> |
|||
<field name="new_membership_plan_id"/> |
|||
<field name="new_plan_duration" readonly="1"/> |
|||
</group> |
|||
</group> |
|||
|
|||
<separator string="Extension Summary"/> |
|||
<group> |
|||
<group> |
|||
<field name="extension_days" readonly="1"/> |
|||
<field name="new_end_date" readonly="1"/> |
|||
</group> |
|||
<group> |
|||
<field name="extension_amount" readonly="1"/> |
|||
</group> |
|||
</group> |
|||
|
|||
<group> |
|||
<field name="notes" placeholder="Optional notes about this extension..."/> |
|||
</group> |
|||
|
|||
<footer> |
|||
<button name="action_extend_membership" string="Create Extension Order" |
|||
type="object" class="btn-primary"/> |
|||
<button string="Cancel" class="btn-secondary" special="cancel"/> |
|||
</footer> |
|||
</form> |
|||
</field> |
|||
</record> |
|||
</odoo> |
Loading…
Reference in new issue