From a47e829935eca7fc8d70c3aa0d1b49590aadd254 Mon Sep 17 00:00:00 2001 From: Denis Durepos Date: Mon, 1 Sep 2025 09:48:47 -0400 Subject: [PATCH] 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 --- .../controllers/player_management_portal.py | 546 ++++++++++++- .../controllers/team_management_portal.py | 286 ++++++- bemade_sports_clinic/models/patient.py | 21 +- .../player_management_portal_templates.xml | 725 +++++++++++------- .../views/sports_clinic_portal_views.xml | 54 +- 5 files changed, 1335 insertions(+), 297 deletions(-) diff --git a/bemade_sports_clinic/controllers/player_management_portal.py b/bemade_sports_clinic/controllers/player_management_portal.py index 576f951..5d5748b 100644 --- a/bemade_sports_clinic/controllers/player_management_portal.py +++ b/bemade_sports_clinic/controllers/player_management_portal.py @@ -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) diff --git a/bemade_sports_clinic/controllers/team_management_portal.py b/bemade_sports_clinic/controllers/team_management_portal.py index 93a0844..18cd3f3 100644 --- a/bemade_sports_clinic/controllers/team_management_portal.py +++ b/bemade_sports_clinic/controllers/team_management_portal.py @@ -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//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//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) diff --git a/bemade_sports_clinic/models/patient.py b/bemade_sports_clinic/models/patient.py index 64fe654..b24340a 100644 --- a/bemade_sports_clinic/models/patient.py +++ b/bemade_sports_clinic/models/patient.py @@ -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( diff --git a/bemade_sports_clinic/views/player_management_portal_templates.xml b/bemade_sports_clinic/views/player_management_portal_templates.xml index f7a6fac..0983645 100644 --- a/bemade_sports_clinic/views/player_management_portal_templates.xml +++ b/bemade_sports_clinic/views/player_management_portal_templates.xml @@ -1,5 +1,266 @@ + +