From c76706d5668730e11035bde46ff16c9b05eb6978 Mon Sep 17 00:00:00 2001 From: Risvana Cybro Date: Tue, 9 Sep 2025 11:49:43 +0530 Subject: [PATCH] Sep 09: [FIX] Bug fixed 'gym_mgmt_system' --- gym_mgmt_system/__manifest__.py | 11 +- gym_mgmt_system/data/gym_membership_cron.xml | 12 + gym_mgmt_system/data/trainer_skill_data.xml | 57 +++ gym_mgmt_system/doc/RELEASE_NOTES.md | 11 + gym_mgmt_system/models/__init__.py | 4 + gym_mgmt_system/models/account_move.py | 66 +++ gym_mgmt_system/models/account_payment.py | 63 +++ gym_mgmt_system/models/gym_attendance.py | 198 ++++++++ gym_mgmt_system/models/gym_membership.py | 444 +++++++++++++++++- gym_mgmt_system/models/hr_employee.py | 46 +- gym_mgmt_system/models/sale_order.py | 45 +- gym_mgmt_system/security/ir.model.access.csv | 11 + gym_mgmt_system/static/description/index.html | 33 ++ .../views/gym_attendance_views.xml | 105 +++++ .../views/gym_membership_history_views.xml | 59 +++ .../views/gym_membership_views.xml | 91 +++- gym_mgmt_system/views/gym_menu.xml | 6 + gym_mgmt_system/views/hr_employee_views.xml | 108 +++-- .../views/membership_plan_views.xml | 1 - gym_mgmt_system/views/trainer_skill_views.xml | 2 - gym_mgmt_system/wizard/__init__.py | 1 + .../wizard/gym_membership_extension.py | 160 +++++++ .../wizard/gym_membership_extension.xml | 57 +++ 23 files changed, 1522 insertions(+), 69 deletions(-) create mode 100644 gym_mgmt_system/data/gym_membership_cron.xml create mode 100644 gym_mgmt_system/data/trainer_skill_data.xml create mode 100644 gym_mgmt_system/models/account_move.py create mode 100644 gym_mgmt_system/models/account_payment.py create mode 100644 gym_mgmt_system/models/gym_attendance.py create mode 100644 gym_mgmt_system/views/gym_attendance_views.xml create mode 100644 gym_mgmt_system/views/gym_membership_history_views.xml create mode 100644 gym_mgmt_system/views/gym_menu.xml create mode 100644 gym_mgmt_system/wizard/gym_membership_extension.py create mode 100644 gym_mgmt_system/wizard/gym_membership_extension.xml diff --git a/gym_mgmt_system/__manifest__.py b/gym_mgmt_system/__manifest__.py index 1ed3d95f3..8c0ca913b 100644 --- a/gym_mgmt_system/__manifest__.py +++ b/gym_mgmt_system/__manifest__.py @@ -21,7 +21,7 @@ ############################################################################# { 'name': 'GYM Management System', - 'version': '18.0.1.0.0', + 'version': '18.0.2.0.0', 'category': 'Industries', 'summary': 'GYM Management System For Managing ' 'Membership, Member, Workout Plan, etc', @@ -33,16 +33,21 @@ 'company': 'Cybrosys Techno Solutions', 'maintainer': 'Cybrosys Techno Solutions', 'website': "https://www.cybrosys.com", - 'depends': [ 'mail', 'contacts', 'hr', - 'product', 'membership', 'sale_management', + 'depends': [ 'base','mail', 'contacts', 'hr', + 'product', 'membership', 'sale_management','hr_skills', ], 'data': [ 'security/gym_mgmt_system_groups.xml', 'security/ir.model.access.csv', 'security/gym_mgmt_system_security.xml', 'data/ir_sequence_data.xml', + 'data/gym_membership_cron.xml', + 'data/trainer_skill_data.xml', 'wizard/assign_workout.xml', + 'wizard/gym_membership_extension.xml', + 'views/gym_menu.xml', 'views/product_template_views.xml', + 'views/gym_attendance_views.xml', 'views/res_partner_views.xml', 'views/exercise_for_views.xml', 'views/gym_exercise_views.xml', diff --git a/gym_mgmt_system/data/gym_membership_cron.xml b/gym_mgmt_system/data/gym_membership_cron.xml new file mode 100644 index 000000000..a30874b62 --- /dev/null +++ b/gym_mgmt_system/data/gym_membership_cron.xml @@ -0,0 +1,12 @@ + + + + Auto Expire Gym Memberships + + code + model._cron_expire_memberships() + 1 + days + + + \ No newline at end of file diff --git a/gym_mgmt_system/data/trainer_skill_data.xml b/gym_mgmt_system/data/trainer_skill_data.xml new file mode 100644 index 000000000..26c0a23e6 --- /dev/null +++ b/gym_mgmt_system/data/trainer_skill_data.xml @@ -0,0 +1,57 @@ + + + + Gym + True + + + + Strength Training + + + + + Cardio Training + + + + + Yoga + + + + + CrossFit + + + + + Pilates + + + + + Martial Arts + + + + + Swimming + + + + + Nutrition Coaching + + + + + Rehabilitation Training + + + + + Group Fitness + + + \ No newline at end of file diff --git a/gym_mgmt_system/doc/RELEASE_NOTES.md b/gym_mgmt_system/doc/RELEASE_NOTES.md index 6ab65ab82..4e6153c01 100644 --- a/gym_mgmt_system/doc/RELEASE_NOTES.md +++ b/gym_mgmt_system/doc/RELEASE_NOTES.md @@ -4,3 +4,14 @@ #### Version 18.0.1.0.0 #### ADD - Initial commit for GYM Management System + +#### 08.09.2025 +#### Version 18.0.2.0.0 +#### ADD +- Membership States – Added states (Draft, Confirmed, Active, Paused, Expired) with automatic transitions. +- Pause & Resume – Implemented pause/resume functionality with auto checkout on pause. +- Extend Membership – Added extend button with options (same plan, custom days, upgrade via wizard). +- Attendance Section – Added Quick Check-in, Attendance Records, and state validations. +- Trainer Skills Integration – Linked skills to Employee (Trainer) form with dedicated tab. +- Configured BMI & BMR calculations in Measurement History +- Fix exercises field in workout plan \ No newline at end of file diff --git a/gym_mgmt_system/models/__init__.py b/gym_mgmt_system/models/__init__.py index 95bdb5bfa..e49d07252 100644 --- a/gym_mgmt_system/models/__init__.py +++ b/gym_mgmt_system/models/__init__.py @@ -19,6 +19,7 @@ # If not, see . # ############################################################################# + from . import exercise_for from . import gym_exercise from . import gym_membership @@ -33,3 +34,6 @@ from . import trainer_skill from . import workout_days from . import workout_plan from . import workout_plan_option +from . import account_move +from . import account_payment +from . import gym_attendance diff --git a/gym_mgmt_system/models/account_move.py b/gym_mgmt_system/models/account_move.py new file mode 100644 index 000000000..5719a565c --- /dev/null +++ b/gym_mgmt_system/models/account_move.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +############################################################################# +# +# Cybrosys Technologies Pvt. Ltd. +# +# Copyright (C) 2025-TODAY Cybrosys Technologies() +# Author: Abbas P () +# +# 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 . +# +############################################################################# +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 \ No newline at end of file diff --git a/gym_mgmt_system/models/account_payment.py b/gym_mgmt_system/models/account_payment.py new file mode 100644 index 000000000..503168f44 --- /dev/null +++ b/gym_mgmt_system/models/account_payment.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +############################################################################# +# +# Cybrosys Technologies Pvt. Ltd. +# +# Copyright (C) 2025-TODAY Cybrosys Technologies() +# Author: Abbas P () +# +# 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 . +# +############################################################################# +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 diff --git a/gym_mgmt_system/models/gym_attendance.py b/gym_mgmt_system/models/gym_attendance.py new file mode 100644 index 000000000..ca00bd2e1 --- /dev/null +++ b/gym_mgmt_system/models/gym_attendance.py @@ -0,0 +1,198 @@ +# -*- coding: utf-8 -*- +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="[('is_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) + ]) + + 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_memberships = self.env['gym.membership'].search([ + ('member_id', '=', member.id), + ('state', '=', 'active') + ]) + + active_membership = None + if active_memberships: + if len(active_memberships) == 1: + active_membership = active_memberships + else: + active_membership = max(active_memberships, + key=lambda m: m.effective_end_date or fields.Date.today()) + + if active_membership: + if active_membership.effective_end_date and active_membership.effective_end_date < fields.Date.today(): + active_membership.action_expire() + else: + return { + 'can_checkin': True, + 'message': _('Check-in allowed.') + } + + any_membership = self.env['gym.membership'].search([ + ('member_id', '=', member.id) + ], order='id desc', limit=1) + + if not any_membership: + return { + 'can_checkin': False, + 'message': _('No membership found for this member.') + } + + if any_membership.state == 'paused': + return { + 'can_checkin': False, + 'message': _( + 'Cannot check in. Your latest membership is PAUSED.\n' + 'Please resume your membership to check in.' + ) + } + elif any_membership.state == 'expired': + return { + 'can_checkin': False, + 'message': _( + 'Cannot check in. Your latest membership has EXPIRED.\n' + 'Please renew your membership to continue.' + ) + } + elif any_membership.state in ['draft', 'confirm']: + return { + 'can_checkin': False, + 'message': _( + 'Cannot check in. Your membership is not yet active.\n' + 'Please wait for activation or contact support.' + ) + } + else: + return { + 'can_checkin': False, + 'message': _( + 'Cannot check in. Membership status: %s' + ) % any_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, + } + } \ No newline at end of file diff --git a/gym_mgmt_system/models/gym_membership.py b/gym_mgmt_system/models/gym_membership.py index 294993cba..bbe3c557d 100644 --- a/gym_mgmt_system/models/gym_membership.py +++ b/gym_mgmt_system/models/gym_membership.py @@ -20,6 +20,11 @@ # ############################################################################# from odoo import api, fields, models, _ +from odoo.exceptions import UserError, ValidationError +from datetime import date, timedelta +import logging +_logger = logging.getLogger(__name__) + class GymMembership(models.Model): @@ -42,8 +47,25 @@ class GymMembership(models.Model): required=True, tracking=True, domain="[('membership_date_from', " "'!=',False)]") - paid_amount = fields.Float(string="Paid Amount", tracking=True, - help="The amount paid for the membership") + + membership_duration = fields.Integer( + string='Plan Duration (Days)', + compute='_compute_membership_duration', + ) + + paid_amount = fields.Monetary( + string="Paid Amount", + compute="_compute_paid_amount", + store=True, + currency_field="company_currency_id", + tracking=True, + ) + + company_currency_id = fields.Many2one( + "res.currency", + related="company_id.currency_id", + readonly=True + ) membership_fees = fields.Float(string="Membership Fees", tracking=True, help="The membership fees", related="membership_scheme_id.list_price") @@ -51,6 +73,8 @@ class GymMembership(models.Model): ondelete='cascade', copy=False, help="Order reference", readonly=True) + + # Original dates from membership scheme membership_date_from = fields.Date(string='Membership Start Date', related="membership_scheme_id." "membership_date_from", @@ -61,22 +85,79 @@ class GymMembership(models.Model): "date_to", help='Date until which membership remains' 'active.') + + # NEW FIELDS FOR PAUSE/EXTEND FUNCTIONALITY + # Effective dates (calculated based on pauses and extensions) + effective_start_date = fields.Date(string='Effective Start Date', + compute='_compute_effective_dates', + help='Actual start date considering pauses') + effective_end_date = fields.Date(string='Effective End Date', + compute='_compute_effective_dates', + help='Actual end date considering pauses and extensions') + + # Pause tracking fields + current_pause_start = fields.Date(string='Current Pause Start Date', + help='Start date of current pause period') + total_paused_days = fields.Integer(string='Total Paused Days', default=0, + help='Total number of days this membership has been paused') + pause_history_ids = fields.One2many('gym.membership.pause', 'membership_id', + string='Pause History') + + # Extension tracking + extension_history_ids = fields.One2many('gym.membership.extension', 'membership_id', + string='Extension History') + total_extended_days = fields.Integer(string='Total Extended Days', default=0, + help='Total number of days this membership has been extended') + company_id = fields.Many2one('res.company', string='Company', default=lambda self: self.env.company, help='The field hold the company id') state = fields.Selection([ ('draft', 'Draft'), - ('confirm', 'Confirm'), + ('confirm', 'Confirmed'), + ('active', 'Active'), + ('paused', 'Paused'), + ('expired', 'Expired'), ('cancelled', 'Cancelled') ], default='draft', string='Status', + tracking=True, help="The status of record defined here") + extension_count = fields.Integer( + string='Extension Count', + compute='_compute_extension_count', + help='Number of times this membership has been extended' + ) + + can_extend = fields.Boolean( + string='Can Extend', + compute='_compute_can_extend' + ) + _sql_constraints = [ ('membership_date_greater', 'check(membership_date_to >= membership_date_from)', 'Error ! Ending Date cannot be set before Beginning Date.') ] + @api.depends('state', 'effective_end_date') + def _compute_can_extend(self): + """Compute if membership can be extended (only if expired)""" + for rec in self: + rec.can_extend = rec.state == 'expired' + + @api.depends('membership_date_from', 'membership_date_to', 'total_paused_days', 'total_extended_days') + def _compute_effective_dates(self): + """Compute effective start and end dates based on pauses and extensions""" + for rec in self: + rec.effective_start_date = rec.membership_date_from + if rec.membership_date_to: + # Add paused days and extended days to the original end date + additional_days = rec.total_paused_days + rec.total_extended_days + rec.effective_end_date = rec.membership_date_to + timedelta(days=additional_days) + else: + rec.effective_end_date = rec.membership_date_to + @api.model_create_multi def create(self, vals_list): """Sequence number for membership """ @@ -85,3 +166,360 @@ class GymMembership(models.Model): vals['reference'] = self.env['ir.sequence'].next_by_code( 'gym.membership') or 'New' return super(GymMembership, self).create(vals_list) + + def action_confirm(self): + for rec in self: + rec.state = 'confirm' + + def action_set_active(self): + for rec in self: + rec.state = 'active' + + def action_pause(self): + """Pause the membership - only from active state""" + for rec in self: + if rec.state != 'active': + raise UserError(_('Only active memberships can be paused.')) + + # IMPORTANT: Auto check-out member if currently checked in + current_attendance = self.env['gym.attendance'].search([ + ('member_id', '=', rec.member_id.id), + ('check_out', '=', False) # Find records without check-out + ], limit=1) + + if current_attendance: + # Force check-out with current timestamp + current_attendance.write({ + 'check_out': fields.Datetime.now() + }) + + # Log the automatic check-out + rec.message_post( + body=_('Member automatically checked out at %s due to membership pause.') % + fields.Datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + message_type='notification' + ) + + # Record the pause start date + rec.current_pause_start = date.today() + rec.state = 'paused' + + # Log the pause action + rec.message_post( + body=_('Membership paused on %s') % date.today().strftime('%Y-%m-%d'), + message_type='notification' + ) + + def action_resume(self): + """Resume the membership from paused state""" + for rec in self: + if rec.state != 'paused': + raise UserError(_('Only paused memberships can be resumed.')) + + if not rec.current_pause_start: + raise UserError(_('No pause start date found.')) + + # Calculate paused days + pause_end = date.today() + paused_days = (pause_end - rec.current_pause_start).days + + # Create pause history record + self.env['gym.membership.pause'].create({ + 'membership_id': rec.id, + 'pause_start': rec.current_pause_start, + 'pause_end': pause_end, + 'days_paused': paused_days, + }) + + # Update total paused days + rec.total_paused_days += paused_days + + # Clear current pause start and set to active + rec.current_pause_start = False + rec.state = 'active' + + # Log the resume action + rec.message_post( + body=_('Membership resumed on %s. Paused for %s days.') % ( + pause_end.strftime('%Y-%m-%d'), paused_days + ), + message_type='notification' + ) + + def _auto_checkout_member(self, reason="membership status change"): + """Helper method to automatically check out a member""" + self.ensure_one() + + # Find any active attendance (not checked out) + current_attendance = self.env['gym.attendance'].search([ + ('member_id', '=', self.member_id.id), + ('check_out', '=', False) + ], limit=1) + + if current_attendance: + # Set check-out time + current_attendance.write({ + 'check_out': fields.Datetime.now() + }) + + # Log the action + self.message_post( + body=_('Member automatically checked out at %s due to %s') % + (fields.Datetime.now().strftime('%Y-%m-%d %H:%M:%S'), reason), + message_type='notification' + ) + + return True + return False + + def action_expire(self): + """Expire the membership and auto check-out if needed""" + for rec in self: + # Auto check-out before expiring + rec._auto_checkout_member("membership expiry") + + # Set to expired + rec.state = 'expired' + + # Log expiry + rec.message_post( + body=_('Membership expired on %s') % date.today().strftime('%Y-%m-%d'), + message_type='notification' + ) + + def action_cancel(self): + """Cancel the membership and auto check-out if needed""" + for rec in self: + # Auto check-out before cancelling + rec._auto_checkout_member("membership cancellation") + + # Set to cancelled + rec.state = 'cancelled' + + def action_extend_membership(self): + """Open wizard to extend membership - only for expired memberships""" + self.ensure_one() + if self.state != 'expired': + raise UserError(_('Only expired memberships can be extended.')) + + # Check if already extended recently (optional business rule) + recent_extension = self.env['gym.membership.extension'].search([ + ('membership_id', '=', self.id), + ('extension_date', '>', fields.Date.today() - timedelta(days=30)) # Within last 30 days + ], limit=1) + + if recent_extension: + raise UserError(_( + 'This membership was already extended on %s. ' + 'You cannot extend again within 30 days of the last extension.' + ) % recent_extension.extension_date.strftime('%Y-%m-%d')) + + return { + 'name': _('Extend Membership'), + 'type': 'ir.actions.act_window', + 'res_model': 'gym.membership.extend.wizard', + 'view_mode': 'form', + 'target': 'new', + 'context': { + 'default_membership_id': self.id, + 'default_member_id': self.member_id.id, + } + } + + def force_checkout_inactive_members(self): + """ + Manual method to force check-out all members who are checked in + but have inactive memberships (paused, expired, cancelled) + """ + # Find all inactive memberships + inactive_memberships = self.search([ + ('state', 'in', ['paused', 'expired', 'cancelled']) + ]) + + checkout_count = 0 + for membership in inactive_memberships: + current_attendance = self.env['gym.attendance'].search([ + ('member_id', '=', membership.member_id.id), + ('check_out', '=', False) + ]) + + if current_attendance: + current_attendance.write({ + 'check_out': fields.Datetime.now() + }) + + checkout_count += 1 + + membership.message_post( + body=_('Forced check-out at %s due to inactive membership status: %s') % + (fields.Datetime.now().strftime('%Y-%m-%d %H:%M:%S'), membership.state), + message_type='notification' + ) + + if checkout_count > 0: + message = _('%s members were automatically checked out due to inactive memberships.') % checkout_count + notification_type = 'success' + else: + message = _('No members needed to be checked out.') + notification_type = 'info' + + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'message': message, + 'type': notification_type, + 'sticky': False, + } + } + + def _check_and_activate_membership(self): + for membership in self: + invoices = membership.sale_order_id.invoice_ids.filtered(lambda inv: inv.move_type == 'out_invoice') + if invoices and all(inv.payment_state == "paid" for inv in invoices): + membership.state = "active" + + @api.depends('sale_order_id.invoice_ids.payment_state', 'sale_order_id.invoice_ids.amount_residual') + def _compute_paid_amount(self): + for membership in self: + total_paid = 0.0 + if membership.sale_order_id: + invoices = membership.sale_order_id.invoice_ids.filtered( + lambda inv: inv.move_type == "out_invoice" and inv.state == "posted" + ) + for invoice in invoices: + total_paid += (invoice.amount_total - invoice.amount_residual) + + membership.paid_amount = total_paid + + # Cron job to automatically expire memberships + @api.model + def _cron_expire_memberships(self): + """Cron job to automatically set memberships to expired when effective end date passes""" + today = fields.Date.today() + _logger.info(f"Starting membership expiry cron job for date: {today}") + + # Only check active and paused memberships that have actually expired + active_memberships = self.search([ + ('state', 'in', ['active', 'paused']), + ('effective_end_date', '!=', False), # Must have an end date + ('effective_end_date', '<', today) # End date must be in the past + ]) + + expired_count = 0 + _logger.info(f"Found {len(active_memberships)} memberships to check for expiry") + + for membership in active_memberships: + try: + # Double-check the date to avoid errors + if membership.effective_end_date and membership.effective_end_date < today: + _logger.info( + f"Expiring membership {membership.reference} - End date: {membership.effective_end_date}") + + # Auto check-out before expiring + membership._auto_checkout_member("automatic membership expiry") + + # Set to expired + membership.state = 'expired' + membership.message_post( + body=_('Membership automatically expired on %s (End date: %s)') % + (today.strftime('%Y-%m-%d'), membership.effective_end_date.strftime('%Y-%m-%d')), + message_type='notification' + ) + expired_count += 1 + + except Exception as e: + _logger.error(f"Error expiring membership {membership.reference}: {str(e)}") + continue + + # Log the result for debugging + _logger.info(f"Cron job completed. Expired {expired_count} memberships") + + @api.depends('membership_date_from', 'membership_date_to') + def _compute_membership_duration(self): + for rec in self: + if rec.membership_date_from and rec.membership_date_to: + rec.membership_duration = (rec.membership_date_to - rec.membership_date_from).days + 1 + else: + rec.membership_duration = 0 + + def complete_extension(self, days_extended, extension_amount, sale_order_id=None): + """Complete the extension process and reactivate membership""" + self.ensure_one() + + if self.state != 'expired': + raise UserError(_('Only expired memberships can be extended.')) + + if days_extended <= 0: + raise UserError(_('Days extended must be greater than 0.')) + + # Create extension history record + extension_record = self.env['gym.membership.extension'].create({ + 'membership_id': self.id, + 'extension_date': fields.Date.today(), + 'days_extended': days_extended, + 'extension_amount': extension_amount, + 'sale_order_id': sale_order_id, + 'notes': f'Membership extended by {days_extended} days for ${extension_amount}' + }) + + # Update total extended days + self.total_extended_days += days_extended + + # IMPORTANT: Reactivate the membership after extension + self.state = 'active' + + # Force recomputation of effective dates + self._compute_effective_dates() + + # Log the extension + self.message_post( + body=_('Membership extended by %s days on %s. Amount paid: %s. Membership reactivated. New end date: %s') % + (days_extended, fields.Date.today().strftime('%Y-%m-%d'), + extension_amount, self.effective_end_date.strftime('%Y-%m-%d')), + message_type='notification' + ) + + return extension_record + + @api.depends('extension_history_ids') + def _compute_extension_count(self): + for rec in self: + rec.extension_count = len(rec.extension_history_ids) + + @api.depends('state', 'extension_count') + def _compute_can_extend(self): + """Compute if membership can be extended""" + for rec in self: + max_extensions = 3 # Business rule: max 3 extensions per membership + rec.can_extend = ( + rec.state == 'expired' and + rec.extension_count < max_extensions + ) + + +class GymMembershipPause(models.Model): + """Model to track pause history""" + _name = 'gym.membership.pause' + _description = 'Gym Membership Pause History' + _order = 'pause_start desc' + + membership_id = fields.Many2one('gym.membership', string='Membership', required=True, ondelete='cascade') + pause_start = fields.Date(string='Pause Start Date', required=True) + pause_end = fields.Date(string='Pause End Date', required=True) + days_paused = fields.Integer(string='Days Paused', required=True) + notes = fields.Text(string='Notes') + + +class GymMembershipExtension(models.Model): + """Model to track extension history""" + _name = 'gym.membership.extension' + _description = 'Gym Membership Extension History' + _order = 'extension_date desc' + + membership_id = fields.Many2one('gym.membership', string='Membership', required=True, ondelete='cascade') + extension_date = fields.Date(string='Extension Date', required=True, default=fields.Date.today) + days_extended = fields.Integer(string='Days Extended', required=True) + extension_amount = fields.Float(string='Extension Amount', required=True) + sale_order_id = fields.Many2one('sale.order', string='Extension Sale Order') + notes = fields.Text(string='Notes') \ No newline at end of file diff --git a/gym_mgmt_system/models/hr_employee.py b/gym_mgmt_system/models/hr_employee.py index 057d5eec2..b58bce27d 100644 --- a/gym_mgmt_system/models/hr_employee.py +++ b/gym_mgmt_system/models/hr_employee.py @@ -19,16 +19,48 @@ # If not, see . # ############################################################################# -from odoo import fields, models +from odoo import api, fields, models, _ class HrEmployee(models.Model): - """Inherited the model hr employee for adding some field to check whether he - is trainer or not.""" + """Inherited the model hr employee for adding gym trainer field.""" _inherit = 'hr.employee' is_trainer = fields.Boolean(string='Gym Trainer', - help="The employee is trainer ") - exercise_for_ids = fields.Many2many("trainer.skill", - string="Specialization", - help="Skill of the trainer") + help="The employee is trainer") + + gym_skill_ids = fields.Many2many( + 'hr.skill', + string='Gym Specializations', + compute='_compute_gym_skills', + help="Gym-related skills of the trainer (read-only display)" + ) + + @api.depends('skill_ids') + def _compute_gym_skills(self): + """Get only gym-related skills from all employee skills""" + for employee in self: + try: + if employee.skill_ids: + gym_skills = employee.skill_ids.filtered( + lambda emp_skill: emp_skill.skill_id and emp_skill.skill_id.skill_type_id.is_gym_skill + ) + employee.gym_skill_ids = gym_skills.mapped('skill_id') + else: + employee.gym_skill_ids = False + except Exception: + employee.gym_skill_ids = False + + +class HrSkillType(models.Model): + """Extend HR Skill Type for gym category""" + _inherit = 'hr.skill.type' + + is_gym_skill = fields.Boolean(string='Is Gym Skill', default=False) + + +class HrSkill(models.Model): + """Extend HR Skill for gym skills""" + _inherit = 'hr.skill' + + is_gym_skill = fields.Boolean(related='skill_type_id.is_gym_skill', store=True) \ No newline at end of file diff --git a/gym_mgmt_system/models/sale_order.py b/gym_mgmt_system/models/sale_order.py index a358d0a94..1a82dfa22 100644 --- a/gym_mgmt_system/models/sale_order.py +++ b/gym_mgmt_system/models/sale_order.py @@ -19,23 +19,46 @@ # If not, see . # ############################################################################# -from odoo import models, _ +# -*- coding: utf-8 -*- +from odoo import api, fields, models, _ class SaleOrder(models.Model): """Inherit the sale.order model for supering the action confirm.""" _inherit = "sale.order" + is_membership_extension = fields.Boolean( + string='Is Membership Extension', + default=False, + help='If True, this sale order is for extending an existing membership and should not create a new membership record.' + ) + def action_confirm(self): - """ Membership created directly from sale order confirmed """ - product = self.env['product.product'].search([('membership_date_from', '!=', False), - ('id', '=', self.order_line.product_id.id)]) + """Membership created directly from sale order confirmed - FIXED VERSION""" + + if self.is_membership_extension: + # Log that this is an extension order + self.message_post( + body=_('This is a membership extension order. No new membership record will be created.'), + message_type='notification' + ) + # Call super but skip membership creation logic + return super().action_confirm() + + # Original logic for NEW memberships only + product = self.env['product.product'].search([ + ('membership_date_from', '!=', False), + ('id', 'in', self.order_line.product_id.ids) + ]) + for record in product: - self.env['gym.membership'].create([ - {'member_id': self.partner_id.id, - 'membership_date_from': record.membership_date_from, - 'membership_scheme_id': self.order_line.product_id.id, - 'sale_order_id': self.id, - }]) + membership = self.env['gym.membership'].create({ + 'member_id': self.partner_id.id, + 'membership_date_from': record.membership_date_from, + 'membership_scheme_id': record.id, + 'sale_order_id': self.id, + 'state': 'confirm', + }) self.partner_id.is_gym_member = True - return super().action_confirm() + + return super().action_confirm() \ No newline at end of file diff --git a/gym_mgmt_system/security/ir.model.access.csv b/gym_mgmt_system/security/ir.model.access.csv index df153ab21..762504585 100644 --- a/gym_mgmt_system/security/ir.model.access.csv +++ b/gym_mgmt_system/security/ir.model.access.csv @@ -41,3 +41,14 @@ access_trainer_skill_member,access.trainer.skill,model_trainer_skill,gym_mgmt_sy access_my_workout_plan_operator,access.my.workout.plan,model_my_workout_plan,gym_mgmt_system.group_gym_operator,1,1,1,1 access_my_workout_plan_trainer,access.my.workout.plan,model_my_workout_plan,gym_mgmt_system.group_gym_trainer,1,1,1,1 access_my_workout_plan_member,access.my.workout.plan,model_my_workout_plan,gym_mgmt_system.group_gym_member,1,0,0,0 +access_gym_membership_pause_operator,access.gym.membership.pause operator,model_gym_membership_pause,gym_mgmt_system.group_gym_operator,1,1,1,1 +access_gym_membership_pause_trainer,access.gym.membership.pause trainer,model_gym_membership_pause,gym_mgmt_system.group_gym_trainer,1,0,0,0 +access_membership_pause_member,access.gym.membership.pause member,model_gym_membership_pause,gym_mgmt_system.group_gym_member,1,0,0,0 +access_membership_extension_operator,access.gym.membership.extension operator,model_gym_membership_extension,gym_mgmt_system.group_gym_operator,1,1,1,1 +access_membership_extension_trainer,access.gym.membership.extension trainer,model_gym_membership_extension,gym_mgmt_system.group_gym_trainer,1,0,0,0 +access_membership_extension_member,access.gym.membership.extension member,model_gym_membership_extension,gym_mgmt_system.group_gym_member,1,0,0,0 +access_membership_extend_wizard_operator,access.gym.membership.extend.wizard operator,model_gym_membership_extend_wizard,gym_mgmt_system.group_gym_operator,1,1,1,1 +access_gym_attendance_operator,access.gym.attendance.operator,model_gym_attendance,gym_mgmt_system.group_gym_operator,1,1,1,1 +access_gym_attendance_trainer,access.gym.attendance.trainer,model_gym_attendance,gym_mgmt_system.group_gym_trainer,1,0,0,0 +access_gym_attendance_member,access.gym.attendance.member,model_gym_attendance,gym_mgmt_system.group_gym_member,1,0,0,0 + diff --git a/gym_mgmt_system/static/description/index.html b/gym_mgmt_system/static/description/index.html index 26fdfa2ea..0d379e947 100644 --- a/gym_mgmt_system/static/description/index.html +++ b/gym_mgmt_system/static/description/index.html @@ -1038,6 +1038,39 @@ +
+
+

+ Latest Release 18.0.2.0.0 +

+ + 8th Sep, 2025 + +
+
+
+
+
+ Add +
+
+
+
    +
  • Membership States – Added states (Draft, Confirmed, Active, Paused, Expired) with automatic transitions.
  • +
  • Pause & Resume – Implemented pause/resume functionality with auto checkout on pause.
  • +
  • Extend Membership – Added extend button with options (same plan, custom days, upgrade via wizard)
  • +
  • Attendance Section – Added Quick Check-in, Attendance Records, and state validations.
  • +
  • Trainer Skills Integration – Linked skills to Employee (Trainer) form with dedicated tab.
  • +
  • Corrected BMI & BMR calculations in Measurement History
  • +
  • Fix exercises field in workout plan
  • +
+
+
+
+
+
+
diff --git a/gym_mgmt_system/views/gym_attendance_views.xml b/gym_mgmt_system/views/gym_attendance_views.xml new file mode 100644 index 000000000..8f9d87d8f --- /dev/null +++ b/gym_mgmt_system/views/gym_attendance_views.xml @@ -0,0 +1,105 @@ + + + + gym.attendance.view.form + gym.attendance + +
+
+
+ + + + + + + + + + + + +
+
+
+ + + gym.attendance.view.tree + gym.attendance + + + + + + + +