diff --git a/gym_mgmt_system/__manifest__.py b/gym_mgmt_system/__manifest__.py
index 694ef5091..e3753b820 100644
--- a/gym_mgmt_system/__manifest__.py
+++ b/gym_mgmt_system/__manifest__.py
@@ -21,7 +21,7 @@
#############################################################################
{
'name': 'GYM Management System',
- 'version': '16.0.1.0.0',
+ 'version': '16.0.2.0.0',
'category': 'Industries',
'summary': 'GYM Management System For Managing '
'Membership, Member, Workout Plan, etc',
@@ -34,14 +34,19 @@
'maintainer': 'Cybrosys Techno Solutions',
'website': "https://www.cybrosys.com",
'depends': [ 'mail', 'contacts', 'hr',
- 'product', 'membership', 'sale_management',
+ '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/gym_attendance_views.xml',
'views/product_template_views.xml',
'views/res_partner_views.xml',
'views/exercise_for_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..ad3c4ea22
--- /dev/null
+++ b/gym_mgmt_system/data/gym_membership_cron.xml
@@ -0,0 +1,12 @@
+
+
+
+ Auto Expire Gym Memberships
+
+ code
+ model._cron_expire_memberships()
+ 5
+ minutes
+
+
+
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 ec5063af3..77c0fd12b 100644
--- a/gym_mgmt_system/doc/RELEASE_NOTES.md
+++ b/gym_mgmt_system/doc/RELEASE_NOTES.md
@@ -4,3 +4,12 @@
#### Version 16.0.1.0.0
#### ADD
- Initial commit for GYM Management System
+
+#### 08.09.2025
+#### Version 16.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.
diff --git a/gym_mgmt_system/models/__init__.py b/gym_mgmt_system/models/__init__.py
index 70a88ae17..a4fd26578 100644
--- a/gym_mgmt_system/models/__init__.py
+++ b/gym_mgmt_system/models/__init__.py
@@ -33,3 +33,6 @@ from . import trainer_skill
from . import workout_days
from . import workout_plan
from . import workout_plan_option
+from . import account_payment
+from . import account_move
+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..fe4f7b1cf
--- /dev/null
+++ b/gym_mgmt_system/models/account_move.py
@@ -0,0 +1,66 @@
+# -*- coding: utf-8 -*-
+#############################################################################
+#
+# Cybrosys Technologies Pvt. Ltd.
+#
+# Copyright (C) 2023-TODAY Cybrosys Technologies()
+# Author: Sahla Sherin ()
+#
+# 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..a3a690647
--- /dev/null
+++ b/gym_mgmt_system/models/account_payment.py
@@ -0,0 +1,64 @@
+# -*- coding: utf-8 -*-
+#############################################################################
+#
+# Cybrosys Technologies Pvt. Ltd.
+#
+# Copyright (C) 2023-TODAY Cybrosys Technologies()
+# Author: Sahla Sherin ()
+#
+# 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
\ No newline at end of file
diff --git a/gym_mgmt_system/models/gym_attendance.py b/gym_mgmt_system/models/gym_attendance.py
new file mode 100644
index 000000000..9d662f088
--- /dev/null
+++ b/gym_mgmt_system/models/gym_attendance.py
@@ -0,0 +1,218 @@
+# -*- coding: utf-8 -*-
+#############################################################################
+#
+# Cybrosys Technologies Pvt. Ltd.
+#
+# Copyright (C) 2023-TODAY Cybrosys Technologies()
+# Author: Sahla Sherin ()
+#
+# 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 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,
+ }
+ }
\ 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 feac4544b..4c252733b 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
+from datetime import date, timedelta
+import logging
+
+_logger = logging.getLogger(__name__)
class GymMembership(models.Model):
@@ -29,21 +34,33 @@ class GymMembership(models.Model):
_description = "Gym Membership"
_rec_name = "reference"
- reference = fields.Char(string='GYM reference',readonly=True,
- default=lambda self: _('New'))
+ reference = fields.Char(string='GYM reference', readonly=True, default=lambda self: _('New'),
+ help="Member reference")
member_id = fields.Many2one('res.partner', string='Member',
required=True, tracking=True,
+ help="Member taken the membership",
domain="[('gym_member', '!=',False)]")
membership_scheme_id = fields.Many2one('product.product',
string='Membership scheme',
+ help="Member ship scheme",
required=True, tracking=True,
domain="[('membership_date_from', "
"'!=',False)]")
- paid_amount = fields.Float(string="Paid Amount", tracking=True)
+ 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")
sale_order_id = fields.Many2one('sale.order', string='Sales Order',
ondelete='cascade', copy=False,
+ help="Order reference",
readonly=True)
membership_date_from = fields.Date(string='Membership Start Date',
related="membership_scheme_id."
@@ -55,27 +72,383 @@ class GymMembership(models.Model):
"date_to",
help='Date until which membership remains'
'active.')
+ 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')
+ 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_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.model
- def create_multi(self, vals):
+ @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:
+ 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 """
- if vals.get('reference', ('New')) == ('New'):
- vals['reference'] = self.env['ir.sequence'].next_by_code(
- 'gym.membership') or ('New')
- res = super(GymMembership, self).create(vals)
- return res
+ for vals in vals_list:
+ if vals.get('reference', 'New') == 'New':
+ 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.'))
+ current_attendance = self.env['gym.attendance'].search([
+ ('member_id', '=', rec.member_id.id),
+ ('check_out', '=', False)
+ ], limit=1)
+
+ if current_attendance:
+ current_attendance.write({
+ 'check_out': fields.Datetime.now()
+ })
+ 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'
+ )
+
+ rec.current_pause_start = date.today()
+ rec.state = 'paused'
+
+ 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.'))
+
+ pause_end = date.today()
+ paused_days = (pause_end - rec.current_pause_start).days
+
+ self.env['gym.membership.pause'].create({
+ 'membership_id': rec.id,
+ 'pause_start': rec.current_pause_start,
+ 'pause_end': pause_end,
+ 'days_paused': paused_days,
+ })
+ rec.total_paused_days += paused_days
+ rec.current_pause_start = False
+ rec.state = 'active'
+
+ 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()
+
+ current_attendance = self.env['gym.attendance'].search([
+ ('member_id', '=', self.member_id.id),
+ ('check_out', '=', False)
+ ], limit=1)
+
+ if current_attendance:
+ current_attendance.write({
+ 'check_out': fields.Datetime.now()
+ })
+
+ 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:
+ rec._auto_checkout_member("membership expiry")
+ rec.state = 'expired'
+ 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:
+ rec._auto_checkout_member("membership cancellation")
+ 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.'))
+
+ recent_extension = self.env['gym.membership.extension'].search([
+ ('membership_id', '=', self.id),
+ ('extension_date', '>', fields.Date.today() - timedelta(days=30))
+ ], 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)
+ """
+ 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
+
+ @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}")
+
+ active_memberships = self.search([
+ ('state', 'in', ['active', 'paused']),
+ ('effective_end_date', '!=', False),
+ ('effective_end_date', '<', today)
+ ])
+
+ expired_count = 0
+ _logger.info(f"Found {len(active_memberships)} memberships to check for expiry")
+
+ for membership in active_memberships:
+ try:
+ if membership.effective_end_date and membership.effective_end_date < today:
+ _logger.info(
+ f"Expiring membership {membership.reference} - End date: {membership.effective_end_date}")
+
+ membership._auto_checkout_member("automatic membership expiry")
+
+ 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
+
+ _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.'))
+
+ 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}'
+ })
+
+ self.total_extended_days += days_extended
+ self.state = 'active'
+ self._compute_effective_dates()
+ 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
+ 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')
diff --git a/gym_mgmt_system/models/hr_employee.py b/gym_mgmt_system/models/hr_employee.py
index 12dbb5cc3..add366ac7 100644
--- a/gym_mgmt_system/models/hr_employee.py
+++ b/gym_mgmt_system/models/hr_employee.py
@@ -1,3 +1,4 @@
+
# -*- coding: utf-8 -*-
#############################################################################
#
@@ -19,16 +20,58 @@
# 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'
- 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")
+ is_trainer = fields.Boolean(string='Gym 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)
+
+class HrSkillLevel(models.Model):
+ _inherit = 'hr.skill.level'
+
+ skill_type_id = fields.Many2one(
+ 'hr.skill.type',
+ string="Skill Type",
+ required=True,
+ help="The skill type this level belongs to (e.g. Gym, Dev, Music)."
+ )
\ No newline at end of file
diff --git a/gym_mgmt_system/models/res_partner.py b/gym_mgmt_system/models/res_partner.py
index e7628b197..b2e273631 100644
--- a/gym_mgmt_system/models/res_partner.py
+++ b/gym_mgmt_system/models/res_partner.py
@@ -26,13 +26,13 @@ class ResPartner(models.Model):
"""Inherited the partner model for adding gym related fields."""
_inherit = 'res.partner'
- gym_member = fields.Boolean(string='Gym Member', default=True,
- help='This field define the whether is member'
- 'of gym')
- membership_count = fields.Integer('membership_count',
+ gym_member = fields.Boolean(string='Gym Member', default=False,
+ help='This field define the whether is '
+ 'member of gym')
+ membership_count = fields.Integer(string='Membership Count',
compute='_compute_membership_count',
help='This help to count the membership')
- measurement_count = fields.Integer('measurement_count',
+ measurement_count = fields.Integer(string='Measurement Count',
compute='_compute_measurement_count',
help='This helps to get the umber of '
'measurements for gym members')
diff --git a/gym_mgmt_system/models/sale_order.py b/gym_mgmt_system/models/sale_order.py
index d517bc2b7..f6c4f43bb 100644
--- a/gym_mgmt_system/models/sale_order.py
+++ b/gym_mgmt_system/models/sale_order.py
@@ -27,17 +27,18 @@ class SaleOrder(models.Model):
_inherit = "sale.order"
def action_confirm(self):
- """ Membership created directly from sale order confirmed """
+ """Membership created directly from sale order confirmed"""
product = self.env['product.product'].search([
('membership_date_from', '!=', False),
- ('id', '=', self.order_line.product_id.id)])
+ ('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,
- }])
-
- res = super(SaleOrder, self).action_confirm()
- return res
+ 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.gym_member = True
+ return super().action_confirm()
\ No newline at end of file
diff --git a/gym_mgmt_system/models/trainer_skill.py b/gym_mgmt_system/models/trainer_skill.py
index 6688916dc..bc40f6518 100644
--- a/gym_mgmt_system/models/trainer_skill.py
+++ b/gym_mgmt_system/models/trainer_skill.py
@@ -28,8 +28,8 @@ class TrainerSkill(models.Model):
_inherit = ["mail.thread", "mail.activity.mixin"]
_description = "Trainer Skill"
- name = fields.Char(string="Name", help="Name")
- code = fields.Char(string="Code", help="Code")
+ name = fields.Char(string="Name", help="Name of the trainer", required=True)
+ code = fields.Char(string="Code", help="Code for the trainer")
company_id = fields.Many2one('res.company', string='Company',
default=lambda self: self.env.company,
help="This field hold company id")
diff --git a/gym_mgmt_system/security/ir.model.access.csv b/gym_mgmt_system/security/ir.model.access.csv
index 1e0193f66..bec7d1a49 100644
--- a/gym_mgmt_system/security/ir.model.access.csv
+++ b/gym_mgmt_system/security/ir.model.access.csv
@@ -8,9 +8,6 @@ access_hr_employee_operator,access.hr.employee,model_hr_employee,gym_mgmt_system
access_sale_order_trainer,access.sale.order,model_sale_order,gym_mgmt_system.group_gym_trainer,1,1,1,0
access_sale_order_member,access.sale.order,model_sale_order,gym_mgmt_system.group_gym_member,1,0,0,0
access_sale_order_operator,access.sale.order,model_sale_order,gym_mgmt_system.group_gym_operator,1,1,1,1
-access_product_template_trainer,access.product.template,model_product_template,gym_mgmt_system.group_gym_trainer,1,1,1,0
-access_product_template_member,access.product.template,model_product_template,gym_mgmt_system.group_gym_member,1,0,0,0
-access_product_template_operator,access.product.template,model_product_template,gym_mgmt_system.group_gym_operator,1,1,1,1
access_res_partner_trainer,access.res.partner,model_res_partner,gym_mgmt_system.group_gym_trainer,1,0,0,0
access_res_partner_operator,access.res.partner,model_res_partner,gym_mgmt_system.group_gym_operator,1,1,1,1
access_res_partner_member,access.res.partner,model_res_partner,gym_mgmt_system.group_gym_member,1,0,0,0
@@ -20,9 +17,6 @@ access_gym_membership_member,access.gym.membership,model_gym_membership,gym_mgmt
access_measurement_history_operator,access.measurement.history,model_measurement_history,gym_mgmt_system.group_gym_operator,1,1,1,1
access_measurement_history_trainer,access.measurement.history,model_measurement_history,gym_mgmt_system.group_gym_trainer,1,1,1,1
access_measurement_history_member,access.measurement.history,model_measurement_history,gym_mgmt_system.group_gym_member,1,0,0,0
-access_trainer_skill_operator,access.trainer_skill,model_trainer_skill,gym_mgmt_system.group_gym_operator,1,1,1,1
-access_trainer_skill_member,access.trainer_skill,model_trainer_skill,gym_mgmt_system.group_gym_member,1,0,0,0
-access_trainer_skill_trainer,access.trainer_skill,model_trainer_skill,gym_mgmt_system.group_gym_trainer,1,1,1,1
access_product_template_trainer,access.product_template,model_product_template,gym_mgmt_system.group_gym_trainer,1,1,1,1
access_product_template_member,access.product_template,model_product_template,gym_mgmt_system.group_gym_member,1,0,0,0
access_product_template_operator,access.product_template,model_product_template,gym_mgmt_system.group_gym_operator,1,1,1,1
@@ -47,3 +41,13 @@ 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/views/gym_attendance_views.xml b/gym_mgmt_system/views/gym_attendance_views.xml
new file mode 100644
index 000000000..73fed2386
--- /dev/null
+++ b/gym_mgmt_system/views/gym_attendance_views.xml
@@ -0,0 +1,107 @@
+
+
+
+ gym.attendance.view.form
+ gym.attendance
+
+
+
+
+
+
+ gym.attendance.view.tree
+ gym.attendance
+
+
+
+
+
+
+
+
+
+
+
+
+
+ gym.attendance.quick.checkin.form
+ gym.attendance
+
+
+
+
+
+
+ Gym Attendance
+ ir.actions.act_window
+ gym.attendance
+ tree,form
+
+
+
+ Quick Check-In
+ ir.actions.act_window
+ gym.attendance
+ form
+
+ new
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/gym_mgmt_system/views/gym_membership_views.xml b/gym_mgmt_system/views/gym_membership_views.xml
index 1fefbf7b4..71eace723 100644
--- a/gym_mgmt_system/views/gym_membership_views.xml
+++ b/gym_mgmt_system/views/gym_membership_views.xml
@@ -1,14 +1,26 @@
-
- gym.membership.form
+ gym.membership.view.form
gym.membership
-