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 + + + + + + + +