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) # Migration tracking field odoo16_patient_id = fields.Integer( string='Odoo 16 Patient ID', help='Original patient ID from Odoo 16 database for migration tracking', index=True ) # Patient fields date_of_birth = fields.Date( groups="bemade_sports_clinic.group_sports_clinic_treatment_professional,bemade_sports_clinic.group_portal_treatment_professional", tracking=True, ) age = fields.Integer( compute="_compute_age", groups="bemade_sports_clinic.group_sports_clinic_treatment_professional,bemade_sports_clinic.group_portal_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,bemade_sports_clinic.group_portal_treatment_professional", ) 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") activity_count = fields.Integer(compute="_compute_activity_count") # Documents linked to this patient (optionally to an injury) document_ids = fields.One2many( comodel_name="sports.injury.document", inverse_name="patient_id", string="Documents", ) document_count = fields.Integer( compute="_compute_document_count", string="Document Count", ) allergies = fields.Text( groups="bemade_sports_clinic.group_sports_clinic_treatment_professional,bemade_sports_clinic.group_portal_treatment_professional", ) team_info_notes = fields.Text( string="Notes", tracking=True, groups="bemade_sports_clinic.group_sports_clinic_treatment_professional,bemade_sports_clinic.group_portal_treatment_professional", ) 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", {}).get("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"].with_context( tracking_disable=True, mail_create_nosubscribe=True, ) .create( { "name": self._get_name_from_first_and_last( row["first_name"], row["last_name"] ) } ) .id ) res = super().create(vals_list) # Avoid triggering follower recomputation (which can create mail/follower # side-effects) when explicitly asked to skip, e.g., during portal creation. if not self.env.context.get("skip_recompute_followers"): res.sudo().with_context( tracking_disable=True, mail_create_nosubscribe=True, ).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: # Patient is injured if their stage is not "healthy" patient.is_injured = patient.stage != "healthy" # For injured_since, find the earliest injury date from active injuries # This logic is kept but may not be reliable until user habits change if patient.is_injured: active_injuries = self.env["sports.patient.injury"].search( [ ("patient_id", "=", patient.id), ("stage", "=", "active"), ] ) if active_injuries: injury_dates = [d for d in active_injuries.mapped("injury_date") if d] patient.injured_since = min(injury_dates) if injury_dates else False else: patient.injured_since = False else: 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 _compute_activity_count(self): for rec in self: rec.activity_count = self.env['mail.activity'].search_count([ ('res_model', '=', 'sports.patient'), ('res_id', '=', rec.id) ]) def _compute_document_count(self): for patient in self: patient.document_count = self.env['sports.injury.document'].search_count([ ('patient_id', '=', patient.id) ]) def action_view_documents(self): self.ensure_one() return { 'type': 'ir.actions.act_window', 'name': _('Documents'), 'res_model': 'sports.injury.document', 'view_mode': 'list,form', 'domain': [('patient_id', '=', self.id)], 'context': { 'default_patient_id': self.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): """Public method to report injury with proper access checks.""" self.ensure_one() # Check permissions - user must have access to this patient user = self.env.user if user.has_group('base.group_portal'): # Portal users must be staff on at least one of the patient's teams user_teams = user.partner_id.team_staff_rel_ids.mapped('team_id') patient_teams = self.team_ids if not (user_teams & patient_teams): raise AccessError(_("You don't have permission to report injuries for this patient")) # Backend users with appropriate groups can access any patient elif not (user.has_group('bemade_sports_clinic.group_sports_clinic_treatment_professional') or user.has_group('bemade_sports_clinic.group_sports_clinic_admin') or user.has_group('base.group_system')): raise AccessError(_("You don't have permission to report injuries")) # Call the private implementation return self._action_report_injury() def _action_report_injury(self): """ Private method containing the actual sudo operations for injury reporting. :return: dict: Action result with success notification """ self.ensure_one() 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", }, ) # Tracking removed from team_info_notes HTML field as it's not supported by the mail tracking system # 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): """Public method to request team removal with proper access checks.""" self.ensure_one() team = self.env['sports.team'].browse(team_id) # Get current user and check permissions current_user = self.env.user is_admin = current_user.has_group('base.group_system') # Permission check - do this before team membership validation if not is_admin: # Check if user is staff on the team user_staff_roles = team.staff_ids.filtered( lambda s: s.user_ids and current_user.id in s.user_ids.ids ) if not user_staff_roles: raise AccessError(_( "You don't have permission to request removal for this team. " "Only team staff or administrators can request player removal." )) # 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")) # Call the private implementation return self._request_team_removal(team_id, reason) def _request_team_removal(self, team_id, reason=None): """ Private method containing the actual sudo operations for requesting team removal. The actual activity creation will be handled by the scheduled action. :param int team_id: ID of the team to request removal from :param str reason: Optional reason for the removal request :return: dict: Action result with success notification """ self.ensure_one() team = self.env['sports.team'].browse(team_id) current_user = self.env.user # Set the pending_removal flag self.write({'pending_removal': True}) # Log the request with details log_message = _( "Removal request submitted for player %(player)s from team %(team)s by %(user)s" ) % { 'player': self.display_name, 'team': team.name, 'user': current_user.name } if reason: log_message += _("\nReason: %s") % reason # Log the request in the chatter self.sudo().message_post(body=log_message) # Return success notification return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': _('Removal Request Submitted'), 'message': _('Your removal request has been submitted and will be processed by an administrator.'), '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): """ Public method to remove player from team with proper permission checks. 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")) # Call the private implementation return self._remove_from_team(team_id, clear_pending, reason) def _remove_from_team(self, team_id, clear_pending=True, reason=None): """ Private method containing the actual sudo operations for team removal. :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 """ self.ensure_one() team = self.env['sports.team'].browse(team_id) current_user = self.env.user # 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 # Use sudo() to avoid mail system access limitations for portal users self.sudo().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, } } @api.model def create_portal_patient(self, vals): """Public method to create a patient from portal with proper permission checks. :param dict vals: Values for patient creation including: - first_name (required) - last_name (required) - email (optional) - phone (optional) - team_ids (optional) - date_of_birth (optional) :return: Created patient record """ # Validate required fields if not vals.get('first_name') or not vals.get('last_name'): raise ValidationError(_("First name and last name are required")) # Check permissions - must be portal treatment professional or team coach user = self.env.user if not (user.has_group('bemade_sports_clinic.group_portal_treatment_professional') or user.has_group('bemade_sports_clinic.group_portal_team_coach')): raise AccessError(_("You don't have permission to create patients")) # Call the private implementation return self._create_portal_patient(vals) @api.model def _create_portal_patient(self, vals): """Private method containing the actual @api.model operations for patient creation. This method is designed to be called from portal controllers where portal users need to create patients but might not have direct create permissions on res.partner. :param dict vals: Values for patient creation :return: Created patient record """ # Create partner first partner_vals = { 'name': f"{vals['first_name']} {vals['last_name']}", 'email': vals.get('email', False), 'phone': vals.get('phone', False), 'type': 'contact', } partner = ( self.env['res.partner'] .sudo() .with_context(tracking_disable=True, mail_create_nosubscribe=True) .create(partner_vals) ) # Prepare patient values patient_vals = { 'partner_id': partner.id, 'first_name': vals['first_name'], 'last_name': vals['last_name'], } # Optional fields if 'team_ids' in vals: patient_vals['team_ids'] = vals['team_ids'] if 'date_of_birth' in vals and vals['date_of_birth']: patient_vals['date_of_birth'] = vals['date_of_birth'] # Create patient with tracking disabled to avoid triggering mail/report side-effects # Also disable auto-subscriptions on creation patient = self.sudo().with_context( tracking_disable=True, mail_create_nosubscribe=True, skip_recompute_followers=True, ).create(patient_vals) # Optionally recompute followers immediately if explicitly requested by context. # Disabled by default for portal flows to avoid mail/report side-effects (e.g., ir.actions.report ACL reads). if self.env.context.get('portal_recompute_followers_post_create'): patient.sudo().with_context( tracking_disable=True, mail_create_nosubscribe=True, ).recompute_followers() return patient 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)