From 3ce8d051bca6b8260424413ec54bfd717c61a936 Mon Sep 17 00:00:00 2001 From: Denis Durepos Date: Wed, 13 Aug 2025 10:43:39 -0400 Subject: [PATCH] portal: enable player removal from Edit Player view; fix QWeb group checks - Replace nested remove form with a submit button using HTML5 formaction to post directly to /my/team/{team_id}/player/{player_id}/remove - Prevent outer save form from intercepting submission; preserves CSRF; confirmation retained; redirects to team page - Replace user_has_group helper with request.env.user.has_group in QWeb to prevent NoneType callable errors Verification: - Edit Player page renders without 500 error - Clicking Remove removes the player and returns 303 redirect to /my/team/{team_id} - Success notification displayed on team page Notes: - Removal route already implemented in controller (TeamManagementPortal.portal_remove_player) --- .../controllers/events_portal.py | 16 +- .../controllers/patient_injury_portal.py | 86 ++++++++- .../controllers/team_staff_portal.py | 18 +- .../models/injury_document.py | 37 +++- bemade_sports_clinic/models/patient.py | 29 +++ .../views/events_portal_templates.xml | 34 +++- .../injury_management_portal_templates.xml | 21 ++- .../player_management_portal_templates.xml | 76 ++++++++ .../views/portal_event_edit_template.xml | 26 ++- .../views/sports_clinic_portal_views.xml | 167 +++++++++++++----- .../views/sports_patient_injury_views.xml | 104 +++++++++++ .../views/sports_patient_views.xml | 75 +++++--- 12 files changed, 586 insertions(+), 103 deletions(-) diff --git a/bemade_sports_clinic/controllers/events_portal.py b/bemade_sports_clinic/controllers/events_portal.py index 340fd09..2921acc 100644 --- a/bemade_sports_clinic/controllers/events_portal.py +++ b/bemade_sports_clinic/controllers/events_portal.py @@ -92,7 +92,7 @@ class EventsPortal(CustomerPortal, AccessControlMixin): @http.route(['/my/events', '/my/events/page/'], type='http', auth='user', website=True) def view_events(self, page=1, view_type='all', team_id=None, organization_id=None, assigned_user_id=None, - date_from=None, date_to=None, sortby=None, search=None, **kw): + date_from=None, date_to=None, sortby=None, search=None, no_default_dates=None, **kw): """Main events view with filtering and pagination""" # Check access @@ -104,6 +104,17 @@ class EventsPortal(CustomerPortal, AccessControlMixin): if not (is_therapist or is_coach or user.has_group('base.group_system')): raise AccessError(_("You don't have access to events.")) + # Default date filter: from yesterday, similar to internal "Upcoming" behavior + # Only apply when user did not specify any date filters AND no explicit clear flag + if not date_from and not date_to and not no_default_dates: + try: + # Use date (not datetime) input format 'YYYY-MM-DD' for the portal date picker + yesterday = (fields.Date.today() - timedelta(days=1)) + date_from = fields.Date.to_string(yesterday) + except Exception: + # Fallback using datetime in rare cases + date_from = (datetime.today() - timedelta(days=1)).strftime('%Y-%m-%d') + # Prepare base domain domain = self._prepare_events_domain(view_type) @@ -167,7 +178,7 @@ class EventsPortal(CustomerPortal, AccessControlMixin): url='/my/events', url_args={'view_type': view_type, 'team_id': team_id, 'organization_id': organization_id, 'assigned_user_id': assigned_user_id, 'date_from': date_from, - 'date_to': date_to, 'sortby': sortby, 'search': search}, + 'date_to': date_to, 'sortby': sortby, 'search': search, 'no_default_dates': no_default_dates}, total=event_count, page=page, step=self._items_per_page, @@ -215,6 +226,7 @@ class EventsPortal(CustomerPortal, AccessControlMixin): 'organizations': organizations, 'treatment_professionals': treatment_professionals, 'can_edit': can_edit, + 'no_default_dates': no_default_dates, } return http.request.render('bemade_sports_clinic.portal_events_list', values) diff --git a/bemade_sports_clinic/controllers/patient_injury_portal.py b/bemade_sports_clinic/controllers/patient_injury_portal.py index a2d86ee..d03d678 100644 --- a/bemade_sports_clinic/controllers/patient_injury_portal.py +++ b/bemade_sports_clinic/controllers/patient_injury_portal.py @@ -556,9 +556,13 @@ class PatientInjuryPortal(CustomerPortal, AccessControlMixin): # Get user's role is_treatment_prof = request.env.user.has_group('bemade_sports_clinic.group_portal_treatment_professional') - # Document categories - categories = [('medical', 'Medical'), ('xray', 'X-Ray'), ('mri', 'MRI'), - ('prescription', 'Prescription'), ('other', 'Other')] + # Document categories (aligned with model) + categories = [ + ('medical', 'Medical'), + ('medical_imaging', 'Medical Imaging'), + ('prescription', 'Prescription'), + ('other', 'Other'), + ] values = { 'injury': injury, @@ -608,6 +612,7 @@ class PatientInjuryPortal(CustomerPortal, AccessControlMixin): # Create the document document = request.env['sports.injury.document'].sudo().create({ 'injury_id': int(injury_id), + 'patient_id': injury.patient_id.id, 'name': post.get('document_name', name), 'description': post.get('description', ''), 'category': post.get('category', 'other'), @@ -632,11 +637,14 @@ class PatientInjuryPortal(CustomerPortal, AccessControlMixin): raise request.not_found() try: - # Check access to the injury this document belongs to - injury = self._check_access_to_injury(document.injury_id.id) + # Prefer checking access via injury; if no injury, check via patient + if document.injury_id: + self._check_access_to_injury(document.injury_id.id) + else: + self._check_access_to_patient(document.patient_id.id) except UserError: raise request.not_found() - + # Return the file for download return request.make_response( base64.b64decode(document.file_content), @@ -645,6 +653,72 @@ class PatientInjuryPortal(CustomerPortal, AccessControlMixin): ('Content-Disposition', f'attachment; filename="{document.file_name}"'), ] ) + + @http.route(['/my/patient/document/download/'], type='http', auth='user') + def download_patient_document(self, document_id, **post): + """Download a document linked to a patient (injury optional).""" + document = request.env['sports.injury.document'].sudo().browse(int(document_id)) + if not document.exists(): + raise request.not_found() + try: + # Access check based on patient (primary link) + self._check_access_to_patient(document.patient_id.id) + except UserError: + raise request.not_found() + return request.make_response( + base64.b64decode(document.file_content), + headers=[ + ('Content-Type', 'application/octet-stream'), + ('Content-Disposition', f'attachment; filename="{document.file_name}"'), + ] + ) + + @http.route(['/my/patient/document/upload'], type='http', auth='user', website=True, methods=['POST']) + def upload_patient_document(self, **post): + """Upload a document directly to a patient (injury optional).""" + patient_id = post.get('patient_id') + if not patient_id: + return request.redirect('/my/players') + + try: + patient = self._check_access_to_patient(patient_id) + except UserError as e: + return request.render('http_routing.http_error', { + 'status_code': 403, + 'status_message': 'Forbidden', + 'error_message': str(e) + }) + + attachment = post.get('attachment') + if not attachment: + return request.redirect(f'/my/player?player_id={patient.id}&error=no_file') + + try: + name = attachment.filename + file_content = attachment.read() + file_size = len(file_content) + + # 10MB limit + if file_size > 10 * 1024 * 1024: + return request.redirect(f'/my/player?player_id={patient.id}&error=file_too_large') + + # Create patient-linked document (injury optional) + request.env['sports.injury.document'].sudo().create({ + 'patient_id': patient.id, + 'injury_id': int(post['injury_id']) if post.get('injury_id') else False, + 'name': post.get('document_name', name), + 'description': post.get('description', ''), + 'category': post.get('category', 'other'), + 'file_content': base64.b64encode(file_content), + 'file_name': name, + 'created_by_id': request.env.user.id, + }) + + return request.redirect(f'/my/player?player_id={patient.id}&success=document_uploaded') + + except Exception as e: + _logger.error(f"Error uploading patient document: {e}") + return request.redirect(f'/my/player?player_id={patient.id}&error=upload_failed') @http.route(['/my/injury/document/delete/'], type='http', auth='user', website=True) def delete_injury_document(self, document_id, **post): diff --git a/bemade_sports_clinic/controllers/team_staff_portal.py b/bemade_sports_clinic/controllers/team_staff_portal.py index d4a5230..2b313f6 100644 --- a/bemade_sports_clinic/controllers/team_staff_portal.py +++ b/bemade_sports_clinic/controllers/team_staff_portal.py @@ -159,13 +159,25 @@ class TeamStaffPortal(CustomerPortal): - # Show all injuries to treatment professionals, but only active ones to coaches if is_treatment_prof: injuries = player.injury_ids else: injuries = player.injury_ids.filtered(lambda r: r.stage == 'active') - + + # Patient documents for Documents tab (primary association now on patient) + patient_documents = http.request.env['sports.injury.document'].search([ + ('patient_id', '=', player.id) + ], order='create_date desc, id desc') + + # Categories for patient document uploads + categories = [ + ('medical', 'Medical'), + ('medical_imaging', 'Medical Imaging'), + ('prescription', 'Prescription'), + ('other', 'Other'), + ] + # Create patient_info dictionary for protected fields (when user is a treatment professional) # No need for sudo() now that we have proper field-level access rights patient_info = {} @@ -179,6 +191,8 @@ class TeamStaffPortal(CustomerPortal): qcontext={ 'player': player, 'injuries': injuries, + 'patient_documents': patient_documents, + 'categories': categories, 'team': team, 'page_name': 'my_player', 'is_treatment_prof': is_treatment_prof, diff --git a/bemade_sports_clinic/models/injury_document.py b/bemade_sports_clinic/models/injury_document.py index 1331a8e..44c21f7 100644 --- a/bemade_sports_clinic/models/injury_document.py +++ b/bemade_sports_clinic/models/injury_document.py @@ -9,22 +9,49 @@ class InjuryDocument(models.Model): _order = 'create_date desc, id desc' name = fields.Char(string='Name', required=True) - injury_id = fields.Many2one('sports.patient.injury', string='Injury', required=True, ondelete='cascade') - patient_id = fields.Many2one('sports.patient', string='Patient', related='injury_id.patient_id', store=True) + # Refactor: documents are tied to a patient with optional injury linkage + patient_id = fields.Many2one('sports.patient', string='Patient', required=True, ondelete='cascade', index=True) + injury_id = fields.Many2one('sports.patient.injury', string='Injury', required=False, ondelete='cascade', index=True) description = fields.Text(string='Description') file_content = fields.Binary(string='File Content', required=True, attachment=False) file_name = fields.Char(string='File Name') file_size = fields.Integer(string='File Size', compute='_compute_file_size', store=True) + # Note: 'xray' and 'mri' kept for backward compatibility; displayed as 'Medical Imaging' category = fields.Selection([ - ('medical', 'Medical Report'), - ('xray', 'X-Ray'), - ('mri', 'MRI'), + ('medical', 'Medical'), + ('medical_imaging', 'Medical Imaging'), + ('xray', 'Medical Imaging'), # deprecated + ('mri', 'Medical Imaging'), # deprecated ('prescription', 'Prescription'), ('other', 'Other'), ], string='Category', default='other', required=True) created_by_id = fields.Many2one('res.users', string='Uploaded By', default=lambda self: self.env.user, required=True) create_date = fields.Datetime(string='Upload Date') + @api.model_create_multi + def create(self, vals_list): + """Ensure backward compatibility: if an injury is provided but patient is missing, + set patient_id from the injury's patient.""" + for vals in vals_list: + if not vals.get('patient_id') and vals.get('injury_id'): + injury = self.env['sports.patient.injury'].browse(vals['injury_id']) + vals['patient_id'] = injury.patient_id.id + return super().create(vals_list) + + @api.onchange('injury_id') + def _onchange_injury_id_sync_patient(self): + """When selecting an injury, auto-set patient to the injury's patient.""" + for rec in self: + if rec.injury_id: + rec.patient_id = rec.injury_id.patient_id + + @api.constrains('injury_id', 'patient_id') + def _check_injury_belongs_to_patient(self): + """Ensure the selected injury belongs to the chosen patient.""" + for rec in self: + if rec.injury_id and rec.patient_id and rec.injury_id.patient_id != rec.patient_id: + raise ValidationError(_('The injury must belong to the selected patient.')) + @api.depends('file_content') def _compute_file_size(self): """Compute the file size in bytes""" diff --git a/bemade_sports_clinic/models/patient.py b/bemade_sports_clinic/models/patient.py index 5f92ef2..2001012 100644 --- a/bemade_sports_clinic/models/patient.py +++ b/bemade_sports_clinic/models/patient.py @@ -138,6 +138,16 @@ class Patient(models.Model): last_consultation_date = fields.Date(tracking=True) active_injury_count = fields.Integer(compute="_compute_active_injury_count") activity_count = fields.Integer(compute="_compute_activity_count") + # Documents linked to this patient (optionally to an injury) + document_ids = fields.One2many( + comodel_name="sports.injury.document", + inverse_name="patient_id", + string="Documents", + ) + document_count = fields.Integer( + compute="_compute_document_count", + string="Document Count", + ) allergies = fields.Text( groups="bemade_sports_clinic.group_sports_clinic_treatment_professional,bemade_sports_clinic.group_portal_treatment_professional", ) @@ -287,6 +297,25 @@ class Patient(models.Model): ('res_id', '=', rec.id) ]) + def _compute_document_count(self): + for patient in self: + patient.document_count = self.env['sports.injury.document'].search_count([ + ('patient_id', '=', patient.id) + ]) + + def action_view_documents(self): + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'name': _('Documents'), + 'res_model': 'sports.injury.document', + 'view_mode': 'list,form', + 'domain': [('patient_id', '=', self.id)], + 'context': { + 'default_patient_id': self.id, + }, + } + def action_view_patient_form(self): self.ensure_one() return { diff --git a/bemade_sports_clinic/views/events_portal_templates.xml b/bemade_sports_clinic/views/events_portal_templates.xml index d987c74..6e2cef2 100644 --- a/bemade_sports_clinic/views/events_portal_templates.xml +++ b/bemade_sports_clinic/views/events_portal_templates.xml @@ -29,26 +29,26 @@ @@ -101,18 +101,33 @@
+
- +
+ + +
- +
+ + +
@@ -199,7 +214,8 @@ -
+
+
diff --git a/bemade_sports_clinic/views/injury_management_portal_templates.xml b/bemade_sports_clinic/views/injury_management_portal_templates.xml index 937bfae..a2e28eb 100644 --- a/bemade_sports_clinic/views/injury_management_portal_templates.xml +++ b/bemade_sports_clinic/views/injury_management_portal_templates.xml @@ -581,17 +581,16 @@ - - Medical - X-Ray - MRI - Prescription - Other - - - - - + + Medical + Medical Imaging + Prescription + Other + + + + + Download diff --git a/bemade_sports_clinic/views/player_management_portal_templates.xml b/bemade_sports_clinic/views/player_management_portal_templates.xml index c6f37f8..5b2e3db 100644 --- a/bemade_sports_clinic/views/player_management_portal_templates.xml +++ b/bemade_sports_clinic/views/player_management_portal_templates.xml @@ -28,6 +28,82 @@
+ +
+
+
Team Membership
+
+ + + + + + + + + + + + + + + +
TeamActions
+ + + + + + + + + + + + + + + + +
+
+
+
diff --git a/bemade_sports_clinic/views/portal_event_edit_template.xml b/bemade_sports_clinic/views/portal_event_edit_template.xml index f00f4ee..7e53653 100644 --- a/bemade_sports_clinic/views/portal_event_edit_template.xml +++ b/bemade_sports_clinic/views/portal_event_edit_template.xml @@ -138,10 +138,30 @@
- - + + + + +
+
+
+ You can paste formatted text. The formatted content will be saved.
+
diff --git a/bemade_sports_clinic/views/sports_clinic_portal_views.xml b/bemade_sports_clinic/views/sports_clinic_portal_views.xml index a180787..95cca0a 100644 --- a/bemade_sports_clinic/views/sports_clinic_portal_views.xml +++ b/bemade_sports_clinic/views/sports_clinic_portal_views.xml @@ -7,24 +7,26 @@ Teams /my/teams - + teams_count Players /my/players - + players_count Activities /my/activities - + activities_count - - Events - /my/events - - + + + Events + /my/events + events_count + +
@@ -92,46 +94,24 @@ @@ -561,6 +541,14 @@ +
@@ -811,7 +799,7 @@
Team Notes
-

+

@@ -819,6 +807,95 @@
+ +
+ +
+
+ Upload Document +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + Max size 10MB +
+
+ +
+
+
+
+
+
+ + + + + + Name + Category + Description + Created + By + Actions + + + + + + + + + + + + + Download + + + + + + + +
+ +

No documents uploaded

+
+
+
diff --git a/bemade_sports_clinic/views/sports_patient_injury_views.xml b/bemade_sports_clinic/views/sports_patient_injury_views.xml index c6e4082..7e6bf9c 100644 --- a/bemade_sports_clinic/views/sports_patient_injury_views.xml +++ b/bemade_sports_clinic/views/sports_patient_injury_views.xml @@ -1,5 +1,72 @@ + + + sports.injury.document.list + sports.injury.document + + + + + + + + + + + + + + + sports.injury.document.form + sports.injury.document + +
+ + + + + + + + + + + + + + + + + + +