bemade_sports_clinic: enforce DOB range validation and improve form errors

- Add DOB validation (not in future, not >120y) in controllers:\n  - controllers/team_management_portal.py: portal_create_player_full, portal_add_player_submit\n  - controllers/player_management_portal.py: create_player_submit, edit_player_submit\n- Preserve form inputs and display clear error messages on invalid DOB\n- Add dateutil.relativedelta import for date range checks\n- Minor template/context tweaks for consistent error rendering
This commit is contained in:
Denis Durepos 2025-09-01 09:48:47 -04:00
parent 6872e5fac2
commit a47e829935
5 changed files with 1335 additions and 297 deletions

View file

@ -4,6 +4,7 @@ from odoo.exceptions import UserError, ValidationError
from odoo.http import request
from odoo.addons.portal.controllers.portal import CustomerPortal, pager
from .access_control_mixin import AccessControlMixin
from dateutil.relativedelta import relativedelta
_logger = logging.getLogger(__name__)
@ -13,6 +14,343 @@ class PlayerManagementPortal(CustomerPortal, AccessControlMixin):
# Access control methods now inherited from AccessControlMixin
@http.route(['/my/player/create'], type='http', auth='user', website=True)
def create_player_form(self, **get):
"""Standalone create player with search-first workflow.
Shows a search UI first; only renders the full create form when a search
is attempted and yields no matching players.
"""
user = request.env.user
is_treatment_prof = request.env.user.has_group('bemade_sports_clinic.group_portal_treatment_professional')
# Gather query params for search-first flow
first_name = (get.get('first_name') or '').strip()
last_name = (get.get('last_name') or '').strip()
dob = (get.get('date_of_birth') or '').strip()
attempted = any(k in get for k in ('first_name', 'last_name', 'date_of_birth'))
searched = bool(first_name or last_name or dob)
# Perform search similar to team add/link, but without team context
Patient = request.env['sports.patient']
active_rs = Patient.browse([])
archived_rs = Patient.browse([])
if searched and (first_name or last_name):
like_first = f"%{first_name}%" if first_name else "%"
like_last = f"%{last_name}%" if last_name else "%"
domain = [
('first_name', 'ilike', like_first),
('last_name', 'ilike', like_last),
]
if dob:
domain.append(('date_of_birth', '=', dob))
active_rs = Patient.search(domain + [('active', '=', True)], limit=20)
# Only TPs/admins can see archived
if is_treatment_prof or request.env.user.has_group('base.group_system'):
archived_rs = Patient.with_context(active_test=False).search(domain + [('active', '=', False)], limit=20)
def _to_dict(p):
return {
'id': p.id,
'name': p.name,
'first_name': p.first_name,
'last_name': p.last_name,
'date_of_birth': p.date_of_birth or '',
'active': bool(p.active),
}
# Staff-only teams for multi-select
all_teams = request.env['sports.team'].search([
('id', 'in', request.env['sports.team.staff'].search([
('partner_id', '=', user.partner_id.id)
]).mapped('team_id').ids)
], order='name')
canada = request.env['res.country'].search([('code', '=', 'CA')], limit=1)
states = request.env['res.country.state'].search([('country_id', '=', canada.id)], order='name') if canada else request.env['res.country.state']
countries = canada if canada else request.env['res.country'].search([], order='name')
values = self._prepare_portal_layout_values()
values.update({
'page_name': 'create_player',
'is_treatment_prof': is_treatment_prof,
'all_teams': all_teams,
'states': states,
'countries': countries,
'relationship_types': request.env['sports.patient.contact']._fields['contact_type'].selection,
# search-first context
'first_name': first_name,
'last_name': last_name,
'date_of_birth': dob,
'attempted': attempted,
'searched': searched,
'active_results': [_to_dict(p) for p in active_rs],
'archived_results': [_to_dict(p) for p in archived_rs],
})
if attempted and not searched:
values['error'] = _('Provide at least a first or last name to search')
return request.render('bemade_sports_clinic.portal_create_player', values)
@http.route(['/my/player/create/save'], type='http', auth='user', website=True, methods=['POST'])
def create_player_submit(self, **post):
"""Handle standalone player creation submit."""
user = request.env.user
is_treatment_prof = user.has_group('bemade_sports_clinic.group_portal_treatment_professional')
first_name = (post.get('first_name') or '').strip()
last_name = (post.get('last_name') or '').strip()
dob = (post.get('date_of_birth') or '').strip()
if not first_name or not last_name:
request.session['notification'] = {
'type': 'danger',
'message': _('First name and last name are required')
}
return request.redirect('/my/player/create')
# Team selection (optional). Restrict to staff teams to avoid tampering
try:
selected_team_ids = [int(tid) for tid in request.httprequest.form.getlist('team_ids')]
except Exception:
selected_team_ids = []
allowed_team_ids = request.env['sports.team.staff'].search([
('partner_id', '=', user.partner_id.id)
]).mapped('team_id').ids
selected_team_ids = [tid for tid in selected_team_ids if tid in allowed_team_ids]
# Require at least one team for treatment professionals to ensure access control
if is_treatment_prof and not selected_team_ids:
canada = request.env['res.country'].search([('code', '=', 'CA')], limit=1)
states = request.env['res.country.state'].search([('country_id', '=', canada.id)], order='name') if canada else request.env['res.country.state']
countries = canada if canada else request.env['res.country'].search([], order='name')
all_teams = request.env['sports.team'].search([
('id', 'in', request.env['sports.team.staff'].search([
('partner_id', '=', user.partner_id.id)
]).mapped('team_id').ids)
], order='name')
values = self._prepare_portal_layout_values()
values.update({
'page_name': 'create_player',
'is_treatment_prof': is_treatment_prof,
'all_teams': all_teams,
'states': states,
'countries': countries,
'relationship_types': request.env['sports.patient.contact']._fields['contact_type'].selection,
# keep the original search context so the create form shows
'first_name': first_name,
'last_name': last_name,
'date_of_birth': dob,
'attempted': True,
'searched': True,
'active_results': [],
'archived_results': [],
# provide posted values to prefill the form
'form_data': dict(post),
'error': _('Please select at least one team to assign to this player.'),
})
return request.render('bemade_sports_clinic.portal_create_player', values)
# Validate DOB format and range if provided
if dob:
try:
# Will raise if the date is not a valid string for a Date field
dob_date = fields.Date.to_date(dob)
except Exception:
_logger.info('Invalid date_of_birth provided on create: %s', dob)
canada = request.env['res.country'].search([('code', '=', 'CA')], limit=1)
states = request.env['res.country.state'].search([('country_id', '=', canada.id)], order='name') if canada else request.env['res.country.state']
countries = canada if canada else request.env['res.country'].search([], order='name')
all_teams = request.env['sports.team'].search([
('id', 'in', request.env['sports.team.staff'].search([
('partner_id', '=', user.partner_id.id)
]).mapped('team_id').ids)
], order='name')
values = self._prepare_portal_layout_values()
values.update({
'page_name': 'create_player',
'is_treatment_prof': is_treatment_prof,
'all_teams': all_teams,
'states': states,
'countries': countries,
'relationship_types': request.env['sports.patient.contact']._fields['contact_type'].selection,
# keep the original search context so the create form shows
'first_name': first_name,
'last_name': last_name,
'date_of_birth': dob,
'attempted': True,
'searched': True,
'active_results': [],
'archived_results': [],
# provide posted values to prefill the form
'form_data': dict(post),
'error': _('Please enter a valid Date of Birth (YYYY-MM-DD).'),
})
return request.render('bemade_sports_clinic.portal_create_player', values)
# Range checks: not in the future, not older than 120 years
today = fields.Date.context_today(request.env.user) or fields.Date.today()
min_dob = today - relativedelta(years=120)
if dob_date > today or dob_date < min_dob:
canada = request.env['res.country'].search([('code', '=', 'CA')], limit=1)
states = request.env['res.country.state'].search([('country_id', '=', canada.id)], order='name') if canada else request.env['res.country.state']
countries = canada if canada else request.env['res.country'].search([], order='name')
all_teams = request.env['sports.team'].search([
('id', 'in', request.env['sports.team.staff'].search([
('partner_id', '=', user.partner_id.id)
]).mapped('team_id').ids)
], order='name')
values = self._prepare_portal_layout_values()
values.update({
'page_name': 'create_player',
'is_treatment_prof': is_treatment_prof,
'all_teams': all_teams,
'states': states,
'countries': countries,
'relationship_types': request.env['sports.patient.contact']._fields['contact_type'].selection,
# keep the original search context so the create form shows
'first_name': first_name,
'last_name': last_name,
'date_of_birth': dob,
'attempted': True,
'searched': True,
'active_results': [],
'archived_results': [],
# provide posted values to prefill the form
'form_data': dict(post),
'error': _('Date of Birth must not be in the future and not be more than 120 years ago.'),
})
return request.render('bemade_sports_clinic.portal_create_player', values)
vals = {
'first_name': first_name,
'last_name': last_name,
}
if dob:
vals['date_of_birth'] = dob
# Optional contact and address fields
for key in ['email', 'phone', 'street', 'street2', 'city', 'zip']:
if key in post:
vals[key] = post.get(key) or False
if post.get('state_id'):
try:
vals['state_id'] = int(post.get('state_id'))
except Exception:
pass
if post.get('country_id'):
try:
vals['country_id'] = int(post.get('country_id'))
except Exception:
pass
# Additional fields for treatment professionals
if is_treatment_prof:
if selected_team_ids:
vals['team_ids'] = [(6, 0, list(set(selected_team_ids)))]
if 'allergies' in post:
_all = (post.get('allergies') or '').strip()
vals['allergies'] = _all if _all else False
if 'team_info_notes' in post:
_notes = (post.get('team_info_notes') or '').strip()
vals['team_info_notes'] = _notes if _notes else False
if post.get('match_status'):
vals['match_status'] = post.get('match_status')
if post.get('practice_status'):
vals['practice_status'] = post.get('practice_status')
# Use public create method with permission checks
try:
patient = request.env['sports.patient'].create_portal_patient(vals)
except ValidationError as ve:
# Re-render the create form (search-first view) with preserved input and a friendly message
_logger.info('ValidationError during player create: %s', ve)
# Rebuild the context needed by the search-first page
canada = request.env['res.country'].search([('code', '=', 'CA')], limit=1)
states = request.env['res.country.state'].search([('country_id', '=', canada.id)], order='name') if canada else request.env['res.country.state']
countries = canada if canada else request.env['res.country'].search([], order='name')
all_teams = request.env['sports.team'].search([
('id', 'in', request.env['sports.team.staff'].search([
('partner_id', '=', user.partner_id.id)
]).mapped('team_id').ids)
], order='name')
values = self._prepare_portal_layout_values()
values.update({
'page_name': 'create_player',
'is_treatment_prof': is_treatment_prof,
'all_teams': all_teams,
'states': states,
'countries': countries,
'relationship_types': request.env['sports.patient.contact']._fields['contact_type'].selection,
# keep the original search context so the create form shows
'first_name': first_name,
'last_name': last_name,
'date_of_birth': dob,
'attempted': True,
'searched': True,
'active_results': [],
'archived_results': [],
# provide posted values to prefill the form
'form_data': dict(post),
'error': str(ve) or _('Invalid combination of match and practice status.'),
})
return request.render('bemade_sports_clinic.portal_create_player', values)
except Exception as e:
# Render the create form with a generic error and preserve inputs
_logger.exception('Error creating patient from portal create')
canada = request.env['res.country'].search([('code', '=', 'CA')], limit=1)
states = request.env['res.country.state'].search([('country_id', '=', canada.id)], order='name') if canada else request.env['res.country.state']
countries = canada if canada else request.env['res.country'].search([], order='name')
all_teams = request.env['sports.team'].search([
('id', 'in', request.env['sports.team.staff'].search([
('partner_id', '=', user.partner_id.id)
]).mapped('team_id').ids)
], order='name')
values = self._prepare_portal_layout_values()
values.update({
'page_name': 'create_player',
'is_treatment_prof': is_treatment_prof,
'all_teams': all_teams,
'states': states,
'countries': countries,
'relationship_types': request.env['sports.patient.contact']._fields['contact_type'].selection,
# keep the original search context so the create form shows
'first_name': first_name,
'last_name': last_name,
'date_of_birth': dob,
'attempted': True,
'searched': True,
'active_results': [],
'archived_results': [],
# provide posted values to prefill the form
'form_data': dict(post),
'error': str(e) or _('There was an error creating the player. Please review your inputs and try again.'),
})
return request.render('bemade_sports_clinic.portal_create_player', values)
# Optionally create a primary emergency contact if provided (TPs only)
if is_treatment_prof:
ec_name = (post.get('ec_name') or '').strip()
ec_type = (post.get('ec_contact_type') or '').strip()
if ec_name and ec_type:
contact_vals = {
'patient_id': patient.id,
'name': ec_name,
'contact_type': ec_type,
}
if post.get('ec_mobile'):
contact_vals['mobile'] = post.get('ec_mobile')
if post.get('ec_email'):
contact_vals['email'] = post.get('ec_email')
try:
request.env['sports.patient.contact'].sudo().create(contact_vals)
except Exception:
_logger.exception('Failed to create primary emergency contact during player create')
return request.redirect(f"/my/player?player_id={patient.id}")
@http.route(['/my/player/edit'], type='http', auth='user', website=True)
def edit_player_form(self, patient_id, **post):
"""Show form to edit player information"""
@ -28,6 +366,12 @@ class PlayerManagementPortal(CustomerPortal, AccessControlMixin):
is_treatment_prof = user.has_group('bemade_sports_clinic.group_portal_treatment_professional')
teams = patient.team_ids
# All teams for multi-select limited to current user's staff teams
all_teams = request.env['sports.team'].search([
('id', 'in', request.env['sports.team.staff'].search([
('partner_id', '=', user.partner_id.id)
]).mapped('team_id').ids)
], order='name')
# Create a dictionary with patient info for protected fields
patient_info = {}
@ -61,13 +405,24 @@ class PlayerManagementPortal(CustomerPortal, AccessControlMixin):
canada = request.env['res.country'].search([('code', '=', 'CA')], limit=1)
states = request.env['res.country.state'].search([('country_id', '=', canada.id)], order='name') if canada else request.env['res.country.state']
countries = canada if canada else request.env['res.country'].search([], order='name')
# Determine primary emergency contact (lowest sequence)
primary_contact = request.env['sports.patient.contact'].search([
('patient_id', '=', patient.id)
], order='sequence,id', limit=1)
values = {
'patient': patient, # Keep original patient
'patient_info': patient_info, # Add patient_info for protected fields
'teams': teams,
'all_teams': all_teams,
'states': states,
'countries': countries,
'relationship_types': request.env['sports.patient.contact']._fields['contact_type'].selection,
# Primary emergency contact defaults for prefill
'ec_name': primary_contact.name if primary_contact else '',
'ec_contact_type': primary_contact.contact_type if primary_contact else '',
'ec_mobile': primary_contact.mobile if primary_contact else '',
'ec_email': primary_contact.email if primary_contact else '',
'return_url': return_url,
'page_name': 'edit_player',
'is_treatment_prof': is_treatment_prof,
@ -91,6 +446,69 @@ class PlayerManagementPortal(CustomerPortal, AccessControlMixin):
# Check if user is a treatment professional
is_treatment_prof = request.env.user.has_group('bemade_sports_clinic.group_portal_treatment_professional')
# If DOB is being changed/provided, validate format upfront to avoid unhelpful errors
dob_str = (post.get('date_of_birth') or '').strip()
if dob_str:
try:
dob_date = fields.Date.to_date(dob_str)
except Exception:
# Rebuild dropdowns and re-render with preserved inputs
user = request.env.user
canada = request.env['res.country'].search([('code', '=', 'CA')], limit=1)
states = request.env['res.country.state'].search([('country_id', '=', canada.id)], order='name') if canada else request.env['res.country.state']
countries = canada if canada else request.env['res.country'].search([], order='name')
all_teams = request.env['sports.team'].search([
('id', 'in', request.env['sports.team.staff'].search([
('partner_id', '=', user.partner_id.id)
]).mapped('team_id').ids)
], order='name')
values = {
'patient': patient,
'patient_info': {},
'teams': patient.team_ids,
'all_teams': all_teams,
'states': states,
'countries': countries,
'relationship_types': request.env['sports.patient.contact']._fields['contact_type'].selection,
'return_url': post.get('return_url', f'/my/player?player_id={patient_id}'),
'page_name': 'edit_player',
'is_treatment_prof': is_treatment_prof,
'error': _('Please enter a valid Date of Birth (YYYY-MM-DD).'),
'form_data': dict(post),
}
return request.render('bemade_sports_clinic.portal_edit_player', values)
# Range checks: not in the future, not older than 120 years
today = fields.Date.context_today(request.env.user) or fields.Date.today()
min_dob = today - relativedelta(years=120)
if dob_date > today or dob_date < min_dob:
user = request.env.user
canada = request.env['res.country'].search([('code', '=', 'CA')], limit=1)
states = request.env['res.country.state'].search([('country_id', '=', canada.id)], order='name') if canada else request.env['res.country.state']
countries = canada if canada else request.env['res.country'].search([], order='name')
all_teams = request.env['sports.team'].search([
('id', 'in', request.env['sports.team.staff'].search([
('partner_id', '=', user.partner_id.id)
]).mapped('team_id').ids)
], order='name')
values = {
'patient': patient,
'patient_info': {},
'teams': patient.team_ids,
'all_teams': all_teams,
'states': states,
'countries': countries,
'relationship_types': request.env['sports.patient.contact']._fields['contact_type'].selection,
'return_url': post.get('return_url', f'/my/player?player_id={patient_id}'),
'page_name': 'edit_player',
'is_treatment_prof': is_treatment_prof,
'error': _('Date of Birth must not be in the future and not be more than 120 years ago.'),
'form_data': dict(post),
}
return request.render('bemade_sports_clinic.portal_edit_player', values)
# Prepare values for patient update
vals = {}
@ -135,6 +553,18 @@ class PlayerManagementPortal(CustomerPortal, AccessControlMixin):
# Additional fields that only treatment professionals can update
if is_treatment_prof:
# Team assignments via multi-select
try:
team_id_list = [int(tid) for tid in request.httprequest.form.getlist('team_ids')]
except Exception:
team_id_list = []
if team_id_list:
# Defense-in-depth: restrict to teams where current user is staff
allowed_team_ids = request.env['sports.team.staff'].search([
('partner_id', '=', request.env.user.partner_id.id)
]).mapped('team_id').ids
filtered_team_ids = [tid for tid in team_id_list if tid in allowed_team_ids]
vals['team_ids'] = [(6, 0, list(set(filtered_team_ids)))]
if post.get('date_of_birth'):
vals.update({
'date_of_birth': post.get('date_of_birth'),
@ -142,13 +572,15 @@ class PlayerManagementPortal(CustomerPortal, AccessControlMixin):
# Medical information
if 'allergies' in post:
_all = (post.get('allergies') or '').strip()
vals.update({
'allergies': post.get('allergies') or False,
'allergies': _all if _all else False,
})
if 'team_info_notes' in post:
_notes = (post.get('team_info_notes') or '').strip()
vals.update({
'team_info_notes': post.get('team_info_notes') or False,
'team_info_notes': _notes if _notes else False,
})
# Status fields
@ -164,8 +596,114 @@ class PlayerManagementPortal(CustomerPortal, AccessControlMixin):
# Update the patient - no sudo needed as field-level security is in place
if vals:
patient.write(vals)
try:
patient.write(vals)
except ValidationError as ve:
# Re-render edit form with preserved input and error
user = request.env.user
is_treatment_prof = user.has_group('bemade_sports_clinic.group_portal_treatment_professional')
canada = request.env['res.country'].search([('code', '=', 'CA')], limit=1)
states = request.env['res.country.state'].search([('country_id', '=', canada.id)], order='name') if canada else request.env['res.country.state']
countries = canada if canada else request.env['res.country'].search([], order='name')
# All teams for multi-select limited to current user's staff teams
all_teams = request.env['sports.team'].search([
('id', 'in', request.env['sports.team.staff'].search([
('partner_id', '=', user.partner_id.id)
]).mapped('team_id').ids)
], order='name')
values = {
'patient': patient,
'patient_info': {},
'teams': patient.team_ids,
'all_teams': all_teams,
'states': states,
'countries': countries,
'relationship_types': request.env['sports.patient.contact']._fields['contact_type'].selection,
'return_url': post.get('return_url', f'/my/player?player_id={patient_id}'),
'page_name': 'edit_player',
'is_treatment_prof': is_treatment_prof,
'error': str(ve) or _('Invalid combination of match and practice status.'),
'form_data': dict(post),
}
return request.render('bemade_sports_clinic.portal_edit_player', values)
except Exception as e:
# Generic exception: re-render with generic error and preserved inputs
user = request.env.user
is_treatment_prof = user.has_group('bemade_sports_clinic.group_portal_treatment_professional')
canada = request.env['res.country'].search([('code', '=', 'CA')], limit=1)
states = request.env['res.country.state'].search([('country_id', '=', canada.id)], order='name') if canada else request.env['res.country.state']
countries = canada if canada else request.env['res.country'].search([], order='name')
all_teams = request.env['sports.team'].search([
('id', 'in', request.env['sports.team.staff'].search([
('partner_id', '=', user.partner_id.id)
]).mapped('team_id').ids)
], order='name')
values = {
'patient': patient,
'patient_info': {},
'teams': patient.team_ids,
'all_teams': all_teams,
'states': states,
'countries': countries,
'relationship_types': request.env['sports.patient.contact']._fields['contact_type'].selection,
'return_url': post.get('return_url', f'/my/player?player_id={patient_id}'),
'page_name': 'edit_player',
'is_treatment_prof': is_treatment_prof,
'error': str(e) or _('An unexpected error occurred.'),
'form_data': dict(post),
}
return request.render('bemade_sports_clinic.portal_edit_player', values)
# Update or create primary emergency contact (TPs only), regardless of patient field changes
if is_treatment_prof:
ec_name = (post.get('ec_name') or '').strip()
ec_type = (post.get('ec_contact_type') or '').strip()
ec_mobile = (post.get('ec_mobile') or '').strip()
ec_email = (post.get('ec_email') or '').strip()
# If any EC field provided, attempt update/create
if any([ec_name, ec_type, ec_mobile, ec_email]):
primary_contact = request.env['sports.patient.contact'].search([
('patient_id', '=', patient.id)
], order='sequence,id', limit=1)
if primary_contact:
update_vals = {}
# Only update name/type if provided (avoid clearing required selection)
if ec_name:
update_vals['name'] = ec_name
if ec_type:
update_vals['contact_type'] = ec_type
# For mobile/email, explicitly set to value or False to clear
if 'ec_mobile' in post:
update_vals['mobile'] = ec_mobile or False
if 'ec_email' in post:
update_vals['email'] = ec_email or False
if update_vals:
try:
primary_contact.sudo().write(update_vals)
except Exception:
_logger.exception('Failed to update primary emergency contact for patient %s', patient.id)
else:
# Create only if we have minimally required fields
if ec_name and ec_type:
create_vals = {
'patient_id': patient.id,
'name': ec_name,
'contact_type': ec_type,
'sequence': 0,
}
if 'ec_mobile' in post:
create_vals['mobile'] = ec_mobile or False
if 'ec_email' in post:
create_vals['email'] = ec_email or False
try:
request.env['sports.patient.contact'].sudo().create(create_vals)
except Exception:
_logger.exception('Failed to create primary emergency contact for patient %s', patient.id)
return request.redirect(f'/my/player?player_id={patient_id}')
@http.route(['/my/player/contact/add'], type='http', auth='user', website=True)

View file

@ -1,9 +1,11 @@
from odoo import http, _
from odoo import http, fields, _
from odoo.http import request
from odoo.addons.portal.controllers.portal import CustomerPortal
from odoo.exceptions import AccessError, MissingError, UserError, ValidationError
from .access_control_mixin import AccessControlMixin
import logging
from datetime import date
from dateutil.relativedelta import relativedelta
_logger = logging.getLogger(__name__)
@ -310,6 +312,17 @@ class TeamManagementPortal(CustomerPortal, AccessControlMixin):
'searched': searched,
'active_results': [_to_dict(p) for p in active_rs],
'archived_results': [_to_dict(p) for p in archived_rs],
# Extra context to support inline full create form
'is_treatment_prof': request.env.user.has_group('bemade_sports_clinic.group_portal_treatment_professional') or request.env.user.has_group('base.group_system'),
'states': request.env['res.country.state'].search([('country_id.code', '=', 'CA')], order='name'),
'countries': request.env['res.country'].search([('code', '=', 'CA')], limit=1) or request.env['res.country'].search([], order='name'),
'all_teams': request.env['sports.team'].search([
('id', 'in', request.env['sports.team.staff'].search([
('partner_id', '=', request.env.user.partner_id.id)
]).mapped('team_id').ids)
], order='name'),
'relationship_types': request.env['sports.patient.contact']._fields['contact_type'].selection,
'prefill_team_id': team.id,
})
if attempted and not searched:
values['error'] = _('Provide at least a first or last name to search')
@ -326,6 +339,233 @@ class TeamManagementPortal(CustomerPortal, AccessControlMixin):
}
return request.redirect(f"/my/team/{team_id}")
@http.route(['/my/team/<int:team_id>/player/create_full'], type='http', auth='user', website=True, methods=['POST'], csrf=True)
def portal_create_player_full(self, team_id, **post):
"""Create a brand new player with full details (inline edit form) and link to team.
This is used when the search yields no results and we render the full form inline.
Only treatment professionals/admins may create directly.
"""
try:
team = self._check_team_access(team_id)
# Permission: only therapists/admins can create directly
is_tp = request.env.user.has_group('bemade_sports_clinic.group_portal_treatment_professional') or \
request.env.user.has_group('base.group_system')
if not is_tp:
raise AccessError(_("You don't have permission to create players."))
# Required basics
first_name = (post.get('first_name') or '').strip()
last_name = (post.get('last_name') or '').strip()
dob = (post.get('date_of_birth') or '').strip()
if not first_name or not last_name:
raise UserError(_('First name and last name are required'))
if not dob:
raise UserError(_('Date of birth is required'))
# Validate DOB format explicitly to avoid ValueError down in ORM
try:
dob_date = fields.Date.to_date(dob)
except Exception:
values = self._prepare_portal_layout_values()
values.update({
'page_name': 'add_link_player',
'team': team,
'first_name': post.get('first_name') or '',
'last_name': post.get('last_name') or '',
'date_of_birth': dob,
'searched': True,
'active_results': [],
'archived_results': [],
'is_treatment_prof': request.env.user.has_group('bemade_sports_clinic.group_portal_treatment_professional') or request.env.user.has_group('base.group_system'),
'states': request.env['res.country.state'].search([('country_id.code', '=', 'CA')], order='name'),
'countries': request.env['res.country'].search([('code', '=', 'CA')], limit=1) or request.env['res.country'].search([], order='name'),
'all_teams': request.env['sports.team'].search([
('id', 'in', request.env['sports.team.staff'].search([
('partner_id', '=', request.env.user.partner_id.id)
]).mapped('team_id').ids)
], order='name'),
'relationship_types': request.env['sports.patient.contact']._fields['contact_type'].selection,
'form_data': dict(post),
'error': _('Please enter a valid Date of Birth (YYYY-MM-DD).'),
'prefill_team_id': team.id,
})
return request.render('bemade_sports_clinic.portal_add_link_player_page', values)
# Enforce DOB not in future and not older than 120 years
today = fields.Date.context_today(request.env.user) or fields.Date.today()
min_dob = today - relativedelta(years=120)
if dob_date > today or dob_date < min_dob:
values = self._prepare_portal_layout_values()
values.update({
'page_name': 'add_link_player',
'team': team,
'first_name': post.get('first_name') or '',
'last_name': post.get('last_name') or '',
'date_of_birth': dob,
'searched': True,
'active_results': [],
'archived_results': [],
'is_treatment_prof': request.env.user.has_group('bemade_sports_clinic.group_portal_treatment_professional') or request.env.user.has_group('base.group_system'),
'states': request.env['res.country.state'].search([('country_id.code', '=', 'CA')], order='name'),
'countries': request.env['res.country'].search([('code', '=', 'CA')], limit=1) or request.env['res.country'].search([], order='name'),
'all_teams': request.env['sports.team'].search([
('id', 'in', request.env['sports.team.staff'].search([
('partner_id', '=', request.env.user.partner_id.id)
]).mapped('team_id').ids)
], order='name'),
'relationship_types': request.env['sports.patient.contact']._fields['contact_type'].selection,
'form_data': dict(post),
'error': _('Date of Birth must not be in the future and not be more than 120 years ago.'),
'prefill_team_id': team.id,
})
return request.render('bemade_sports_clinic.portal_add_link_player_page', values)
# Build values similar to edit form, honoring permissions
# Collect team_ids from multi-select; ensure current team is included by default
try:
selected_team_ids = [int(tid) for tid in request.httprequest.form.getlist('team_ids')]
except Exception:
selected_team_ids = []
if not selected_team_ids:
selected_team_ids = [team.id]
if team.id not in selected_team_ids:
selected_team_ids.append(team.id)
# Restrict to teams where current user is staff (defense-in-depth against tampering)
allowed_team_ids = request.env['sports.team.staff'].search([
('partner_id', '=', request.env.user.partner_id.id)
]).mapped('team_id').ids
selected_team_ids = [tid for tid in selected_team_ids if tid in allowed_team_ids]
vals = {
'first_name': first_name,
'last_name': last_name,
'date_of_birth': dob,
'team_ids': [(6, 0, list(set(selected_team_ids)))],
}
# Contact info
email = (post.get('email') or '').strip()
phone = (post.get('phone') or '').strip()
if email:
vals['email'] = email
if phone:
vals['phone'] = phone
# Address info
for f in ['street', 'street2', 'city', 'zip']:
if f in post:
vals[f] = post.get(f) or False
# State/Country
if post.get('state_id'):
try:
vals['state_id'] = int(post.get('state_id'))
except Exception:
pass
if post.get('country_id'):
try:
vals['country_id'] = int(post.get('country_id'))
except Exception:
pass
# Extra medical/status fields (only for TP/admin)
if is_tp:
if 'allergies' in post:
vals['allergies'] = post.get('allergies') or False
if 'team_info_notes' in post:
vals['team_info_notes'] = post.get('team_info_notes') or False
if post.get('match_status'):
vals['match_status'] = post.get('match_status')
if post.get('practice_status'):
vals['practice_status'] = post.get('practice_status')
# Create through secured helper
patient = request.env['sports.patient']._create_portal_patient(vals)
# Optionally create a primary emergency contact if provided (TP/admin only)
if is_tp:
ec_name = (post.get('ec_name') or '').strip()
ec_type = (post.get('ec_contact_type') or '').strip()
if ec_name and ec_type:
contact_vals = {
'patient_id': patient.id,
'name': ec_name,
'contact_type': ec_type,
}
if post.get('ec_mobile'):
contact_vals['mobile'] = post.get('ec_mobile')
if post.get('ec_email'):
contact_vals['email'] = post.get('ec_email')
try:
request.env['sports.patient.contact'].sudo().create(contact_vals)
except Exception:
_logger.exception('Failed to create primary emergency contact during inline player create')
request.session['notification'] = {
'type': 'success',
'title': _('Player Created'),
'message': _('%s has been created and added to the team.') % patient.name,
'sticky': False,
}
# After one-step create, return to team page
return request.redirect(f"/my/team/{team.id}")
except (AccessError, UserError, ValidationError) as e:
# Re-render the add/link page with preserved form values and error
values = self._prepare_portal_layout_values()
team = self._check_team_access(team_id)
values.update({
'page_name': 'add_link_player',
'team': team,
'first_name': post.get('first_name') or '',
'last_name': post.get('last_name') or '',
'date_of_birth': post.get('date_of_birth') or '',
'searched': True,
'active_results': [],
'archived_results': [],
'is_treatment_prof': request.env.user.has_group('bemade_sports_clinic.group_portal_treatment_professional') or request.env.user.has_group('base.group_system'),
'states': request.env['res.country.state'].search([('country_id.code', '=', 'CA')], order='name'),
'countries': request.env['res.country'].search([('code', '=', 'CA')], limit=1) or request.env['res.country'].search([], order='name'),
'all_teams': request.env['sports.team'].search([
('id', 'in', request.env['sports.team.staff'].search([
('partner_id', '=', request.env.user.partner_id.id)
]).mapped('team_id').ids)
], order='name'),
'relationship_types': request.env['sports.patient.contact']._fields['contact_type'].selection,
'form_data': dict(post),
'error': str(e),
'prefill_team_id': team.id,
})
return request.render('bemade_sports_clinic.portal_add_link_player_page', values)
except Exception:
_logger.exception('Error creating player from inline full form')
# Render with generic error and preserved inputs
values = self._prepare_portal_layout_values()
team = self._check_team_access(team_id)
values.update({
'page_name': 'add_link_player',
'team': team,
'first_name': post.get('first_name') or '',
'last_name': post.get('last_name') or '',
'date_of_birth': post.get('date_of_birth') or '',
'searched': True,
'active_results': [],
'archived_results': [],
'is_treatment_prof': request.env.user.has_group('bemade_sports_clinic.group_portal_treatment_professional') or request.env.user.has_group('base.group_system'),
'states': request.env['res.country.state'].search([('country_id.code', '=', 'CA')], order='name'),
'countries': request.env['res.country'].search([('code', '=', 'CA')], limit=1) or request.env['res.country'].search([], order='name'),
'all_teams': request.env['sports.team'].search([
('id', 'in', request.env['sports.team.staff'].search([
('partner_id', '=', request.env.user.partner_id.id)
]).mapped('team_id').ids)
], order='name'),
'relationship_types': request.env['sports.patient.contact']._fields['contact_type'].selection,
'form_data': dict(post),
'error': _('An unexpected error occurred.'),
'prefill_team_id': team.id,
})
return request.render('bemade_sports_clinic.portal_add_link_player_page', values)
@http.route(['/my/team/<int:team_id>/add_player/submit'],
type='http', auth="user", website=True, methods=['POST'], csrf=True)
def portal_add_player_submit(self, team_id, **post):
@ -345,7 +585,35 @@ class TeamManagementPortal(CustomerPortal, AccessControlMixin):
# Enforce DOB for data integrity
if not dob:
raise UserError(_("Date of birth is required"))
# Validate DOB format explicitly
try:
dob_date = fields.Date.to_date(dob)
except Exception:
values = {
'error': _('Please enter a valid Date of Birth (YYYY-MM-DD).'),
'team': team,
'page_name': 'add_player',
}
values.update(post)
return request.render("bemade_sports_clinic.portal_add_player", values)
# Enforce DOB not in future and not older than 120 years
today = fields.Date.context_today(request.env.user) or fields.Date.today()
min_dob = today - relativedelta(years=120)
if dob_date > today or dob_date < min_dob:
values = {
'error': _('Date of Birth must not be in the future and not be more than 120 years ago.'),
'team': team,
'page_name': 'add_player',
}
values.update(post)
return request.render("bemade_sports_clinic.portal_add_player", values)
# Determine if current user is allowed to set medical/status fields
is_tp_or_admin = request.env.user.has_group('bemade_sports_clinic.group_portal_treatment_professional') or \
request.env.user.has_group('base.group_system')
# Check for existing player
existing_patient = self._find_existing_patient(
first_name, last_name, email, phone
@ -395,6 +663,12 @@ class TeamManagementPortal(CustomerPortal, AccessControlMixin):
'phone': phone or False,
'date_of_birth': dob,
}
# Pass through status fields if permitted
if is_tp_or_admin:
if post.get('match_status'):
patient_vals['match_status'] = post.get('match_status')
if post.get('practice_status'):
patient_vals['practice_status'] = post.get('practice_status')
# Create patient through the private _create_portal_patient method which has proper access controls
patient = request.env['sports.patient']._create_portal_patient(patient_vals)
@ -556,6 +830,11 @@ class TeamManagementPortal(CustomerPortal, AccessControlMixin):
}
if dob:
vals['date_of_birth'] = dob
# Pass through status fields (route already restricts to TP/admin)
if post.get('match_status'):
vals['match_status'] = post.get('match_status')
if post.get('practice_status'):
vals['practice_status'] = post.get('practice_status')
patient = request.env['sports.patient']._create_portal_patient(vals)
@ -622,6 +901,11 @@ class TeamManagementPortal(CustomerPortal, AccessControlMixin):
vals['email'] = email
if phone:
vals['phone'] = phone
# Pass through status fields
if post.get('match_status'):
vals['match_status'] = post.get('match_status')
if post.get('practice_status'):
vals['practice_status'] = post.get('practice_status')
patient = request.env['sports.patient']._create_portal_patient(vals)

View file

@ -839,6 +839,15 @@ class Patient(models.Model):
'phone': vals.get('phone', False),
'type': 'contact',
}
# Optional address fields coming from portal form
# These are res.partner fields, so capture them here
for key in ['street', 'street2', 'city', 'zip']:
if key in vals:
partner_vals[key] = vals.get(key) or False
if vals.get('state_id'):
partner_vals['state_id'] = vals.get('state_id')
if vals.get('country_id'):
partner_vals['country_id'] = vals.get('country_id')
partner = (
self.env['res.partner']
.sudo()
@ -858,7 +867,17 @@ class Patient(models.Model):
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']
# Status fields from portal (treatment professionals only)
if vals.get('match_status'):
patient_vals['match_status'] = vals.get('match_status')
if vals.get('practice_status'):
patient_vals['practice_status'] = vals.get('practice_status')
# Other optional patient fields
if 'allergies' in vals:
patient_vals['allergies'] = vals.get('allergies') or False
if 'team_info_notes' in vals:
patient_vals['team_info_notes'] = vals.get('team_info_notes') or False
# Create patient with tracking disabled to avoid triggering mail/report side-effects
# Also disable auto-subscriptions on creation
patient = self.sudo().with_context(

View file

@ -1,5 +1,266 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Reusable partial: Player Create/Edit Form Body (used by standalone and team create views) -->
<template id="portal_player_form_body" name="Player Form Body">
<!-- Player Details Card -->
<div class="card mb-3">
<div class="card-header">
<h6 class="mb-0">Player Details</h6>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-6">
<div class="form-group">
<label>First Name <span class="text-danger">*</span></label>
<input type="text" name="first_name" class="form-control" required="required"
t-att-value="(form_data and form_data.get('first_name')) or (patient and patient.first_name) or first_name or ''"/>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label>Last Name <span class="text-danger">*</span></label>
<input type="text" name="last_name" class="form-control" required="required"
t-att-value="(form_data and form_data.get('last_name')) or (patient and patient.last_name) or last_name or ''"/>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<div class="form-group">
<label>Email</label>
<input type="email" name="email" class="form-control"
t-att-value="(form_data and form_data.get('email')) or (patient and patient.email) or ''"/>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label>Phone</label>
<input type="tel" name="phone" class="form-control"
t-att-value="(form_data and form_data.get('phone')) or (patient and patient.phone) or ''"/>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<div class="form-group">
<label>Date of Birth <span class="text-danger">*</span></label>
<input type="date" name="date_of_birth" class="form-control" required="required"
t-att-value="(form_data and form_data.get('date_of_birth')) or (patient_info and patient_info.get('date_of_birth')) or (patient and patient.date_of_birth) or date_of_birth or ''"/>
</div>
</div>
</div>
<!-- TP-only additional info placed within Player Details -->
<t t-if="is_treatment_prof">
<div class="row mb-3">
<div class="col-12">
<div class="form-group">
<label for="allergies">Allergies</label>
<textarea name="allergies" id="allergies" class="form-control" rows="2" placeholder="Enter allergies information here"><t t-esc="(form_data and form_data.get('allergies')) or (patient_info and patient_info.get('allergies')) or ''"/></textarea>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-12">
<div class="form-group">
<label for="team_info_notes">Internal Notes</label>
<textarea name="team_info_notes" id="team_info_notes" class="form-control" rows="3" placeholder="Enter internal notes here"><t t-esc="(form_data and form_data.get('team_info_notes')) or (patient_info and patient_info.get('team_info_notes')) or ''"/></textarea>
</div>
</div>
</div>
</t>
</div>
</div>
<!-- Status Card (TP only) -->
<t t-if="is_treatment_prof">
<div class="card mb-3">
<div class="card-header">
<h6 class="mb-0">Status</h6>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label>Match Status</label>
<select name="match_status" class="form-control" id="create_match_status">
<option value="yes" t-att-selected="'selected' if ((form_data and form_data.get('match_status') == 'yes') or (not form_data and patient_info and patient_info.get('match_status') == 'yes')) else None">Yes</option>
<option value="no" t-att-selected="'selected' if ((form_data and form_data.get('match_status') == 'no') or (not form_data and patient_info and patient_info.get('match_status') == 'no')) else None">No</option>
</select>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label>Practice Status</label>
<select name="practice_status" class="form-control" id="create_practice_status">
<option value="yes" t-att-selected="'selected' if ((form_data and form_data.get('practice_status') == 'yes') or (not form_data and patient_info and patient_info.get('practice_status') == 'yes')) else None">Yes</option>
<option value="no_contact" t-att-selected="'selected' if ((form_data and form_data.get('practice_status') == 'no_contact') or (not form_data and patient_info and patient_info.get('practice_status') == 'no_contact')) else None">Yes, no contact</option>
<option value="no" t-att-selected="'selected' if ((form_data and form_data.get('practice_status') == 'no') or (not form_data and patient_info and patient_info.get('practice_status') == 'no')) else None">No</option>
</select>
</div>
</div>
</div>
</div>
</div>
</t>
<!-- Address Card -->
<div class="card mb-3">
<div class="card-header">
<h6 class="mb-0">Address Information</h6>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-12">
<div class="form-group">
<label>Street Address</label>
<input type="text" name="street" class="form-control"
t-att-value="(form_data and form_data.get('street')) or (patient and patient.street) or ''"/>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-md-12">
<div class="form-group">
<label>Street Address 2</label>
<input type="text" name="street2" class="form-control"
t-att-value="(form_data and form_data.get('street2')) or (patient and patient.street2) or ''"/>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<div class="form-group">
<label>City</label>
<input type="text" name="city" class="form-control"
t-att-value="(form_data and form_data.get('city')) or (patient and patient.city) or ''"/>
</div>
</div>
<div class="col-md-3">
<div class="form-group">
<label>ZIP/Postal Code</label>
<input type="text" name="zip" class="form-control"
t-att-value="(form_data and form_data.get('zip')) or (patient and patient.zip) or ''"/>
</div>
</div>
<div class="col-md-3">
<div class="form-group">
<label>Province/Territory</label>
<select name="state_id" class="form-control">
<option value="">Select Province/Territory...</option>
<t t-foreach="states" t-as="state">
<option t-att-value="state.id"
t-att-selected="'selected' if (form_data and form_data.get('state_id') and int(form_data.get('state_id')) == state.id) or (not form_data and patient and patient.state_id and patient.state_id.id == state.id) else None"
t-esc="state.name"/>
</t>
</select>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<div class="form-group">
<label>Country</label>
<select name="country_id" class="form-control">
<t t-if="countries">
<option t-att-value="countries.id"
t-att-selected="'selected' if (form_data and form_data.get('country_id') and int(form_data.get('country_id')) == countries.id) or (not form_data and patient and patient.country_id and patient.country_id.id == countries.id) else None"
t-esc="countries.name"/>
</t>
<t t-else="">
<option value="">No country available</option>
</t>
</select>
</div>
</div>
</div>
</div>
</div>
<!-- Primary Emergency Contact (TP only) -->
<t t-if="is_treatment_prof">
<div class="card mb-3">
<div class="card-header">
<h6 class="mb-0">Primary Emergency Contact</h6>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-6">
<div class="form-group">
<label>Contact Name</label>
<input type="text" name="ec_name" class="form-control" placeholder="e.g., Jane Doe"
t-att-value="(form_data and form_data.get('ec_name')) or ec_name or ''"/>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label>Relationship</label>
<select name="ec_contact_type" class="form-select">
<option value="">Select...</option>
<t t-foreach="relationship_types" t-as="rel">
<option t-att-value="rel[0]"
t-att-selected="'selected' if ((form_data and form_data.get('ec_contact_type') == rel[0]) or (not form_data and ec_contact_type and ec_contact_type == rel[0])) else None"
t-esc="rel[1]"/>
</t>
</select>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<div class="form-group">
<label>Mobile</label>
<input type="tel" name="ec_mobile" class="form-control" placeholder="e.g., +1 555-555-1234"
t-att-value="(form_data and form_data.get('ec_mobile')) or ec_mobile or ''"/>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label>Email</label>
<input type="email" name="ec_email" class="form-control" placeholder="name@example.com"
t-att-value="(form_data and form_data.get('ec_email')) or ec_email or ''"/>
</div>
</div>
</div>
</div>
</div>
</t>
<!-- Assign Teams (TP only) -->
<t t-if="is_treatment_prof">
<div class="card mb-3">
<div class="card-header">
<h6 class="mb-0">Assign Teams</h6>
</div>
<div class="card-body">
<div class="row">
<div class="col-12">
<div class="form-group">
<label class="form-label">Teams</label>
<div class="row row-cols-1 row-cols-md-2 g-2">
<t t-foreach="all_teams" t-as="t">
<div class="col">
<div class="form-check">
<input class="form-check-input" type="checkbox"
t-att-id="'team_ids_' + str(t.id)"
name="team_ids" t-att-value="t.id"
t-att-checked="'checked' if ((form_data and form_data.get('team_ids') and (str(t.id) in (form_data.get('team_ids') if isinstance(form_data.get('team_ids'), list) else [form_data.get('team_ids')]))) or (not form_data and patient and (t.id in patient.team_ids.ids)) or (prefill_team_id and t.id == prefill_team_id)) else None"/>
<label class="form-check-label" t-att-for="'team_ids_' + str(t.id)">
<t t-esc="t.name"/>
</label>
</div>
</div>
</t>
</div>
</div>
</div>
</div>
</div>
</div>
</t>
</template>
<!-- Template for editing player information -->
<template id="portal_edit_player" name="Edit Player">
<t t-call="portal.portal_layout">
@ -8,9 +269,12 @@
<div class="col-12">
<h2>Edit Player Information</h2>
<t t-if="error" t-out="error" class="alert alert-danger"/>
<t t-if="error">
<div class="alert alert-danger" t-esc="error"/>
</t>
<div id="edit-player-client-error" class="alert alert-danger d-none"></div>
<form action="/my/player/save" method="post">
<form action="/my/player/save" method="post" id="edit-player-form">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<input type="hidden" name="patient_id" t-att-value="patient.id"/>
<input type="hidden" name="return_url" t-att-value="return_url"/>
@ -28,265 +292,54 @@
</div>
</div>
</div>
<!-- Team membership actions: Remove/Request Removal -->
<div class="row mb-4">
<div class="col-12">
<h5>Team Membership</h5>
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead>
<tr>
<th>Team</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
<t t-foreach="teams" t-as="team">
<tr>
<td>
<a t-attf-href="/my/team?team_id={{ team.id }}">
<t t-esc="team.name"/>
</a>
</td>
<td class="text-end">
<t t-set="is_treatment_prof" t-value="request.env.user.has_group('bemade_sports_clinic.group_portal_treatment_professional') or request.env.user.has_group('base.group_system')"/>
<t t-set="is_team_staff" t-value="request.env.user.partner_id in team.staff_ids.user_ids.partner_id"/>
<!-- Direct remove for treatment professionals -->
<t t-if="is_treatment_prof">
<button type="submit"
class="btn btn-danger btn-sm"
t-att-formaction="'/my/team/' + str(team.id) + '/player/' + str(patient.id) + '/remove'"
formmethod="post"
formnovalidate="formnovalidate"
onclick="return confirm('Are you sure you want to remove this player from the team?');">
<i class="fa fa-user-times"/> Remove
</button>
</t>
<!-- Request removal for team staff (e.g., coaches) -->
<t t-elif="is_team_staff">
<button type="button"
class="btn bg-o-color-2 text-white btn-sm"
data-bs-toggle="modal"
t-att-data-bs-target="'#requestRemovalModalEdit' + str(team.id)">
<i class="fa fa-flag"/> Request Removal
</button>
<!-- Request Removal Modal -->
<div t-att-id="'requestRemovalModalEdit' + str(team.id)" class="modal fade" tabindex="-1" t-attf-aria-labelledby="requestRemovalModalEditLabel#{team.id}" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" t-attf-id="requestRemovalModalEditLabel#{team.id}">Request Player Removal</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form t-attf-action="/my/team/{{ team.id }}/player/{{ patient.id }}/request_removal" method="post">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<div class="modal-body">
<p>Please provide a reason for removing this player from the team. This will create a task for the head therapist to review.</p>
<div class="mb-3">
<label class="form-label">Reason</label>
<textarea name="reason" class="form-control" rows="3" required="required" placeholder="Please explain why this player should be removed from the team"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn bg-o-color-2 text-white" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn bg-o-color-1 text-white">Submit Request</button>
</div>
</form>
</div>
</div>
</div>
</t>
</td>
</tr>
</t>
</tbody>
</table>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<div class="form-group">
<label for="first_name">First Name <span class="text-danger">*</span></label>
<input type="text" name="first_name" id="first_name" class="form-control"
required="required" t-att-value="patient.first_name"/>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="last_name">Last Name <span class="text-danger">*</span></label>
<input type="text" name="last_name" id="last_name" class="form-control"
required="required" t-att-value="patient.last_name"/>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<div class="form-group">
<label for="email">Email</label>
<input type="email" name="email" id="email" class="form-control"
t-att-value="patient.email"/>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="phone">Phone</label>
<input type="tel" name="phone" id="phone" class="form-control"
t-att-value="patient.phone"/>
</div>
</div>
</div>
<!-- Address Information -->
<div class="row mb-3">
<div class="col-12">
<h5>Address Information</h5>
</div>
</div>
<div class="row mb-3">
<div class="col-md-12">
<div class="form-group">
<label for="street">Street Address</label>
<input type="text" name="street" id="street" class="form-control"
t-att-value="patient.street"/>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-md-12">
<div class="form-group">
<label for="street2">Street Address 2</label>
<input type="text" name="street2" id="street2" class="form-control"
t-att-value="patient.street2"/>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<div class="form-group">
<label for="city">City</label>
<input type="text" name="city" id="city" class="form-control"
t-att-value="patient.city"/>
</div>
</div>
<div class="col-md-3">
<div class="form-group">
<label for="zip">ZIP/Postal Code</label>
<input type="text" name="zip" id="zip" class="form-control"
t-att-value="patient.zip"/>
</div>
</div>
<div class="col-md-3">
<div class="form-group">
<label for="state_id">Province/Territory</label>
<select name="state_id" id="state_id" class="form-control">
<option value="">Select Province/Territory...</option>
<t t-foreach="states" t-as="state">
<option t-att-value="state.id"
t-att-selected="'selected' if patient.state_id and patient.state_id.id == state.id else None"
t-esc="state.name"/>
</t>
</select>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<div class="form-group">
<label for="country_id">Country</label>
<select name="country_id" id="country_id" class="form-control">
<t t-if="countries">
<option t-att-value="countries.id" selected="selected" t-esc="countries.name"/>
</t>
<t t-else="">
<option value="">No country available</option>
</t>
</select>
</div>
</div>
</div>
<!-- Additional fields accessible only to treatment professionals -->
<!-- Unified form content via shared partial -->
<t t-call="bemade_sports_clinic.portal_player_form_body"/>
<!-- Client-side validation for status fields -->
<t t-if="is_treatment_prof">
<div class="row mb-3">
<div class="col-md-6">
<div class="form-group">
<label for="date_of_birth">Date of Birth</label>
<input type="date" name="date_of_birth" id="date_of_birth" class="form-control"
t-att-value="patient_info.get('date_of_birth')"/>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="age">Age</label>
<input type="text" name="age" id="age" class="form-control" readonly="readonly"
t-att-value="patient_info.get('age')"/>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-12">
<div class="form-group">
<label for="allergies">Allergies</label>
<textarea name="allergies" id="allergies" class="form-control"
rows="2" placeholder="Enter allergies information here"
t-esc="patient_info.get('allergies') or ''"/>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-12">
<div class="form-group">
<label for="team_info_notes">Team Notes</label>
<textarea name="team_info_notes" id="team_info_notes" class="form-control"
rows="4" placeholder="Enter team notes here"
t-esc="patient_info.get('team_info_notes') or ''"/>
</div>
</div>
</div>
<!-- Status fields -->
<div class="row mb-3">
<div class="col-md-6">
<div class="form-group">
<label for="match_status">Match Status</label>
<select name="match_status" id="match_status" class="form-control">
<option value="yes" t-att-selected="patient_info.get('match_status') == 'yes'">Yes</option>
<option value="no" t-att-selected="patient_info.get('match_status') == 'no'">No</option>
</select>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="practice_status">Practice Status</label>
<select name="practice_status" id="practice_status" class="form-control">
<option value="yes" t-att-selected="patient_info.get('practice_status') == 'yes'">Yes</option>
<option value="no_contact" t-att-selected="patient_info.get('practice_status') == 'no_contact'">Yes, no contact</option>
<option value="no" t-att-selected="patient_info.get('practice_status') == 'no'">No</option>
</select>
</div>
</div>
</div>
<!-- Injury information -->
<div class="row mb-3" t-if="patient_info.get('injured_since')">
<div class="col-12">
<div class="form-group">
<label>Injured Since</label>
<p class="form-control-static" t-esc="patient_info.get('injured_since')"></p>
</div>
</div>
</div>
<script type="text/javascript">
//<![CDATA[
(function() {
function showError(msg){
var el = document.getElementById('edit-player-client-error');
if(!el) return;
el.textContent = msg;
el.classList.remove('d-none');
}
function clearError(){
var el = document.getElementById('edit-player-client-error');
if(!el) return;
el.textContent = '';
el.classList.add('d-none');
}
function isValidCombo(matchVal, practiceVal){
var valid = {
'yes': {'yes': true},
'no': {'yes': true, 'no_contact': true, 'no': true}
};
return !!(valid[matchVal] && valid[matchVal][practiceVal]);
}
var form = document.getElementById('edit-player-form');
if(form){
form.addEventListener('submit', function(e){
var mEl = document.getElementById('match_status') || document.getElementById('create_match_status');
var pEl = document.getElementById('practice_status') || document.getElementById('create_practice_status');
var m = (mEl||{}).value;
var p = (pEl||{}).value;
clearError();
if(m && p && !isValidCombo(m, p)){
e.preventDefault();
showError('Invalid combination of match and practice status.');
}
});
}
})();
//]]>
</script>
</t>
<div class="row mt-4">
<div class="col-6">
<a t-att-href="return_url" class="btn bg-o-color-2 text-white">Cancel</a>
@ -482,6 +535,13 @@
</a>
</div>
<!-- Error alert (e.g., invalid DOB on create_full) -->
<div t-if="error" class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="fa fa-exclamation-triangle me-2"/>
<span t-esc="error"/>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<div class="card mb-3">
<div class="card-body">
<form t-attf-action="/my/team/{{ team.id }}/player/add_link" method="get" class="row g-3">
@ -574,30 +634,16 @@
<div class="card mt-3">
<div class="card-header">Create New Player</div>
<div class="card-body">
<form t-attf-action="/my/team/{{ team.id }}/player/create" method="post" class="row g-3">
<form t-attf-action="/my/team/{{ team.id }}/player/create_full" method="post">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<div class="col-md-3">
<label class="form-label">First Name <span class="text-danger">*</span></label>
<input type="text" name="first_name" class="form-control" required="required" t-att-value="first_name"/>
</div>
<div class="col-md-3">
<label class="form-label">Last Name <span class="text-danger">*</span></label>
<input type="text" name="last_name" class="form-control" required="required" t-att-value="last_name"/>
</div>
<div class="col-md-3">
<label class="form-label">Date of Birth <span class="text-danger">*</span></label>
<input type="date" name="date_of_birth" class="form-control" t-att-value="date_of_birth" required="required"/>
</div>
<div class="col-md-3">
<label class="form-label">Email</label>
<input type="email" name="email" class="form-control"/>
</div>
<div class="col-md-3">
<label class="form-label">Phone</label>
<input type="tel" name="phone" class="form-control"/>
</div>
<div class="col-12 d-flex justify-content-end">
<button type="submit" class="btn bg-o-color-1 text-white"><i class="fa fa-save me-1"/> Create Player</button>
<!-- Reusable form body -->
<t t-set="prefill_team_id" t-value="team.id"/>
<t t-call="bemade_sports_clinic.portal_player_form_body"/>
<div class="row mt-3">
<div class="col-12 d-flex justify-content-end">
<button type="submit" class="btn bg-o-color-1 text-white"><i class="fa fa-save me-1"/> Create Player</button>
</div>
</div>
</form>
</div>
@ -607,4 +653,123 @@
</div>
</t>
</template>
<template id="portal_create_player" name="Portal Create Player">
<t t-call="portal.portal_layout">
<t t-set="title" t-value="'Create Player'"/>
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-10 col-lg-9">
<div class="d-flex justify-content-between align-items-center mb-3">
<h3 class="mb-0" t-esc="title"/>
<a href="/my/players" class="btn bg-o-color-2 text-white"><i class="fa fa-arrow-left me-1"/> Back to Players</a>
</div>
<t t-if="error">
<div class="alert alert-danger" t-esc="error"/>
</t>
<div id="create-player-client-error" class="alert alert-danger d-none"></div>
<!-- Search Form -->
<div class="card mb-3">
<div class="card-body">
<form action="/my/player/create" method="get" class="row g-3">
<div class="col-md-4">
<label class="form-label">First Name</label>
<input type="text" name="first_name" class="form-control" t-att-value="first_name"/>
</div>
<div class="col-md-4">
<label class="form-label">Last Name</label>
<input type="text" name="last_name" class="form-control" t-att-value="last_name"/>
</div>
<div class="col-md-4">
<label class="form-label">Date of Birth</label>
<input type="date" name="date_of_birth" class="form-control" t-att-value="date_of_birth"/>
</div>
<div class="col-12 d-flex justify-content-end">
<button type="submit" class="btn bg-o-color-1 text-white"><i class="fa fa-search me-1"/> Search</button>
</div>
</form>
</div>
</div>
<!-- Search Results -->
<t t-if="searched">
<t t-set="has_active" t-value="len(active_results) &gt; 0"/>
<t t-set="has_archived" t-value="len(archived_results) &gt; 0"/>
<t t-if="has_active or has_archived">
<div class="row g-3">
<t t-if="has_active">
<div class="col-12">
<div class="card">
<div class="card-header bg-success text-white">Active Matches</div>
<ul class="list-group list-group-flush">
<t t-foreach="active_results" t-as="p">
<li class="list-group-item d-flex justify-content-between align-items-center">
<div>
<div class="fw-bold" t-esc="p['name']"/>
<small class="text-muted">DOB: <t t-esc="p['date_of_birth'] or '-'"/></small>
</div>
<a t-attf-href="/my/player?player_id={{ p['id'] }}" class="btn btn-outline-primary btn-sm">View</a>
</li>
</t>
</ul>
</div>
</div>
</t>
<t t-if="has_archived">
<div class="col-12">
<div class="card">
<div class="card-header bg-secondary text-white">Archived Matches</div>
<ul class="list-group list-group-flush">
<t t-foreach="archived_results" t-as="p">
<li class="list-group-item d-flex justify-content-between align-items-center">
<div>
<div class="fw-bold" t-esc="p['name']"/>
<small class="text-muted">DOB: <t t-esc="p['date_of_birth'] or '-'"/></small>
</div>
<a t-attf-href="/my/player?player_id={{ p['id'] }}" class="btn btn-outline-primary btn-sm">View</a>
</li>
</t>
</ul>
</div>
</div>
</t>
</div>
</t>
<!-- Only show create when no matches -->
<t t-if="not has_active and not has_archived">
<div class="alert alert-info">No matching players found. You can create a new one below.</div>
</t>
</t>
<!-- Create Form: shown only when searched and there are no matches -->
<t t-if="searched and not (len(active_results) or len(archived_results))">
<div class="card mt-3">
<div class="card-header">
<h5 class="mb-0">New Player Details</h5>
</div>
<div class="card-body">
<form t-attf-action="/my/player/create/save" method="post" id="create-player-form">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<!-- Reusable form body -->
<t t-set="prefill_team_id" t-value="None"/>
<t t-call="bemade_sports_clinic.portal_player_form_body"/>
<div class="row mt-3">
<div class="col-12 d-flex justify-content-end">
<button type="submit" class="btn bg-o-color-1 text-white"><i class="fa fa-save me-1"/> Create Player</button>
</div>
</div>
</form>
</div>
</div>
</t>
</div>
</div>
</div>
</t>
</template>
</odoo>

View file

@ -427,6 +427,13 @@
<t t-call="portal.portal_layout">
<h1>Your Players</h1>
<div class="d-flex justify-content-end mb-3">
<a t-if="request.env.user.has_group('bemade_sports_clinic.group_portal_treatment_professional')"
href="/my/player/create" class="btn bg-o-color-1 text-white">
<i class="fa fa-plus"/> Create Player
</a>
</div>
<!-- Filters -->
<div class="container-fluid mb-3">
<div class="row">
@ -635,7 +642,7 @@
<div class="col-md-3" t-if="player.stage">
<h6 class="mb-1">Status</h6>
<span t-if="player.stage == 'healthy'" class="text-success font-weight-bold h5">
<i class="fa fa-heart"></i> Play OK
<i class="fa fa-thumbs-up"></i> Play OK
</span>
<span t-elif="player.stage == 'practice_ok'" class="text-warning font-weight-bold h5">
<i class="fa fa-running"></i> Practice OK
@ -647,7 +654,7 @@
<i class="fa fa-question-circle"></i> <span t-esc="player.stage"/>
</span>
</div>
<div class="col-md-3" t-if="player.allergies">
<div class="col-md-3" t-if="(player.allergies or '').replace('\xa0','').strip()">
<h6 class="mb-1">Allergies</h6>
<div class="alert alert-warning py-2 mb-0">
<i class="fa fa-exclamation-triangle"></i>
@ -819,8 +826,8 @@
<td><strong>Last Consultation:</strong></td>
<td t-esc="player.last_consultation_date"/>
</tr>
<tr t-if="is_treatment_prof and player.allergies">
<td><strong>Allergies:</strong></td>
<tr t-if="is_treatment_prof and (player.allergies or '').replace('\xa0','').strip()">
<td><strong>Allergies:</strong></td>
<td>
<div class="alert alert-warning mb-0 p-2">
<i class="fa fa-exclamation-triangle"></i>
@ -937,7 +944,7 @@
<tr t-if="player.stage">
<td><strong>Status:</strong></td>
<td>
<span t-if="player.stage == 'healthy'" class="text-success font-weight-bold"><i class="fa fa-heart"></i> Play OK</span>
<span t-if="player.stage == 'healthy'" class="text-success font-weight-bold"><i class="fa fa-thumbs-up"></i> Play OK</span>
<span t-elif="player.stage == 'practice_ok'" class="text-warning font-weight-bold"><i class="fa fa-running"></i> Practice OK</span>
<span t-elif="player.stage == 'no_play'" class="text-danger font-weight-bold"><i class="fa fa-ban"></i> Injured</span>
<span t-else="" class="text-muted" t-esc="player.stage"/>
@ -1049,7 +1056,7 @@
<!-- Medical Information Tab (Treatment Professionals Only) -->
<div class="tab-pane fade" id="medical-info" role="tabpanel" aria-labelledby="medical-info-tab" t-if="is_treatment_prof">
<div class="row">
<div class="col-md-6" t-if="patient_info.get('allergies')">
<div class="col-md-6" t-if="(patient_info.get('allergies') or '').replace('\xa0','').strip()">
<div class="card border-warning">
<div class="card-header bg-warning text-dark">
<i class="fa fa-exclamation-triangle"></i> <strong>Allergies</strong>
@ -1069,7 +1076,7 @@
</div>
</div>
</div>
<div class="col-12" t-if="not patient_info.get('allergies') and not patient_info.get('team_info_notes')">
<div class="col-12" t-if="not (patient_info.get('allergies') or '').replace('\xa0','').strip() and not (patient_info.get('team_info_notes') or '').replace('\xa0','').strip()">
<div class="text-center text-muted py-4">
<i class="fa fa-notes-medical fa-3x mb-3"></i>
<p>No medical information available</p>
@ -1183,18 +1190,22 @@
</div>
<div class="card-body">
<!-- Success/Error Messages -->
<t t-if="error" class="alert alert-danger" role="alert">
<div t-if="error" class="alert alert-danger" role="alert">
<i class="fa fa-exclamation-triangle me-2"/>
<span t-esc="error"/>
</t>
<t t-if="success" class="alert alert-success" role="alert">
</div>
<div t-if="success" class="alert alert-success" role="alert">
<i class="fa fa-check-circle me-2"/>
<span t-esc="success"/>
</t>
</div>
<form t-attf-action="/my/team/{{ team.id }}/add_player/submit" method="post" class="mt-3">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<input type="hidden" name="team_id" t-att-value="team.id"/>
<!-- Player Details -->
<div class="mb-2">
<h5 class="mb-0">Player Details</h5>
</div>
<div class="mb-3">
<label for="first_name" class="form-label">First Name *</label>
@ -1226,6 +1237,27 @@
t-att-value="date_of_birth or ''" required="required"/>
</div>
<!-- Status fields (Treatment Professionals only) -->
<t t-if="user_has_group('bemade_sports_clinic.group_portal_treatment_professional') or user_has_group('base.group_system')">
<div class="row g-3 mb-3">
<div class="col-md-6">
<label for="match_status" class="form-label">Match Status</label>
<select class="form-select" id="match_status" name="match_status">
<option value="yes" t-att-selected="(match_status or '') == 'yes' and 'selected' or None">Yes</option>
<option value="no" t-att-selected="(match_status or '') == 'no' and 'selected' or None">No</option>
</select>
</div>
<div class="col-md-6">
<label for="practice_status" class="form-label">Practice Status</label>
<select class="form-select" id="practice_status" name="practice_status">
<option value="yes" t-att-selected="(practice_status or '') == 'yes' and 'selected' or None">Yes</option>
<option value="no_contact" t-att-selected="(practice_status or '') == 'no_contact' and 'selected' or None">Yes, no contact</option>
<option value="no" t-att-selected="(practice_status or '') == 'no' and 'selected' or None">No</option>
</select>
</div>
</div>
</t>
<div class="d-flex justify-content-between">
<a t-attf-href="/my/team?team_id={{ team.id }}" class="btn bg-o-color-2 text-white">
<i class="fa fa-times me-1"/> Cancel