Created portal views for therapists and Report Injury functionality for portal users (coaches and therapists).

This commit is contained in:
Denis Durepos 2025-06-21 21:24:50 -04:00
parent b4eea01a20
commit c60ffd5bfa
18 changed files with 262 additions and 58 deletions

View file

@ -32,6 +32,11 @@
External users (portal users) can be added to give coaches and other team
personnel access to limited player data such as estimated return-to-play dates.
Medical professionals (primarily field therapists) can also access the system
via the portal interface instead of requiring full internal user accounts, allowing
for more flexible collaboration while maintaining appropriate access controls.
Coaches and treatment professionals can signal player injuries directly through the
portal interface, streamlining communication and injury tracking.
""",
"category": "Services/Medical",
"author": "Bemade Inc.",
@ -45,14 +50,18 @@
},
"data": [
"security/sports_clinic_groups.xml",
"security/sports_clinic_portal_groups.xml",
"security/ir.model.access.csv",
"security/sports_clinic_rules.xml",
"security/sports_clinic_portal_rules.xml",
"data/sports_clinic_data.xml",
"data/admin_access_data.xml",
"views/sports_team_views.xml",
"views/sports_clinic_menus.xml",
"views/sports_patient_injury_views.xml",
"views/sports_patient_views.xml",
"views/sports_clinic_portal_views.xml",
"views/sports_patient_injury_portal.xml",
"views/res_partner_views.xml",
"views/res_users_views.xml",
],

View file

@ -1 +1,2 @@
from . import team_staff_portal
from . import patient_injury_portal

View file

@ -165,7 +165,10 @@
<field name="partner_id" ref="partner_therapist_team_carabins"/>
<field name="login">therapist</field>
<field name="password">therapist</field>
<field name="groups_id" eval="[Command.clear()]"/>
<field name="groups_id" eval="[(5, 0, 0),
(4, ref('base.group_portal'))]"/>
<!-- Note: Portal users should not be directly assigned to treatment_professional group -->
<!-- The is_treatment_professional flag will be set based on staff role instead -->
</record>
<record id="carabins_coach_user" model="res.users" context="{'no_reset_password': True}">
<field name="partner_id" ref="partner_coach_team_carabins"/>
@ -173,12 +176,7 @@
<field name="password">coach</field>
<field name="groups_id" eval="[Command.clear()]"/>
</record>
<record id="base.group_portal" model="res.groups">
<field name="users" eval="[Command.link(ref('carabins_therapist_user'))]"/>
</record>
<record id="base.group_portal" model="res.groups">
<field name="users" eval="[Command.link(ref('carabins_coach_user'))]"/>
</record>
<!-- Users should be either internal users or portal users, not both -->
<record id="group_sports_clinic_admin" model="res.groups">
<field name="users" eval="[Command.link(ref('base.user_admin'))]"/>
</record>

View file

@ -287,7 +287,7 @@ msgid "Date of Injury"
msgstr "Date de la blessure"
#. module: bemade_sports_clinic
#: model_terms:ir.ui.view,arch_db:bemade_sports_clinic.sports_patient_injury_view_tree
#: model_terms:ir.ui.view,arch_db:bemade_sports_clinic.sports_patient_injury_view_list
#: model_terms:ir.ui.view,arch_db:bemade_sports_clinic.sports_patient_view_list_embedded
msgid "Details"
msgstr "Détails"
@ -798,7 +798,7 @@ msgid "Patient Injury"
msgstr "La blessure d'un patient."
#. module: bemade_sports_clinic
#: model_terms:ir.ui.view,arch_db:bemade_sports_clinic.sports_patient_injury_view_tree
#: model_terms:ir.ui.view,arch_db:bemade_sports_clinic.sports_patient_injury_view_list
msgid "Patient Injury History"
msgstr "Historique des blessures du patient"

View file

@ -253,6 +253,40 @@ class Patient(models.Model):
"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):

View file

@ -5,7 +5,12 @@ from odoo.addons.phone_validation.tools import phone_validation
class PatientContact(models.Model):
_name = 'sports.patient.contact'
_description = "Emergency or other contacts for a patient."
@api.model
def _valid_field_parameter(self, field, name):
# Allow 'unaccent' parameter for fields
return name == 'unaccent' or super()._valid_field_parameter(field, name)
sequence = fields.Integer(required=True, default=0)
name = fields.Char(unaccent=True)
contact_type = fields.Selection(selection=[

View file

@ -42,6 +42,11 @@ class PatientInjury(models.Model):
ondelete="cascade",
)
patient_name = fields.Char(related="patient_id.name")
team_id = fields.Many2one(
comodel_name="sports.team",
string="Team",
help="The team for which this injury was reported, especially important when a player belongs to multiple teams.",
)
diagnosis = fields.Char(tracking=True)
injury_date = fields.Date(
@ -171,5 +176,33 @@ class PatientInjury(models.Model):
@api.model_create_multi
def create(self, vals_list):
res = super().create(vals_list)
res.patient_id.recompute_followers()
for record in res:
# Automatically assign therapist when creating an injury
current_user = self.env.user
# If the injury creator is a treatment professional, assign them
if current_user.has_group('bemade_sports_clinic.group_sports_clinic_treatment_professional'):
record.treatment_professional_ids = [(4, current_user.id)]
# Otherwise, if there's a team_id, find and assign team therapists
elif record.team_id:
# Find all team staff users
team_staff = self.env['sports.team.staff'].search([
('team_id', '=', record.team_id.id)
])
# Filter to only staff users who are treatment professionals
if team_staff:
treatment_professional_group = self.env.ref('bemade_sports_clinic.group_sports_clinic_treatment_professional')
# Get all users from staff and filter them by group
staff_users = team_staff.mapped('user_ids')
therapist_users = staff_users.filtered(
lambda user: user.has_group('bemade_sports_clinic.group_sports_clinic_treatment_professional')
)
if therapist_users:
record.treatment_professional_ids = [(6, 0, therapist_users.ids)]
record.patient_id.recompute_followers()
return res

View file

@ -14,12 +14,25 @@ class User(models.Model):
inverse="_inverse_accessible_team_ids",
)
@api.depends("groups_id")
@api.depends("groups_id", "partner_id", "partner_id.staff_ids", "partner_id.staff_ids.role")
def _compute_is_treatment_professional(self):
for rec in self:
rec.is_treatment_professional = rec.has_group(
# Check if user has the security group
has_security_group = rec.has_group(
"bemade_sports_clinic.group_sports_clinic_treatment_professional"
)
# Check if user is linked to any team staff as head therapist or therapist
is_therapist_staff = False
if rec.partner_id:
staff_records = self.env['sports.team.staff'].sudo().search([
('partner_id', '=', rec.partner_id.id),
('role', 'in', ['head_therapist', 'therapist'])
])
is_therapist_staff = bool(staff_records)
# Set field based on either condition
rec.is_treatment_professional = has_security_group or is_therapist_staff
def _compute_accessible_team_ids(self):
for rec in self:

View file

@ -193,26 +193,33 @@ class TeamStaff(models.Model):
@api.depends("user_ids", "user_ids.groups_id")
def _compute_has_portal_access(self):
for rec in self:
# Check if the partner has any active users with portal or internal access
rec.has_portal_access = (
bool(rec.user_ids.filtered(lambda r: r.has_group("base.group_portal")))
or bool(rec.user_ids.filtered(lambda r: r.has_group("base.group_user")))
or bool(rec.partner_id.signup_token)
)
def action_revoke_portal_access(self):
group_portal = self.env.ref("base.group_portal")
group_public = self.env.ref("base.group_public")
self.user_ids.write(
{
"groups_id": [
Command.unlink(group_portal.id),
Command.link(group_public.id),
],
"active": False,
}
)
# Remove the signup token, so it cannot be used
self.partner_id.sudo().signup_token = False
# Deactivate the user and remove from portal group
if self.user_ids:
self.user_ids.write(
{
"groups_id": [
Command.unlink(group_portal.id),
Command.link(group_public.id),
],
"active": False,
}
)
# If there's an active signup invitation, cancel it
# This uses the portal.wizard from Odoo core to handle all the details
if self.partner_id and self.has_portal_access:
self.env['res.partner'].sudo().invalidate_model(['signup_valid'])
users = self.env['res.users'].sudo().search([('partner_id', '=', self.partner_id.id)])
users.write({'active': False})
def action_grant_portal_access(self):
wiz = self.env["portal.wizard"].create(
@ -223,22 +230,110 @@ class TeamStaff(models.Model):
@api.model_create_multi
def create(self, vals_list):
res = super().create(vals_list)
# Update treatment professional group membership for new records
res._update_treatment_professional_group()
# Recompute the is_treatment_professional field on affected users
affected_users = res.mapped('user_ids')
if affected_users:
affected_users.sudo()._compute_is_treatment_professional()
# Handle follower recomputation
res.team_id.mapped("patient_ids").recompute_followers()
return res
def unlink(self):
# Store affected partners and users before deletion
affected_partners = self.mapped('partner_id')
affected_users = self.mapped('user_ids')
# Store roles - only therapist roles matter for the update
had_therapist_role = self.filtered(lambda s: s.role in ['head_therapist', 'therapist'])
therapist_partners = had_therapist_role.mapped('partner_id')
# Standard processing for follower recomputation
patients = self.team_id.mapped("patient_ids")
res = super().unlink()
patients.recompute_followers()
# After deletion, check if therapist partners still have any therapist roles
# and update their group membership accordingly
for partner in therapist_partners:
remaining_therapist_roles = self.env['sports.team.staff'].sudo().search_count([
('partner_id', '=', partner.id),
('role', 'in', ['head_therapist', 'therapist']),
])
if not remaining_therapist_roles:
# No therapist roles left, remove from treatment professional group
users = self.env['res.users'].sudo().search([('partner_id', '=', partner.id)])
treatment_prof_group = self.env.ref('bemade_sports_clinic.group_sports_clinic_treatment_professional')
for user in users:
if user.has_group('bemade_sports_clinic.group_sports_clinic_treatment_professional'):
user.sudo().write({'groups_id': [(3, treatment_prof_group.id)]})
# Recompute is_treatment_professional on all affected users
affected_users.sudo().invalidate_model(['is_treatment_professional'])
return res
def _update_treatment_professional_group(self):
"""Update treatment professional status based on staff role
For internal users, this adds them to the treatment professional group.
For portal users, we set the flag via recomputation.
"""
treatment_prof_group = self.env.ref('bemade_sports_clinic.group_sports_clinic_treatment_professional')
for staff in self:
# Skip if partner has no user accounts
if not staff.user_ids:
continue
# Check if this partner has any staff records with therapist roles
all_staff_records = self.env['sports.team.staff'].sudo().search([
('partner_id', '=', staff.partner_id.id),
('role', 'in', ['head_therapist', 'therapist'])
])
should_be_treatment_professional = bool(all_staff_records)
# Update all users linked to this partner
for user in staff.user_ids:
# Skip changes during module installation to avoid conflicts in demo data
if self.env.context.get('module'):
continue
# Always trigger a recomputation of is_treatment_professional
user.sudo().invalidate_model(['is_treatment_professional'])
# For internal users, we can directly manage group membership
if user.has_group('base.group_user'):
if should_be_treatment_professional and not user.has_group('bemade_sports_clinic.group_sports_clinic_treatment_professional'):
user.sudo().write({'groups_id': [(4, treatment_prof_group.id)]}) # Add to group
elif not should_be_treatment_professional and user.has_group('bemade_sports_clinic.group_sports_clinic_treatment_professional'):
user.sudo().write({'groups_id': [(3, treatment_prof_group.id)]}) # Remove from group
def write(self, values):
old_roles = {record.id: record.role for record in self}
result = super().write(values)
# If role changed or team changed, handle group membership updates
if 'role' in values or 'team_id' in values:
self._update_treatment_professional_group()
# Recompute the `is_treatment_professional` field on affected users
affected_users = self.mapped('user_ids')
if affected_users:
affected_users.sudo()._compute_is_treatment_professional()
# Handle team changes for follower recomputation
if "team_id" in values:
to_recompute = self.env["sports.patient"]
for rec in self:
if rec.team_id.id != values["team_id"]:
to_recompute |= rec.team_id.patient_ids
res = super().write(values)
to_recompute.recompute_followers()
return res
return super().write(values)
return result

View file

@ -11,3 +11,4 @@ access_team_admin,Admin Access for Teams,model_sports_team,group_sports_clinic_a
access_team_portal,Portal Access for Teams,model_sports_team,base.group_portal,1,0,0,0
access_team_staff_user,User Access for Team Staff,model_sports_team_staff,group_sports_clinic_user,1,1,1,1
access_team_staff_portal,Portal Access for Team Staff,model_sports_team_staff,base.group_portal,1,0,0,0
access_team_staff_admin,Admin Access for Team Staff,model_sports_team_staff,group_sports_clinic_admin,1,1,1,1

1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
11 access_team_portal Portal Access for Teams model_sports_team base.group_portal 1 0 0 0
12 access_team_staff_user User Access for Team Staff model_sports_team_staff group_sports_clinic_user 1 1 1 1
13 access_team_staff_portal Portal Access for Team Staff model_sports_team_staff base.group_portal 1 0 0 0
14 access_team_staff_admin Admin Access for Team Staff model_sports_team_staff group_sports_clinic_admin 1 1 1 1

View file

@ -19,13 +19,14 @@
<record id="group_sports_clinic_treatment_professional" model="res.groups">
<field name="name">Treatment Professional</field>
<field name="category_id" ref="module_category_sports_clinic_management"/>
<field name="implied_ids" eval="[Command.link(ref('group_sports_clinic_user'))]"/>
<!-- No implied_ids to allow both internal and portal treatment professionals -->
</record>
<record id="group_sports_clinic_admin" model="res.groups">
<field name="name">Administrator</field>
<field name="category_id" ref="module_category_sports_clinic_management"/>
<field name="implied_ids"
eval="[Command.link(ref('group_sports_clinic_treatment_professional'))]"/>
eval="[Command.link(ref('group_sports_clinic_user')),
Command.link(ref('group_sports_clinic_treatment_professional'))]"/>
</record>
</data>
</odoo>

View file

@ -1,3 +1,5 @@
from . import test_patient
from . import test_rights
from . import test_users
from . import test_portal_access
from . import test_treatment_professional_consistency

View file

@ -1,14 +1,29 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<!-- Add Sports Clinic security groups to the user form access rights section -->
<record id="view_users_form_inherit" model="ir.ui.view">
<field name="name">view.users.form.inherit</field>
<field name="inherit_id" ref="base.view_users_form"/>
<field name="model">res.users</field>
<field name="arch" type="xml">
<!-- Add the existing page for team access -->
<page name="preferences" position="before">
<page name="Sports Team Access"
<page name="sports_team_access" string="Sports Team Access"
groups="base.group_system,bemade_sports_clinic.group_sports_clinic_admin">
<field name="accessible_team_ids" widget="many2many_checkboxes"/>
<group>
<field name="accessible_team_ids" widget="many2many_checkboxes"/>
</group>
</page>
</page>
<!-- Add a dedicated page for Sports Clinic permissions -->
<page name="access_rights" position="after">
<page name="sports_clinic_access" string="Sports Clinic Access Rights" groups="base.group_system">
<group>
<field name="groups_id" widget="many2many_checkboxes"
domain="[('category_id','=',ref('bemade_sports_clinic.module_category_sports_clinic_management'))]"
readonly="0" nolabel="1"/>
</group>
</page>
</page>
</field>

View file

@ -28,7 +28,7 @@
<record id="action_view_organization" model="ir.actions.act_window">
<field name="name">Organizations</field>
<field name="res_model">res.partner</field>
<field name="view_mode">tree,kanban,form</field>
<field name="view_mode">list,kanban,form</field>
<field name="domain">[('is_company', '=', True)]</field>
</record>
<record id="action_view_patient" model="ir.actions.act_window">

View file

@ -176,7 +176,7 @@
<span t-field="injury.diagnosis" class="text-wrap"/>
</td>
<td>
<span t-raw="injury.external_notes" class="text-wrap"/>
<span t-out="injury.external_notes" class="text-wrap"/>
</td>
</tr>
</t>

View file

@ -18,6 +18,7 @@
<group>
<group col="2">
<field name="patient_id" invisible="1"/>
<field name="team_id"/>
<span colspan="2">
<label for="injury_date"/>
<field name="injury_date" widget="date" class="oe_inline"/>
@ -37,22 +38,19 @@
</group>
</group>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids" widget="mail_followers"/>
<field name="activity_ids" widget="mail_activity"/>
<field name="message_ids" widget="mail_thread"/>
</div>
<chatter reload_on_post="True"/>
</form>
</field>
</record>
<record id="sports_patient_injury_view_tree" model="ir.ui.view">
<field name="name">sports.patient.injury.view.tree</field>
<record id="sports_patient_injury_view_list" model="ir.ui.view">
<field name="name">sports.patient.injury.view.list</field>
<field name="model">sports.patient.injury</field>
<field name="arch" type="xml">
<list string="Patient Injury History" editable="bottom"
multi_edit="True">
<field name="patient_id" invisible="1"/>
<field name="patient_id" column_invisible="True"/>
<field name="injury_date" widget="date"/>
<field name="team_id"/>
<field name="diagnosis"/>
<field name="predicted_resolution_date" widget="date"/>
<field name="resolution_date" widget="date"/>

View file

@ -79,7 +79,7 @@
<field name="return_date" widget="date"/>
<field name="activity_ids" widget="list_activity"/>
<templates>
<t t-name="kanban-box">
<t t-name="card">
<t t-set="stage" t-value="record.stage.raw_value"/>
<div t-attf-class="oe_kanban_global_click
{{stage === 'no_play' ? 'text-danger' : stage === 'practice_ok' ? 'text-warning' : ''}}">
@ -114,7 +114,14 @@
<field name="stage" widget="statusbar" />
</header>
<sheet>
<div class="oe_button_box"></div>
<div class="oe_button_box" name="button_box">
<button name="action_report_injury" type="object" class="oe_stat_button" icon="fa-medkit">
<div class="o_stat_info">
<span class="o_stat_text">Report</span>
<span class="o_stat_text">Injury</span>
</div>
</button>
</div>
<div class="oe_title"><h1>Patient Record</h1></div>
<group>
<group col="3">
@ -176,11 +183,7 @@
</notebook>
</group>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids" widget="mail_followers"/>
<field name="activity_ids" widget="mail_activity"/>
<field name="message_ids" widget="mail_thread"/>
</div>
<chatter reload_on_post="True"/>
</form>
</field>
</record>

View file

@ -26,7 +26,7 @@
<field name="injured_count"/>
<field name="healthy_count"/>
<templates>
<t t-name="kanban-box">
<t t-name="card">
<div t-attf-class="oe_kanban_content oe_kanban_global_click">
<div>
<strong class="o_kanban_record_title">
@ -110,8 +110,8 @@
<field name="name"/>
<field name="role"/>
<field name="mobile" widget="phone"/>
<field invisible="1" name="has_portal_access"/>
<field invisible="1" name="partner_id"/>
<field column_invisible="True" name="has_portal_access"/>
<field column_invisible="True" name="partner_id"/>
<button class="oe_highlight" groups="bemade_sports_clinic.group_sports_clinic_admin,base.group_system" icon="fa-user-plus" invisible="has_portal_access == True" name="action_grant_portal_access" title="Grant Portal Access" type="object"/>
<button class="oe_highlight" groups="bemade_sports_clinic.group_sports_clinic_admin,base.group_system" icon="fa-ban" invisible="has_portal_access == False" name="action_revoke_portal_access" title="Revoke Portal Access" type="object"/>
</list></field>
@ -120,7 +120,7 @@
<notebook>
<page name="players" string="Players">
<field name="patient_ids" nolabel="1"
context="{'tree_view_ref': 'bemade_sports_clinic.sports_patient_view_list_embedded'}"/>
context="{'list_view_ref': 'bemade_sports_clinic.sports_patient_view_list_embedded'}"/>
</page>
<page name="user_access"
string="User Access"
@ -129,11 +129,7 @@
</page>
</notebook>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids" widget="mail_followers"/>
<field name="activity_ids" widget="mail_activity"/>
<field name="message_ids" widget="mail_thread"/>
</div>
<chatter reload_on_post="True"/>
</form>
</field>
</record>