from odoo import models, fields, _, api, Command, SUPERUSER_ID from odoo.exceptions import ValidationError, AccessError, UserError from datetime import date, datetime, timedelta from dateutil.relativedelta import relativedelta from odoo.addons.phone_validation.tools import phone_validation import logging _logger = logging.getLogger(__name__) external_tracking_fields = { "last_consultation_date", "match_status", "practice_status", "predicted_return_date", "return_date", } internal_tracking_fields = { "team_info_notes", "age", "date_of_birth", } class Patient(models.Model): _name = "sports.patient" _description = "Patient" _inherit = ["mail.thread", "mail.activity.mixin"] _sql_constraints = [ ('unique_patient', 'UNIQUE(partner_id)', 'A patient with this contact already exists.'), ] pending_removal = fields.Boolean(string='Pending Removal', default=False, tracking=True, help='Indicates if this player has a pending removal request') _order = "last_name, first_name" active = fields.Boolean( string='Active', default=True, help="If unchecked, it means this patient has been archived and won't appear in searches by default.") # res.partner fields partner_id = fields.Many2one( comodel_name="res.partner", string="Contact", ondelete="restrict", compute_sudo=True, ) first_name = fields.Char(required=True, tracking=True) last_name = fields.Char(required=True, tracking=True) name = fields.Char( related="partner_id.name", ) phone = fields.Char(related="partner_id.phone", readonly=False) mobile = fields.Char(related="partner_id.mobile", readonly=False) street = fields.Char(related="partner_id.street", readonly=False) street2 = fields.Char(related="partner_id.street2", readonly=False) city = fields.Char(related="partner_id.city", readonly=False) state_id = fields.Many2one(related="partner_id.state_id", readonly=False) zip = fields.Char(related="partner_id.zip", readonly=False) country_id = fields.Many2one(related="partner_id.country_id", readonly=False) email = fields.Char(related="partner_id.email", readonly=False) # Patient fields date_of_birth = fields.Date( groups="bemade_sports_clinic.group_sports_clinic_treatment_professional", tracking=True, ) age = fields.Integer( compute="_compute_age", groups="bemade_sports_clinic.group_sports_clinic_treatment_professional", ) contact_ids = fields.One2many( comodel_name="sports.patient.contact", inverse_name="patient_id", string="Patient Contacts", groups="bemade_sports_clinic.group_sports_clinic_user", ) team_ids = fields.Many2many( comodel_name="sports.team", relation="sports_team_patient_rel", column1="patient_id", column2="team_id", string="Teams", ) match_status = fields.Selection( # Selection rather than bool for easy expansion later selection=[ ("yes", "Yes"), ("no", "No"), ], required=True, default="yes", tracking=True, ) practice_status = fields.Selection( selection=[("yes", "Yes"), ("no_contact", "Yes, no contact"), ("no", "No")], tracking=True, required=True, default="yes", ) injury_ids = fields.One2many( comodel_name="sports.patient.injury", inverse_name="patient_id", string="Injuries", ) treatment_note_ids = fields.One2many( comodel_name="sports.treatment.note", inverse_name="patient_id", string="Treatment Notes", ) treatment_note_count = fields.Integer( compute="_compute_treatment_note_count", string="Treatment Note Count", ) injured_since = fields.Date(compute="_compute_is_injured") predicted_return_date = fields.Date(tracking=True) return_date = fields.Date( tracking=True, help="When the player was cleared by medical staff to " "return to match play.", ) is_injured = fields.Boolean(compute="_compute_is_injured") stage = fields.Selection( selection=[ ("no_play", "Injured"), ("practice_ok", "Practice OK"), ("healthy", "Play OK"), ], compute="_compute_stage", ) last_consultation_date = fields.Date(tracking=True) active_injury_count = fields.Integer(compute="_compute_active_injury_count") allergies = fields.Text() team_info_notes = fields.Html( string="Notes", tracking=True, ) def default_get(self, fields_list): res = super().default_get(fields_list) if ( "team_ids" in fields_list and "params" in self.env.context and self.env.context.get("params")["model"] == "sports.team" ): team = self.env["sports.team"].browse(self.env.context.get("params")["id"]) team_ids = [Command.set([team.id])] if team_ids: res.update({"team_ids": team_ids}) return res def write(self, values): res = super().write(values) if "team_ids" in values: self.sudo().recompute_followers() if "first_name" in values or "last_name" in values: self._recompute_name() return res def _recompute_name(self): for rec in self: rec.partner_id.with_context(patient_update=True).name = ( rec._get_name_from_first_and_last(rec.first_name, rec.last_name) ) @api.model_create_multi def create(self, vals_list): for row in vals_list: if "partner_id" not in row: row["partner_id"] = ( self.env["res.partner"] .create( { "name": self._get_name_from_first_and_last( row["first_name"], row["last_name"] ) } ) .id ) res = super().create(vals_list) res.sudo().recompute_followers() return res @api.constrains("match_status", "practice_status") def constrain_match_and_practice_status(self): """Avoid invalid combinations of match and practice status: - Yes (match), No (practice) - Yes (match), No Contact (practice) """ # combinations of (match_status, practice_status) that are valid valid_combinations = [ ("yes", "yes"), ("no", "yes"), ("no", "no_contact"), ("no", "no"), ] for rec in self: if (rec.match_status, rec.practice_status) not in valid_combinations: raise ValidationError( _("Invalid combination of match and practice status.") ) @api.depends("injury_ids.stage") def _compute_active_injury_count(self): for rec in self: rec.active_injury_count = len( rec.injury_ids.filtered(lambda r: r.stage == "active") ) @api.depends("match_status", "practice_status") def _compute_stage(self): stage_map = { ("yes", "yes"): "healthy", ("no", "yes"): "practice_ok", ("no", "no_contact"): "practice_ok", ("no", "no"): "no_play", } for rec in self: # not a valid combination, will be caught by constraint if save is attempted if (rec.match_status, rec.practice_status) not in stage_map: rec.stage = False continue rec.stage = stage_map[(rec.match_status, rec.practice_status)] @api.depends("date_of_birth") def _compute_age(self): for rec in self: if not rec.date_of_birth: rec.age = False else: rec.age = relativedelta(date.today(), rec.date_of_birth).years @api.model def _get_name_from_first_and_last(self, first_name, last_name): names = [] if first_name: names.append(first_name) if last_name: names.append(last_name) return " ".join(names) @api.depends("practice_status", "match_status", "injury_ids.injury_date") def _compute_is_injured(self): for patient in self: active_injuries = self.env["sports.patient.injury"].search( [ ("patient_id", "=", patient.id), ("stage", "=", "active"), ] ) if active_injuries: patient.is_injured = True patient.injured_since = min(active_injuries.mapped("injury_date")) else: patient.is_injured = False patient.injured_since = False def _compute_treatment_note_count(self): for patient in self: patient.treatment_note_count = self.env['sports.treatment.note'].search_count( [('patient_id', '=', patient.id)] ) def action_view_patient_form(self): self.ensure_one() return { "type": "ir.actions.act_window", "view_mode": "form", "res_model": "sports.patient", "res_id": self.id, "context": self._context, } def action_consulted_today(self): self.ensure_one() # should just be called from form view self.last_consultation_date = date.today() return { "view_mode": "form", "res_model": "sports.patient", "context": self._context, "res_id": self.id, } def action_report_injury(self): """Open the injury report form for this patient. For portal users: redirects to the portal form For backend users: opens a new injury form in the backend """ self.ensure_one() # Check if current user is a portal user is_portal = self.env.user.has_group('base.group_portal') if is_portal: # Redirect to portal injury form base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url') portal_url = f"{base_url}/my/patient/injury/new?patient_id={self.id}" return { 'type': 'ir.actions.act_url', 'url': portal_url, 'target': 'self', } else: # Open backend injury form return { 'type': 'ir.actions.act_window', 'name': f'Report Injury for {self.name}', 'view_mode': 'form', 'res_model': 'sports.patient.injury', 'context': { 'default_patient_id': self.id, 'default_patient_name': self.name, 'default_stage': 'active', 'default_team_id': self.team_ids[0].id if self.team_ids else False }, } @api.onchange("mobile", "country_id") def _onchange_mobile_validation(self): if self.mobile: self.mobile = self._phone_format(self.mobile, force_format="INTERNATIONAL") @api.onchange("phone", "country_id") def _onchange_phone_validation(self): if self.phone: self.phone = self._phone_format(self.phone, force_format="INTERNATIONAL") def _phone_format(self, number, force_format="E164"): country = self.country_id or self.env.company.country_id if not country or not number: return number return phone_validation.phone_format( number, country.code if country else None, country.phone_code if country else None, force_format=force_format, raise_exception=False, ) def _track_subtype(self, init_values): return self.env.ref("mail.mt_note") def _track_template(self, changes): res = super()._track_template(changes) params = set(changes) external = bool(external_tracking_fields & params) if external: first_external_field = (external_tracking_fields & params).pop() res[first_external_field] = ( self.env.ref( "bemade_sports_clinic.mail_template_patient_status_update" ), { "auto_delete": False, "subtype_id": self.env.ref( "bemade_sports_clinic.subtype_patient_external_update" ).id, "email_layout_xmlid": "mail.mail_notification_light", }, ) if "team_info_notes" in changes: res["team_info_notes"] = ( self.env.ref( "bemade_sports_clinic.mail_template_patient_new_team_note" ), { "auto_delete": False, "subtype_id": self.env.ref( "bemade_sports_clinic.subtype_patient_internal_update" ).id, "email_layout_xmlid": "mail.mail_notification_light", }, ) return res def _get_team_head_therapist_user(self, team): """Get the head therapist user for a team, or None if not found""" head_therapist = team.staff_ids.filtered( lambda s: s.role == 'head_therapist' and s.user_ids ) if head_therapist: return head_therapist.user_ids[0] return None def _get_admin_user(self): """Get the admin user with the lowest ID""" return self.env['res.users'].search([('active', '=', True)], order='id', limit=1) def request_team_removal(self, team_id, reason=None): """ Request removal of a player from a team by setting the pending_removal flag. The actual activity creation will be handled by the scheduled action. :param int team_id: ID of the team to remove the player from :param str reason: Optional reason for removal :return: dict: Action to display a notification to the user """ self.ensure_one() team = self.env['sports.team'].browse(team_id) if not team: raise ValidationError(_("Team not found")) if team not in self.team_ids: raise ValidationError(_("Player is not a member of this team")) # Check if there's already a pending removal request if self.pending_removal: raise ValidationError(_("A removal request is already pending for this player")) # Check if this is the last team is_last_team = len(self.team_ids) <= 1 # Mark as pending removal - the cron job will handle the rest self.write({'pending_removal': True}) # Log the request in the chatter (using sudo to ensure it works for portal users) message = _("Removal requested from team %(team)s by %(user)s. Reason: %(reason)s") % { 'team': team.name, 'user': self.env.user.name, 'reason': reason or _("No reason provided") } self.sudo().message_post(body=message) # Notify the coach who made the request coach_notification = _("Your removal request for %(player)s from team %(team)s has been submitted for review.") % { 'player': self.display_name, 'team': team.name } if is_last_team: coach_notification += _("\n\n⚠️ WARNING: This is the player's only team. They will be archived if removed.") return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': _('Removal Request Submitted'), 'message': coach_notification, 'type': 'success', 'sticky': True, } } def _schedule_removal_request_activity(self, request_data): """ Scheduled action to create an activity for the head therapist to review the removal request. :param dict request_data: Data for the removal request """ player = self.env['sports.patient'].browse(request_data['player_id']) team = self.env['sports.team'].browse(request_data['team_id']) requested_by = self.env['res.users'].browse(request_data['requested_by_id']) reason = request_data['reason'] is_last_team = request_data['is_last_team'] assignee = self.env['res.users'].browse(request_data['assignee_id']) # Create a more detailed activity note = _("Player Removal Request\n") note += _("====================\n\n") note += _("Player: %s\n") % player.display_name note += _("Team: %s\n") % team.name note += _("Requested by: %s\n\n") % requested_by.name if reason: note += _("Reason for removal request:\n%s\n\n") % reason if is_last_team: note += _("⚠️ WARNING: This is the player's only team. They will be archived if removed.\n\n") note += _("Please review this request and take appropriate action.") # Create activity for head therapist activity_vals = { 'activity_type_id': self.env.ref('mail.mail_activity_data_todo').id, 'note': note, 'res_id': player.id, 'res_model_id': self.env['ir.model']._get('sports.patient').id, 'user_id': assignee.id, 'summary': _('Player Removal Request: %s') % player.display_name, } # Create the activity self.env['mail.activity'].create(activity_vals) @api.model def _cron_handle_pending_removals(self): """ Scheduled action to handle players pending removal. Creates mail activities for head therapists to review the removal requests. """ # Find all active players with pending removal that still have teams players_pending_removal = self.search([ ('active', '=', True), ('pending_removal', '=', True), ('team_ids', '!=', False) ]) if not players_pending_removal: return # Get the mail activity type activity_type = self.env.ref('mail.mail_activity_data_todo') model_id = self.env['ir.model']._get('sports.patient').id today = fields.Date.today() for player in players_pending_removal: # Skip if there's already an activity for this player existing_activity = self.env['mail.activity'].search([ ('res_model', '=', 'sports.patient'), ('res_id', '=', player.id), ('activity_type_id', '=', activity_type.id), ('summary', 'ilike', 'Player Removal Request') ], limit=1) if existing_activity: continue # Find head therapist or fallback to any therapist head_therapist = player.team_ids[0].staff_ids.filtered( lambda s: s.role == 'therapist' and s.is_head_therapist ) if not head_therapist and len(player.team_ids[0].staff_ids) > 0: # Fallback to any therapist head_therapist = player.team_ids[0].staff_ids.filtered( lambda s: s.role == 'therapist' ) user_id = head_therapist.user_ids[0].id if head_therapist and head_therapist.user_ids else SUPERUSER_ID # Create the activity self.env['mail.activity'].create({ 'activity_type_id': activity_type.id, 'summary': _('Player Removal Request'), 'note': _('Player %s has been requested for removal from the team. Please review.') % player.display_name, 'user_id': user_id, 'res_id': player.id, 'res_model_id': model_id, 'date_deadline': today, }) @api.model def _cron_archive_players_without_teams(self): """ Scheduled action to archive players who have no teams. This is a separate process from the removal request workflow. """ # Find all active players with no teams players_to_archive = self.search([ ('active', '=', True), ('team_ids', '=', False) ]) if players_to_archive: players_to_archive.write({'active': False}) _logger.info('Archived %s players with no teams', len(players_to_archive)) def _archive_if_no_teams(self, team_name, user_name): """ Check if the patient has no teams left and should be archived. :param str team_name: Name of the team the patient was removed from :param str user_name: Name of the user performing the action :return: tuple: (should_archive, message) """ if not self.team_ids: return True, _("Removed from last team %s. Player will be archived shortly.") % team_name return False, _("Removed from team %s by %s") % (team_name, user_name) def remove_from_team(self, team_id, clear_pending=True, reason=None): """ Remove the player from the specified team with proper permission checks and logging. Permissions: - System Administrators (base.group_system) can remove any player - Treatment Professionals (group_sports_clinic_treatment_professional) can remove players from teams where they are therapists :param int team_id: ID of the team to remove the player from :param bool clear_pending: Whether to clear the pending_removal flag (default: True) :param str reason: Optional reason for removal (for audit purposes) :return: dict: Action result with success notification :raises ValidationError: If team is not found or player is not a member :raises AccessError: If user doesn't have permission to remove the player """ self.ensure_one() team = self.env['sports.team'].browse(team_id) # Get current user and check permissions first current_user = self.env.user is_admin = current_user.has_group('base.group_system') # Check if user is a treatment professional on the team user_staff_roles = team.staff_ids.filtered( lambda s: s.user_ids and current_user.id in s.user_ids.ids ) is_team_therapist = any(role.role == 'therapist' for role in user_staff_roles) is_treatment_prof = current_user.has_group('bemade_sports_clinic.group_sports_clinic_treatment_professional') # Permission check - do this before team membership validation if not is_admin: if not is_treatment_prof: raise AccessError(_( "You don't have permission to remove players from teams. " "Only treatment professionals or administrators can perform this action. " "Please use the 'Request Removal' action instead." )) if not is_team_therapist: raise AccessError(_( "You must be a therapist on the team to remove players. " "Please request removal through the team's head therapist." )) # Now validate team existence and membership if not team.exists(): raise ValidationError(_("Team not found or you don't have access to it")) if team not in self.team_ids: raise ValidationError(_("Player is not a member of the specified team")) # Log the action with details log_message = _( "Player %(player)s removed from team %(team)s by %(user)s" ) % { 'player': self.sudo().display_name, 'team': team.sudo().name, 'user': current_user.sudo().name } if reason: log_message += _("\nReason: %s") % reason # Prepare update values update_vals = {'team_ids': [(3, team.id)]} if clear_pending and self.sudo().pending_removal: update_vals['pending_removal'] = False log_message += _("\nPending removal flag was cleared.") # Execute the removal self.write(update_vals) # Check if this was the last team should_archive, archive_message = self._archive_if_no_teams(team.name, current_user.name) if should_archive: log_message += "\n" + archive_message # The archiving cron job will handle this success_message = _('Player successfully removed from team. They will be archived shortly.') else: # Only set pending_removal if clear_pending is False and not already set if not clear_pending and not self.pending_removal: self.write({'pending_removal': True}) log_message += _("\nPending removal flag was set for the removal request workflow.") success_message = _('Player successfully removed from team. A removal request has been created.') else: success_message = _('Player successfully removed from team.') # Log the action in the chatter self.message_post( body=log_message, message_type="comment", subtype_xmlid="mail.mt_comment", ) # Return success notification return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': _('Player Removed'), 'message': success_message, 'type': 'success', 'sticky': True, } } def recompute_followers(self): """Recompute the followers for this patient (and its injuries) based on the changes to a specific team's staff members. Ignoring manually unsubscribed followers, the set of followers should be the set of staff on all teams the patient is part of.""" for patient in self: patient = patient.sudo() current_followers = patient.message_partner_ids future_followers = patient.team_ids.mapped("staff_ids").mapped("partner_id") removed_followers = current_followers - future_followers if removed_followers: _logger.debug(f"{self} unsubscribing {removed_followers}") patient.message_unsubscribe(removed_followers.ids) patient.injury_ids.message_unsubscribe(removed_followers.ids) if future_followers: _logger.debug(f"{self} subscribing {future_followers}") patient.message_subscribe(future_followers.ids) patient.injury_ids.message_subscribe(future_followers.ids)