- Fix ACL test assumptions about browse() behavior * Update tests to check field-level access instead of browse().exists() * Correct test methodology for AccessError validation - Implement buddy method pattern for RPC security * Refactor all sudo()/api.model methods to use public/private pattern * Public methods perform access checks, private methods contain privileged ops * Prevents RPC privilege escalation vulnerabilities - Enhanced security architecture * Add comprehensive mail activity portal access rules * Strengthen partner access controls * Update controller method references to use secure public methods - Test suite improvements * Add extensive mail activity portal access tests * Update player removal tests to use public method interfaces * Improve test coverage for security scenarios - Security best practices enforcement * All privileged operations now encapsulated in private methods * Clear separation between public API and internal operations * Maintains functionality while securing RPC access Resolves RPC security vulnerabilities and establishes proper ACL enforcement patterns.
816 lines
32 KiB
Python
816 lines
32 KiB
Python
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,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")
|
|
allergies = fields.Text(
|
|
groups="bemade_sports_clinic.group_sports_clinic_treatment_professional,bemade_sports_clinic.group_portal_treatment_professional",
|
|
)
|
|
team_info_notes = fields.Html(
|
|
string="Notes",
|
|
# Removed tracking=True as HTML fields are not supported by mail tracking system
|
|
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")["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):
|
|
"""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
|
|
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,
|
|
}
|
|
}
|
|
|
|
@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'].with_context(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 normal permissions
|
|
return self.create(patient_vals)
|
|
|
|
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)
|