diff --git a/bemade_sports_clinic/PROJECT_TASK_PORTAL_SECURITY.md b/bemade_sports_clinic/PROJECT_TASK_PORTAL_SECURITY.md new file mode 100644 index 0000000..ad58408 --- /dev/null +++ b/bemade_sports_clinic/PROJECT_TASK_PORTAL_SECURITY.md @@ -0,0 +1,200 @@ +# Project Task Portal Security Architecture + +## Overview +This document outlines the comprehensive security implementation for portal treatment professionals accessing project.task objects (events) in the bemade_sports_clinic module. + +## Security Principles Applied + +### 1. **Explicit Authorization Only** +- **NO** blanket access based on `privacy_visibility = 'portal'` +- **YES** explicit authorization through follower relationships or team membership +- **Principle**: Users must be explicitly granted access, not implicitly through project settings + +### 2. **Multi-Layer Security Architecture** +1. **ACL Level**: Model-level CRUD permissions +2. **Record Rule Level**: Record-level filtering based on explicit relationships +3. **Field Level**: Field-level group access controls +4. **Controller Level**: Additional business logic validation + +## Implementation Details + +### Access Control Lists (ACLs) +**File**: `security/ir.model.access.csv` + +```csv +# Portal Treatment Professionals get read/write/create access to project tasks +access_project_task_portal_tp,Portal TP Access for Project Tasks,project.model_project_task,bemade_sports_clinic.group_portal_treatment_professional,1,1,1,0 + +# Portal Treatment Professionals get read/write access to projects (for follower management) +access_project_project_portal_tp,Portal TP Access for Projects,project.model_project_project,bemade_sports_clinic.group_portal_treatment_professional,1,1,0,0 + +# Read-only access to supporting models +access_project_task_type_portal_tp,Portal TP Access for Task Types,project.model_project_task_type,bemade_sports_clinic.group_portal_treatment_professional,1,0,0,0 +access_project_tags_portal_tp,Portal TP Access for Project Tags,project.model_project_tags,bemade_sports_clinic.group_portal_treatment_professional,1,0,0,0 +access_project_milestone_portal_tp,Portal TP Access for Project Milestones,project.model_project_milestone,bemade_sports_clinic.group_portal_treatment_professional,1,0,0,0 +``` + +### Record Rules +**File**: `security/project_task_portal_rules.xml` + +#### Project Task Access Rule +```xml +[ + '|', '|', '|', + # Tasks assigned to the user + ('user_ids', 'in', [user.id]), + # Tasks where user is a follower + ('message_partner_ids', 'in', [user.partner_id.id]), + # Tasks from projects where user is a follower + ('project_id.message_partner_ids', 'in', [user.partner_id.id]), + # Tasks from projects where user's teams are partners + ('project_id.partner_id', 'in', user.partner_id.team_staff_rel_ids.mapped('team_id.id') or [0]) +] +``` + +#### Project Access Rule +```xml +[ + '|', + # Projects where user is explicitly a follower + ('message_partner_ids', 'in', [user.partner_id.id]), + # Projects where user's teams are partners + ('partner_id', 'in', user.partner_id.team_staff_rel_ids.mapped('team_id.id') or [0]) +] +``` + +### Field-Level Security +**Files**: `models/project_task.py` and `models/project_project.py` + +All critical fields are overridden with explicit group access for authorized sports clinic users only: +```python +# SECURE: Only authorized sports clinic portal users, not all portal users +_portal_groups = 'base.group_user,bemade_sports_clinic.group_portal_treatment_professional,bemade_sports_clinic.group_portal_team_coach' + +# Core fields +name = fields.Char(groups=_portal_groups) +description = fields.Html(groups=_portal_groups) +user_ids = fields.Many2many(groups=_portal_groups) +project_id = fields.Many2one(groups=_portal_groups) +# ... and many more +``` + +### Controller Security +**File**: `controllers/events_portal.py` + +#### Secure Domain Construction +```python +def _prepare_events_domain(self, view_type='all'): + # Base domain: tasks from projects where user has explicit authorization + base_domain = [ + '|', '|', + # Tasks from projects where user's teams are partners + ('project_id.partner_id', 'in', team_ids or [0]), + # Tasks from projects where user is a follower + ('project_id.message_partner_ids', 'in', [partner.id]), + # Tasks where user is directly assigned or following + '|', + ('user_ids', 'in', [user.id]), + ('message_partner_ids', 'in', [partner.id]) + ] +``` + +#### Project Filtering +```python +def _get_available_projects(self): + # Only show projects where user has explicit authorization + domain = [ + '|', + # Projects where user's teams are partners + ('partner_id', 'in', team_ids or [0]), + # Projects where user is explicitly a follower + ('message_partner_ids', 'in', [partner.id]) + ] +``` + +## Security Validation + +### Model-Level Access Checking +```python +def check_portal_task_access(self): + # 1. Check user is assigned to task + # 2. Check user is follower of task + # 3. Check user is follower of project + # 4. Check user's teams are partners of project + # NO blanket portal visibility check +``` + +### Project Configuration +```python +def ensure_portal_access_for_treatment_professionals(self): + # Only add treatment professionals who are staff on the team + # NO blanket addition of all portal users +``` + +## Critical Security Fixes Applied + +### ❌ **BEFORE (Vulnerable)** +```python +# SECURITY FLAW: Any portal user could access any portal-visible project +domain = [ + '|', '|', + ('partner_id', 'in', team_ids), + ('message_partner_ids', 'in', [partner.id]), + ('privacy_visibility', '=', 'portal') # ← VULNERABILITY +] +``` + +### ✅ **AFTER (Secure)** +```python +# SECURE: Only explicitly authorized users can access projects +domain = [ + '|', + ('partner_id', 'in', team_ids or [0]), # Team relationship + ('message_partner_ids', 'in', [partner.id]) # Explicit follower +] +``` + +## Testing and Validation + +### Security Test Utility +**File**: `models/project_task_security_test.py` +- Admin UI for testing field access +- Validates all security layers +- Tests unauthorized access scenarios + +### Comprehensive Test Suite +**File**: `tests/test_project_task_portal_security.py` +- 10 test cases covering all security aspects +- Validates proper access isolation +- Tests record rule enforcement + +## Best Practices Established + +1. **Explicit Authorization Required**: Never grant access based solely on project privacy settings +2. **Follower-Based Security**: Use message_partner_ids for explicit access control +3. **Team-Based Authorization**: Link project access to sports team relationships +4. **Layered Security**: Multiple security layers working together +5. **Principle of Least Privilege**: Grant minimum necessary permissions + +## Common Security Anti-Patterns Avoided + +1. **❌ Portal Visibility OR Condition**: `('privacy_visibility', '=', 'portal')` as standalone OR +2. **❌ Blanket Group Access**: Adding all portal users as followers +3. **❌ Overly Broad Domains**: Using `[(1, '=', 1)]` or similar catch-all domains +4. **❌ Missing Fallbacks**: Not using `or [0]` for empty list protection + +## Deployment Checklist + +- [ ] Update module to install new security files +- [ ] Run security tests to validate implementation +- [ ] Verify portal users can only access authorized projects/tasks +- [ ] Test that unauthorized users are properly denied access +- [ ] Validate field-level access works correctly in portal UI + +## Maintenance Guidelines + +1. **Always use explicit authorization** when creating new access rules +2. **Test security boundaries** whenever adding new portal functionality +3. **Document security decisions** for future developers +4. **Regular security audits** of domain logic and record rules +5. **Follow the established patterns** for consistent security implementation diff --git a/bemade_sports_clinic/__init__.py b/bemade_sports_clinic/__init__.py index f7209b1..b6de227 100644 --- a/bemade_sports_clinic/__init__.py +++ b/bemade_sports_clinic/__init__.py @@ -1,2 +1,3 @@ from . import models from . import controllers +from . import wizards diff --git a/bemade_sports_clinic/__manifest__.py b/bemade_sports_clinic/__manifest__.py index 02e696d..957db62 100644 --- a/bemade_sports_clinic/__manifest__.py +++ b/bemade_sports_clinic/__manifest__.py @@ -18,14 +18,14 @@ # { 'name': 'Sports Clinic Management', - 'version': '18.0.1.9.0', - 'summary': 'Manage the patients of a sports medicine clinic.', + 'version': '18.0.2.0.0', + 'summary': 'Comprehensive sports medicine clinic management with portal access and activity tracking.', 'description': """ Sports Clinic Management System ============================= A comprehensive solution for managing sports medicine clinics, focusing on player health, - injury tracking, and team collaboration. + injury tracking, team collaboration, and integrated activity management. Key Features: ------------ @@ -34,50 +34,78 @@ - Internal clinic staff with full patient record access - Treatment professionals with medical record access - Portal access for field therapists and team coaches + - Automated group assignment based on team roles 2. Player Management: - - Track player details and contact information + - Track player details and contact information with address management - Monitor team memberships and playing status - Record and track injuries and treatment history - Track match and practice availability + - Emergency contacts management with mobile numbers + - Canadian address validation (provinces/territories) 3. Injury Tracking: - Comprehensive injury recording and documentation - - Treatment professional assignment + - Treatment professional assignment and parental consent tracking - Progress tracking and resolution monitoring - Internal and external notes for different audiences + - Document attachment support with portal access + - Injury status workflow (Unverified → Active → Resolved) 4. Team Management: - - Organize players into teams - - Assign coaching and medical staff + - Organize players into teams with staff assignments + - Assign coaching and medical staff with automatic portal access - Team-specific player status tracking + - Player removal workflow with approval process + - Treatment notes management across team members - 5. Portal Access: + 5. Activity Management (NEW): + - Integrated mail.activity system for task management + - Portal access to activities for treatment professionals + - Activity creation, completion, and reassignment + - Team-based activity filtering and access control + - Activity counts and navigation throughout portal + - Activity detail views with attachment support + + 6. Portal Access: - Coaches can view their teams and player status - Field therapists can access and update medical records - Injury reporting directly through the portal - - Coaches can request player removal with reason - - Treatment professionals can approve/process removal requests - - Visual indicators for pending removal requests - - Emergency contacts management for treatment professionals - - Treatment notes management for players with or without injuries + - Comprehensive activity management interface + - Player removal requests with reason tracking + - Emergency contacts and address management + - Document upload and download capabilities + - Messages and attachments portal access - 6. Security and Privacy: - - Granular permission system + 7. Security and Privacy: + - Layered security architecture (ACL + Record Rules + Controller filtering) + - Team-based access control throughout the system - Field-level security for sensitive information - Audit trails for all changes - GDPR and Quebec Law 25 compliance features - Configurable data retention policies + - RPC security protection with buddy method pattern - 7. Data Protection: + 8. Data Protection: - Scheduled data anonymization - Configurable retention periods - Audit logging of all data handling - Manual anonymization wizard - This module is designed to facilitate communication between medical professionals, - coaching staff, and administrative personnel while maintaining appropriate access - controls and data privacy. + 9. Localization: + - Full French Canadian (fr_CA) translation support + - Canadian-specific address and province handling + - Localized date and number formatting + + 10. Integration Features: + - Mail system integration for notifications + - Project task integration for event management + - Task-to-event conversion wizard + - Comprehensive demo data for testing + + This module provides a complete sports medicine clinic management solution with + robust portal access, activity tracking, and team collaboration features while + maintaining strict security and data privacy controls. """, "category": "Services/Medical", "author": "Bemade Inc.", @@ -88,6 +116,7 @@ "portal", "contacts", "phone_validation", # For phone number formatting in patient contacts + "project", # Required for project.task (Events) functionality ], "external_dependencies": { "python": [ @@ -102,9 +131,12 @@ "security/sports_clinic_rules.xml", "security/sports_clinic_portal_rules.xml", "security/mail_activity_portal_rules.xml", + "security/project_task_portal_rules.xml", + "security/sports_event_rules.xml", "security/partner_access.xml", "data/sports_clinic_data.xml", "data/admin_access_data.xml", + # "data/project_portal_demo_data.xml", # Temporarily disabled for clean upgrade "data/cron_actions.xml", "views/sports_team_views.xml", "views/sports_clinic_menus.xml", @@ -115,12 +147,19 @@ "views/player_management_portal_templates.xml", "views/injury_management_portal_templates.xml", "views/task_management_portal_templates.xml", + "views/events_portal_templates.xml", + "views/project_task_views.xml", + "views/project_task_security_test_views.xml", + "views/sports_event_views.xml", "views/portal_activity_detail_template.xml", "views/portal_messages_template.xml", "views/portal_attachments_template.xml", + "views/portal_event_detail_template.xml", + "views/portal_event_edit_template.xml", "views/treatment_note_views.xml", "views/res_partner_views.xml", "views/res_users_views.xml", + "views/task_to_event_wizard_views.xml", ], "demo": ["data/demo/sports_clinic_demo_data.xml"], "installable": True, diff --git a/bemade_sports_clinic/controllers/__init__.py b/bemade_sports_clinic/controllers/__init__.py index 3f2e40f..74f7307 100644 --- a/bemade_sports_clinic/controllers/__init__.py +++ b/bemade_sports_clinic/controllers/__init__.py @@ -4,3 +4,4 @@ from . import patient_injury_portal from . import team_management_portal from . import player_management_portal from . import task_management_portal +from . import events_portal diff --git a/bemade_sports_clinic/controllers/events_portal.py b/bemade_sports_clinic/controllers/events_portal.py new file mode 100644 index 0000000..340fd09 --- /dev/null +++ b/bemade_sports_clinic/controllers/events_portal.py @@ -0,0 +1,437 @@ +from odoo.addons.portal.controllers.portal import CustomerPortal, pager +from odoo import http, _, fields +from odoo.exceptions import UserError, AccessError +from datetime import datetime, timedelta +from .access_control_mixin import AccessControlMixin +import logging + +_logger = logging.getLogger(__name__) + + +class EventsPortal(CustomerPortal, AccessControlMixin): + + def _prepare_home_portal_values(self, counters): + """Add events count to portal home""" + rtn = super()._prepare_home_portal_values(counters) + if 'events_count' in counters: + events_domain = self._prepare_events_domain() + rtn['events_count'] = http.request.env['sports.event'].search_count(events_domain) + return rtn + + def _prepare_events_domain(self, view_type='all'): + """Prepare domain for sports events based on user access""" + user = http.request.env.user + partner = user.partner_id + + # Check if user is therapist (can see all events) or coach (only their teams) + is_therapist = user.has_group('bemade_sports_clinic.group_portal_treatment_professional') or \ + user.has_group('bemade_sports_clinic.group_sports_clinic_treatment_professional') + is_coach = user.has_group('bemade_sports_clinic.group_portal_team_coach') + + if is_therapist: + # Therapists can see all events + base_domain = [] + elif is_coach: + # Coaches can only see events for teams they are staff on + team_staff_rels = partner.team_staff_rel_ids + team_ids = team_staff_rels.mapped('team_id.id') + base_domain = [('team_id', 'in', team_ids or [0])] + else: + # No access for other users + base_domain = [('id', '=', 0)] # No results + + # Add view-specific filters + if view_type == 'my': + # My Events: assigned to current user + base_domain.append(('assigned_staff_ids', 'in', [user.id])) + elif view_type == 'unassigned': + # Unassigned Events: no assigned staff + base_domain.append(('assigned_staff_ids', '=', False)) + # 'all' view uses base domain only + + return base_domain + + def _get_treatment_professionals(self): + """Get all treatment professionals from team staff with relevant roles""" + # Search for users who are on team staff with therapist, head therapist, or doctor roles + team_staff_users = http.request.env['sports.team.staff'].search([ + ('role', 'in', ['therapist', 'head_therapist', 'doctor', 'treatment_professional']) + ]).mapped('partner_id.user_ids') + + # Filter active users and sort by name + active_users = team_staff_users.filtered(lambda u: u.active) + + _logger.info(f"Found {len(active_users)} treatment professionals from team staff: {[u.name for u in active_users]}") + return active_users.sorted('name') + + def _get_accessible_teams(self): + """Get teams accessible to current user""" + user = http.request.env.user + partner = user.partner_id + + # Check if user is therapist (can see all teams) or coach (only their teams) + is_therapist = user.has_group('bemade_sports_clinic.group_portal_treatment_professional') or \ + user.has_group('bemade_sports_clinic.group_sports_clinic_treatment_professional') + + if is_therapist: + # Therapists can see all teams + teams = http.request.env['sports.team'].search([]) + else: + # Coaches can only see teams they are staff on + team_staff_rels = partner.team_staff_rel_ids + team_ids = team_staff_rels.mapped('team_id.id') + teams = http.request.env['sports.team'].browse(team_ids) + + return teams.sorted('name') + + def _get_organizations(self): + """Get organizations (parent partners of teams)""" + teams = self._get_accessible_teams() + organizations = teams.mapped('parent_id').filtered(lambda p: p) + return organizations.sorted('name') + + @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): + """Main events view with filtering and pagination""" + + # Check access + user = http.request.env.user + is_therapist = user.has_group('bemade_sports_clinic.group_portal_treatment_professional') or \ + user.has_group('bemade_sports_clinic.group_sports_clinic_treatment_professional') + is_coach = user.has_group('bemade_sports_clinic.group_portal_team_coach') + + if not (is_therapist or is_coach or user.has_group('base.group_system')): + raise AccessError(_("You don't have access to events.")) + + # Prepare base domain + domain = self._prepare_events_domain(view_type) + + # Apply additional filters + if team_id: + domain.append(('team_id', '=', int(team_id))) + + if organization_id: + org_id = int(organization_id) + domain.append(('partner_id', '=', org_id)) + # Debug: Log organization filter + import logging + _logger = logging.getLogger(__name__) + _logger.info(f"Organization filter applied: partner_id = {org_id}") + + if assigned_user_id: + domain.append(('assigned_staff_ids', 'in', [int(assigned_user_id)])) + + if date_from: + try: + date_from_dt = fields.Datetime.from_string(date_from) + domain.append(('date_start', '>=', date_from_dt)) + except ValueError: + pass + + if date_to: + try: + date_to_dt = fields.Datetime.from_string(date_to) + domain.append(('date_start', '<=', date_to_dt)) + except ValueError: + pass + + if search: + domain.extend([ + '|', '|', '|', + ('name', 'ilike', search), + ('description', 'ilike', search), + ('team_id.name', 'ilike', search), + ('venue_id.name', 'ilike', search) + ]) + + # Sorting options - default to date ascending as requested + sort_options = { + 'date': 'date_start asc', + 'date_desc': 'date_start desc', + 'name': 'name', + 'team': 'team_id', + 'assigned': 'assigned_staff_ids', + } + order = sort_options.get(sortby, 'date_start asc') # Default ascending by date + + # Debug: Log final domain and count + import logging + _logger = logging.getLogger(__name__) + _logger.info(f"Final events domain: {domain}") + + # Count and pagination + event_count = http.request.env['sports.event'].search_count(domain) + _logger.info(f"Event count with domain: {event_count}") + pager_values = pager( + 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}, + total=event_count, + page=page, + step=self._items_per_page, + ) + + # Get events + events = http.request.env['sports.event'].search( + domain, + order=order, + limit=self._items_per_page, + offset=pager_values['offset'] + ) + + # Get filter options + teams = self._get_accessible_teams() + organizations = self._get_organizations() + treatment_professionals = self._get_treatment_professionals() + + # Debug: Log filter options + _logger.info(f"Available organizations: {[(org.id, org.name) for org in organizations]}") + _logger.info(f"Received organization_id parameter: {organization_id}") + + # Debug: Check sample events and their partner_id values + sample_events = http.request.env['sports.event'].search([], limit=5) + for event in sample_events: + _logger.info(f"Event '{event.name}': team={event.team_id.name if event.team_id else None}, partner_id={event.partner_id.name if event.partner_id else None}") + + # Check if user can edit events (only therapists) + can_edit = is_therapist + + values = { + 'events': events, + 'page_name': 'events', + 'pager': pager_values, + 'default_url': '/my/events', + 'view_type': view_type, + 'team_id': int(team_id) if team_id else None, + 'organization_id': int(organization_id) if organization_id else None, + 'assigned_user_id': int(assigned_user_id) if assigned_user_id else None, + 'date_from': date_from, + 'date_to': date_to, + 'sortby': sortby, + 'search': search, + 'teams': teams, + 'organizations': organizations, + 'treatment_professionals': treatment_professionals, + 'can_edit': can_edit, + } + + return http.request.render('bemade_sports_clinic.portal_events_list', values) + + @http.route(['/my/event/'], type='http', auth='user', website=True) + def view_event_detail(self, event_id, **kw): + """View individual event detail""" + + # Check access + user = http.request.env.user + is_therapist = user.has_group('bemade_sports_clinic.group_portal_treatment_professional') or \ + user.has_group('bemade_sports_clinic.group_sports_clinic_treatment_professional') + is_coach = user.has_group('bemade_sports_clinic.group_portal_team_coach') + + if not (is_therapist or is_coach or user.has_group('base.group_system')): + raise AccessError(_("You don't have access to events.")) + + # Get the event + event = http.request.env['sports.event'].browse(event_id) + if not event.exists(): + return http.request.not_found() + + # Check team access for coaches + if is_coach and not is_therapist: + partner = user.partner_id + team_staff_rels = partner.team_staff_rel_ids + accessible_team_ids = team_staff_rels.mapped('team_id.id') + if event.team_id.id not in accessible_team_ids: + raise AccessError(_("You don't have access to this event.")) + + # Check if user can edit (only therapists) + can_edit = is_therapist + + values = { + 'event': event, + 'page_name': 'event_detail', + 'can_edit': can_edit, + } + + return http.request.render('bemade_sports_clinic.portal_event_detail', values) + + @http.route(['/my/event//edit'], type='http', auth='user', website=True) + def edit_event_form(self, event_id, **kw): + """Edit event form - only accessible to therapists""" + + # Check access - only therapists can edit + user = http.request.env.user + is_therapist = user.has_group('bemade_sports_clinic.group_portal_treatment_professional') or \ + user.has_group('bemade_sports_clinic.group_sports_clinic_treatment_professional') + + if not (is_therapist or user.has_group('base.group_system')): + raise AccessError(_("You don't have permission to edit events.")) + + # Get the event + event = http.request.env['sports.event'].browse(event_id) + if not event.exists(): + return http.request.not_found() + + # Get filter options for form + teams = http.request.env['sports.team'].search([]) + treatment_professionals = self._get_treatment_professionals() + venues = http.request.env['res.partner'].search([('is_venue', '=', True)]) + + values = { + 'event': event, + 'teams': teams, + 'treatment_professionals': treatment_professionals, + 'venues': venues, + 'page_name': 'event_edit', + } + + return http.request.render('bemade_sports_clinic.portal_event_edit', values) + + @http.route(['/my/event//save'], type='http', auth='user', website=True, methods=['POST'], csrf=False) + def save_event(self, event_id, **post): + """Save event changes - only accessible to therapists""" + + # Debug: Log all POST data + _logger.info(f"Full POST data received: {dict(post)}") + _logger.info(f"POST keys: {list(post.keys())}") + + # Check access - only therapists can edit + user = http.request.env.user + is_therapist = user.has_group('bemade_sports_clinic.group_portal_treatment_professional') or \ + user.has_group('bemade_sports_clinic.group_sports_clinic_treatment_professional') + + if not (is_therapist or user.has_group('base.group_system')): + raise AccessError(_("You don't have permission to edit events.")) + + # Get the event + event = http.request.env['sports.event'].browse(event_id) + if not event.exists(): + return http.request.not_found() + + try: + # Update event fields + update_vals = {} + + if 'name' in post: + update_vals['name'] = post['name'] + if 'description' in post: + update_vals['description'] = post['description'] + if 'team_id' in post and post['team_id']: + update_vals['team_id'] = int(post['team_id']) + if 'venue_id' in post and post['venue_id']: + update_vals['venue_id'] = int(post['venue_id']) + if 'event_type' in post: + update_vals['event_type'] = post['event_type'] + if 'state' in post: + update_vals['state'] = post['state'] + if 'date_start' in post and post['date_start']: + # Parse datetime from HTML datetime-local format (ISO format) + date_str = post['date_start'] + try: + if 'T' in date_str: + # ISO format: 2025-05-26T12:15 + update_vals['date_start'] = datetime.strptime(date_str, '%Y-%m-%dT%H:%M') + else: + # Standard format: 2025-05-26 12:15 + update_vals['date_start'] = datetime.strptime(date_str, '%Y-%m-%d %H:%M') + except ValueError as ve: + # Try alternative formats + try: + # Try with seconds + if 'T' in date_str: + update_vals['date_start'] = datetime.strptime(date_str, '%Y-%m-%dT%H:%M:%S') + else: + update_vals['date_start'] = datetime.strptime(date_str, '%Y-%m-%d %H:%M:%S') + except ValueError: + # Last fallback: let Odoo handle it + update_vals['date_start'] = date_str + + if 'date_end' in post and post['date_end']: + date_str = post['date_end'] + try: + if 'T' in date_str: + update_vals['date_end'] = datetime.strptime(date_str, '%Y-%m-%dT%H:%M') + else: + update_vals['date_end'] = datetime.strptime(date_str, '%Y-%m-%d %H:%M') + except ValueError: + try: + if 'T' in date_str: + update_vals['date_end'] = datetime.strptime(date_str, '%Y-%m-%dT%H:%M:%S') + else: + update_vals['date_end'] = datetime.strptime(date_str, '%Y-%m-%d %H:%M:%S') + except ValueError: + update_vals['date_end'] = date_str + + if 'therapist_start' in post and post['therapist_start']: + date_str = post['therapist_start'] + try: + if 'T' in date_str: + update_vals['therapist_start'] = datetime.strptime(date_str, '%Y-%m-%dT%H:%M') + else: + update_vals['therapist_start'] = datetime.strptime(date_str, '%Y-%m-%d %H:%M') + except ValueError: + try: + if 'T' in date_str: + update_vals['therapist_start'] = datetime.strptime(date_str, '%Y-%m-%dT%H:%M:%S') + else: + update_vals['therapist_start'] = datetime.strptime(date_str, '%Y-%m-%d %H:%M:%S') + except ValueError: + update_vals['therapist_start'] = date_str + + if 'therapist_end' in post and post['therapist_end']: + date_str = post['therapist_end'] + try: + if 'T' in date_str: + update_vals['therapist_end'] = datetime.strptime(date_str, '%Y-%m-%dT%H:%M') + else: + update_vals['therapist_end'] = datetime.strptime(date_str, '%Y-%m-%d %H:%M') + except ValueError: + try: + if 'T' in date_str: + update_vals['therapist_end'] = datetime.strptime(date_str, '%Y-%m-%dT%H:%M:%S') + else: + update_vals['therapist_end'] = datetime.strptime(date_str, '%Y-%m-%d %H:%M:%S') + except ValueError: + update_vals['therapist_end'] = date_str + + # Handle assigned staff (many2many) + # Check for both array-style and regular parameter names + staff_param = None + if 'assigned_staff_ids' in post: + staff_param = post['assigned_staff_ids'] + param_name = 'assigned_staff_ids' + elif 'assigned_staff_ids[]' in post: + staff_param = post['assigned_staff_ids[]'] + param_name = 'assigned_staff_ids[]' + + if staff_param is not None: + _logger.info(f"Raw {param_name} from form: {staff_param} (type: {type(staff_param)})") + staff_ids = [] + if isinstance(staff_param, list): + # Handle list of values + staff_ids = [int(x) for x in staff_param if x] + elif staff_param: + # Handle single value or comma-separated string + if ',' in str(staff_param): + # Comma-separated values from JavaScript workaround + staff_ids = [int(x.strip()) for x in str(staff_param).split(',') if x.strip()] + else: + # Single value + staff_ids = [int(staff_param)] + _logger.info(f"Processed staff_ids: {staff_ids}") + update_vals['assigned_staff_ids'] = [(6, 0, staff_ids)] + else: + _logger.info("No assigned_staff_ids parameter in post data") + # If no staff selected, clear the field + update_vals['assigned_staff_ids'] = [(6, 0, [])] + + # Update the event + event.write(update_vals) + + return http.request.redirect(f'/my/event/{event_id}?success=1') + + except Exception as e: + # Clean error message to prevent redirect issues with newlines + error_msg = str(e).replace('\n', ' ').replace('\r', ' ') + return http.request.redirect(f'/my/event/{event_id}/edit?error={error_msg}') diff --git a/bemade_sports_clinic/controllers/patient_injury_portal.py b/bemade_sports_clinic/controllers/patient_injury_portal.py index 9fb39a9..a2d86ee 100644 --- a/bemade_sports_clinic/controllers/patient_injury_portal.py +++ b/bemade_sports_clinic/controllers/patient_injury_portal.py @@ -38,11 +38,25 @@ class PatientInjuryPortal(CustomerPortal, AccessControlMixin): is_treatment_prof = request.env.user.has_group('bemade_sports_clinic.group_portal_treatment_professional') user = request.env.user + # Get treatment professionals for the multi-select field (if treatment professional) + treatment_professionals = [] + parental_consent_options = None + if is_treatment_prof: + # Include both portal and internal treatment professionals + portal_tp_group = request.env.ref('bemade_sports_clinic.group_portal_treatment_professional') + internal_tp_group = request.env.ref('bemade_sports_clinic.group_sports_clinic_treatment_professional') + treatment_professionals = request.env['res.users'].search([ + ('groups_id', 'in', [portal_tp_group.id, internal_tp_group.id]) + ]) + parental_consent_options = request.env['sports.patient.injury']._fields['parental_consent'].selection + values = { 'patient': patient, 'return_url': return_url, 'page_name': 'report_injury', 'is_treatment_prof': is_treatment_prof, # Pass flag to template for conditional display + 'treatment_professionals': treatment_professionals, + 'parental_consent_options': parental_consent_options, } return request.render('bemade_sports_clinic.portal_create_injury', values) @@ -76,11 +90,18 @@ class PatientInjuryPortal(CustomerPortal, AccessControlMixin): vals = { 'patient_id': patient.id, 'diagnosis': post.get('diagnosis', ''), - 'injury_date': post.get('injury_date'), 'external_notes': post.get('external_notes', ''), 'stage': 'active' if is_treatment_prof else 'unverified', } + # Handle injury date and injury_date_na checkbox + if post.get('injury_date_na'): + vals['injury_date_na'] = True + vals['injury_date'] = False # Clear injury_date if N/A is checked + elif post.get('injury_date'): + vals['injury_date'] = post.get('injury_date') + vals['injury_date_na'] = False + # Only add team_id if we have a single team for the patient if team_id: vals['team_id'] = int(team_id) @@ -92,6 +113,10 @@ class PatientInjuryPortal(CustomerPortal, AccessControlMixin): if post.get('predicted_resolution_date'): vals['predicted_resolution_date'] = post.get('predicted_resolution_date') + # Handle internal notes for treatment professionals + if is_treatment_prof and post.get('internal_notes'): + vals['internal_notes'] = post.get('internal_notes') + # Create the injury record - portal users now have create permission injury = request.env['sports.patient.injury'].create(vals) @@ -103,12 +128,26 @@ class PatientInjuryPortal(CustomerPortal, AccessControlMixin): # Assign treatment professionals based on user role - # If user is a treatment professional, add them to the treatment professionals - # Only check group membership, not computed field + # Handle treatment professional assignments + treatment_prof_ids = [] + + # If user is a treatment professional, add them by default if is_treatment_prof: - # Add current user as treatment professional + treatment_prof_ids.append(user.id) + + # Also add any additional treatment professionals selected in the form (checkbox-based) + selected_tp_ids = request.httprequest.form.getlist('treatment_professional_ids[]') + if selected_tp_ids: + # Convert to integers and add to list (avoiding duplicates) + for tp_id in selected_tp_ids: + tp_id_int = int(tp_id) + if tp_id_int not in treatment_prof_ids: + treatment_prof_ids.append(tp_id_int) + + # Assign treatment professionals if any were identified + if treatment_prof_ids: injury.write({ - 'treatment_professional_ids': [(4, user.id)] + 'treatment_professional_ids': [(6, 0, treatment_prof_ids)] }) else: # User is not a treatment professional @@ -180,6 +219,21 @@ class PatientInjuryPortal(CustomerPortal, AccessControlMixin): if not treatment_pros_assigned: _logger.warning("No valid therapists found to assign to the injury") + # Handle treatment note creation if provided by treatment professional + if is_treatment_prof and post.get('treatment_note'): + treatment_note_text = post.get('treatment_note').strip() + if treatment_note_text: + try: + # Create treatment note using the injury's _add_treatment_note method + injury._add_treatment_note( + patient=injury.patient_id, + note=treatment_note_text, + user=request.env.user + ) + _logger.info(f"Treatment note added to injury {injury.id} by user {request.env.user.id}") + except Exception as e: + _logger.error(f"Failed to create treatment note for injury {injury.id}: {str(e)}") + # Trigger recomputation of patient status based on the injury patient._compute_is_injured() patient._compute_stage() @@ -220,9 +274,11 @@ class PatientInjuryPortal(CustomerPortal, AccessControlMixin): stage_selection = request.env['sports.patient.injury']._fields['stage'].selection stages = [(k, v) for k, v in stage_selection] - # Get treatment professionals for the multi-select field + # Get treatment professionals for the multi-select field (both portal and internal) + portal_tp_group = request.env.ref('bemade_sports_clinic.group_portal_treatment_professional') + internal_tp_group = request.env.ref('bemade_sports_clinic.group_sports_clinic_treatment_professional') treatment_professionals = request.env['res.users'].search([ - ('groups_id', 'in', request.env.ref('bemade_sports_clinic.group_portal_treatment_professional').id) + ('groups_id', 'in', [portal_tp_group.id, internal_tp_group.id]) ]) # Get parental consent options if treatment professional @@ -293,18 +349,15 @@ class PatientInjuryPortal(CustomerPortal, AccessControlMixin): if post.get('resolution_date'): vals['resolution_date'] = post.get('resolution_date') - # Handle treatment professionals (multi-select) - if post.get('treatment_professional_ids'): - # Convert to list if it's a single value - prof_ids = post.get('treatment_professional_ids') - if isinstance(prof_ids, str): - prof_ids = [prof_ids] - elif not isinstance(prof_ids, list): - prof_ids = [prof_ids] - + # Handle treatment professionals (checkbox-based multi-select) + selected_tp_ids = request.httprequest.form.getlist('treatment_professional_ids[]') + if selected_tp_ids: # Convert to integers and set using Odoo's many2many syntax - prof_ids = [int(pid) for pid in prof_ids if pid] + prof_ids = [int(pid) for pid in selected_tp_ids if pid] vals['treatment_professional_ids'] = [(6, 0, prof_ids)] + else: + # If no checkboxes are selected, clear the treatment professionals + vals['treatment_professional_ids'] = [(6, 0, [])] # Fields only treatment professionals can update if is_treatment_prof: @@ -639,3 +692,41 @@ class PatientInjuryPortal(CustomerPortal, AccessControlMixin): except Exception as e: _logger.error(f"Error verifying injury: {e}") return request.redirect('/my') + + @http.route(['/my/injury/delete'], type='http', auth='user', website=True, methods=['POST']) + def delete_injury(self, **post): + """Delete an injury record (only for treatment professionals)""" + injury_id = post.get('injury_id') + return_url = post.get('return_url', '/my/players') + + if not injury_id: + return request.redirect(return_url) + + try: + injury = self._check_access_to_injury(injury_id) + except UserError as e: + return request.render('http_routing.http_error', { + 'status_code': 403, + 'status_message': 'Forbidden', + 'error_message': str(e) + }) + + # Check if user is a treatment professional (only they can delete injuries) + is_treatment_prof = request.env.user.has_group('bemade_sports_clinic.group_portal_treatment_professional') + if not is_treatment_prof: + return request.redirect(f'{return_url}?error=permission_denied') + + # Get patient info before deletion for redirect + patient_id = injury.patient_id.id + + try: + # Delete the injury record (this will cascade delete related records) + injury.sudo().unlink() + _logger.info(f"Injury {injury_id} deleted by user {request.env.user.id}") + + # Redirect back to player page with success message + return request.redirect(f'/my/player?player_id={patient_id}&success=injury_deleted') + + except Exception as e: + _logger.error(f"Error deleting injury {injury_id}: {str(e)}") + return request.redirect(f'{return_url}?error=delete_failed') diff --git a/bemade_sports_clinic/controllers/team_staff_portal.py b/bemade_sports_clinic/controllers/team_staff_portal.py index 4a32169..d4a5230 100644 --- a/bemade_sports_clinic/controllers/team_staff_portal.py +++ b/bemade_sports_clinic/controllers/team_staff_portal.py @@ -9,11 +9,14 @@ class TeamStaffPortal(CustomerPortal): teams_domain = self._prepare_teams_domain() players_domain = self._prepare_players_domain(teams_domain) activities_domain = self._prepare_activities_domain() + events_domain = self._prepare_events_domain() rtn['teams_count'] = http.request.env['sports.team'].search_count(teams_domain) rtn['players_count'] = http.request.env['sports.patient'].search_count( players_domain) rtn['activities_count'] = http.request.env['mail.activity'].search_count( activities_domain) + rtn['events_count'] = http.request.env['sports.event'].search_count( + events_domain) return rtn @classmethod @@ -55,6 +58,29 @@ class TeamStaffPortal(CustomerPortal): ('res_id', 'in', team_staff_rels.mapped('team_id.id') or [0]) ] + @classmethod + def _prepare_events_domain(cls): + """Prepare domain for sports events based on user access""" + user = http.request.env.user + partner = user.partner_id + + # Check if user is therapist (can see all events) or coach (only their teams) + is_therapist = user.has_group('bemade_sports_clinic.group_portal_treatment_professional') or \ + user.has_group('bemade_sports_clinic.group_sports_clinic_treatment_professional') + is_coach = user.has_group('bemade_sports_clinic.group_portal_team_coach') + + if is_therapist: + # Therapists can see all events + return [] + elif is_coach: + # Coaches can only see events for teams they are staff on + team_staff_rels = partner.team_staff_rel_ids + team_ids = team_staff_rels.mapped('team_id.id') + return [('team_id', 'in', team_ids or [0])] + else: + # No access for other users + return [('id', '=', 0)] # No results + @http.route(route=['/my/teams', '/my/teams/page/'], type='http', auth='user', website=True) def view_teams(self, page=0, **kw): """ Display the list of teams that a portal user has access to """ diff --git a/bemade_sports_clinic/data/project_portal_demo_data.xml.disabled b/bemade_sports_clinic/data/project_portal_demo_data.xml.disabled new file mode 100644 index 0000000..0c78ecc --- /dev/null +++ b/bemade_sports_clinic/data/project_portal_demo_data.xml.disabled @@ -0,0 +1,60 @@ + + + + + + + Sports Clinic Events + Default project for sports clinic events and activities + True + portal + + + + + + + Carabins Team Events + Events and activities for Carabins team + True + portal + + + + + + + Team Meeting - Injury Prevention + Monthly team meeting to discuss injury prevention strategies + + + + + 1 + + + + Pre-Season Medical Checkups + Conduct medical checkups for all team members before season start + + + + + 2 + + + + + project.project + + + + + + project.project + + + + + + diff --git a/bemade_sports_clinic/models/__init__.py b/bemade_sports_clinic/models/__init__.py index 0ef760e..ce890dc 100644 --- a/bemade_sports_clinic/models/__init__.py +++ b/bemade_sports_clinic/models/__init__.py @@ -3,7 +3,11 @@ from . import patient from . import patient_injury from . import patient_contact from . import res_partner -from . import sports_team from . import res_users +from . import sports_team from . import treatment_note from . import injury_document +from . import project_task +from . import project_task_security_test +from . import project_project +from . import sports_event diff --git a/bemade_sports_clinic/models/patient_injury.py b/bemade_sports_clinic/models/patient_injury.py index 44a58be..527b894 100644 --- a/bemade_sports_clinic/models/patient_injury.py +++ b/bemade_sports_clinic/models/patient_injury.py @@ -62,10 +62,7 @@ class PatientInjury(models.Model): column1="patient_injury_id", column2="treatment_pro_id", string="Treatment Professionals", - domain=lambda self: [('groups_id', 'in', [ - self.env.ref('bemade_sports_clinic.group_sports_clinic_treatment_professional').id, - self.env.ref('bemade_sports_clinic.group_portal_treatment_professional').id - ])], + tracking=True, ) predicted_resolution_date = fields.Date(tracking=True) diff --git a/bemade_sports_clinic/models/project_project.py b/bemade_sports_clinic/models/project_project.py new file mode 100644 index 0000000..3996c04 --- /dev/null +++ b/bemade_sports_clinic/models/project_project.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- + +from odoo import api, fields, models, _ + + +class ProjectProject(models.Model): + _inherit = 'project.project' + + # Portal access group definition for reuse - only authorized sports clinic users + _portal_groups = 'base.group_user,bemade_sports_clinic.group_portal_treatment_professional,bemade_sports_clinic.group_portal_team_coach' + + # Override core fields to grant portal access + name = fields.Char(groups=_portal_groups) + description = fields.Html(groups=_portal_groups) + partner_id = fields.Many2one(groups=_portal_groups) + user_id = fields.Many2one(groups=_portal_groups) + privacy_visibility = fields.Selection(groups=_portal_groups) + is_favorite = fields.Boolean(groups=_portal_groups) + color = fields.Integer(groups=_portal_groups) + stage_id = fields.Many2one(groups=_portal_groups) + + # Sports clinic specific fields + is_sports_clinic_project = fields.Boolean( + string='Sports Clinic Project', + default=False, + help='Mark this project as related to sports clinic activities', + groups=_portal_groups + ) + + related_team_ids = fields.Many2many( + 'sports.team', + string='Related Teams', + help='Teams associated with this project', + groups=_portal_groups + ) + + @api.model + def create_sports_clinic_project(self, name, team_id=None, description=None): + """Create a project specifically for sports clinic events""" + vals = { + 'name': name, + 'is_sports_clinic_project': True, + 'privacy_visibility': 'portal', # Allow portal access + 'description': description or f'Project for sports clinic events: {name}', + } + + if team_id: + vals['partner_id'] = team_id # Set team as project partner + + project = self.create(vals) + + # Only add authorized treatment professionals as followers + if team_id: + project.ensure_portal_access_for_treatment_professionals() + + return project + + def ensure_portal_access_for_treatment_professionals(self): + """Ensure authorized treatment professionals have access to this project""" + self.ensure_one() + + # Only add treatment professionals who have team relationships with this project + if self.partner_id: # Project must have a partner (team) + # Find treatment professionals who are staff on this team + team_staff = self.env['sports.team.staff'].search([ + ('team_id', '=', self.partner_id.id), + ('role', 'in', ['therapist', 'head_therapist']) + ]) + + authorized_partners = team_staff.mapped('partner_id') + current_followers = self.message_partner_ids + new_followers = authorized_partners - current_followers + + if new_followers: + self.message_subscribe(partner_ids=new_followers.ids) + + return True + + @api.model + def get_or_create_default_sports_project(self): + """Get or create a default project for sports clinic events""" + default_project = self.search([ + ('is_sports_clinic_project', '=', True), + ('name', '=', 'Sports Clinic Events') + ], limit=1) + + if not default_project: + default_project = self.create_sports_clinic_project( + name='Sports Clinic Events', + description='Default project for sports clinic events and activities' + ) + + # Ensure portal access is configured + default_project.ensure_portal_access_for_treatment_professionals() + + return default_project diff --git a/bemade_sports_clinic/models/project_task.py b/bemade_sports_clinic/models/project_task.py new file mode 100644 index 0000000..917bf03 --- /dev/null +++ b/bemade_sports_clinic/models/project_task.py @@ -0,0 +1,77 @@ +from odoo import api, fields, models +from odoo.exceptions import AccessError + + +class ProjectTask(models.Model): + _inherit = 'project.task' + + # Portal access group definition for reuse - only authorized sports clinic users + _portal_groups = 'base.group_user,bemade_sports_clinic.group_portal_treatment_professional,bemade_sports_clinic.group_portal_team_coach' + + # Override core fields to grant portal access + name = fields.Char(groups=_portal_groups) + description = fields.Html(groups=_portal_groups) + user_ids = fields.Many2many(groups=_portal_groups) + project_id = fields.Many2one(groups=_portal_groups) + stage_id = fields.Many2one(groups=_portal_groups) + tag_ids = fields.Many2many(groups=_portal_groups) + partner_id = fields.Many2one(groups=_portal_groups) + date_deadline = fields.Datetime(groups=_portal_groups) + priority = fields.Selection(groups=_portal_groups) + sequence = fields.Integer(groups=_portal_groups) + state = fields.Selection(groups=_portal_groups) + is_closed = fields.Boolean(groups=_portal_groups) + task_properties = fields.Properties(groups=_portal_groups) + + date_start = fields.Datetime( + string='Starting Date', + groups=_portal_groups + ) + + date_end = fields.Datetime( + string='Ending Date', + groups=_portal_groups + ) + + + + @api.model + def check_portal_access_rights(self, operation, raise_exception=True): + """Check if portal treatment professional has access to perform operation""" + if self.env.user.has_group('bemade_sports_clinic.group_portal_treatment_professional'): + allowed_operations = ['read', 'write', 'create'] + if operation in allowed_operations: + return True + + if raise_exception: + raise AccessError(f"Portal treatment professionals cannot perform {operation} operation") + return False + + def check_portal_task_access(self): + """Check if current user can access this specific task""" + if not self.env.user.has_group('bemade_sports_clinic.group_portal_treatment_professional'): + return False + + user = self.env.user + partner = user.partner_id + + # Check if user is assigned to task + if user in self.user_ids: + return True + + # Check if user is follower of task + if partner in self.message_partner_ids: + return True + + # Check if user is follower of project + if self.project_id and partner in self.project_id.message_partner_ids: + return True + + # Check if user's teams are partners of the project + if self.project_id: + team_staff_rels = partner.team_staff_rel_ids + team_ids = team_staff_rels.mapped('team_id.id') + if self.project_id.partner_id.id in team_ids: + return True + + return False diff --git a/bemade_sports_clinic/models/project_task_security_test.py b/bemade_sports_clinic/models/project_task_security_test.py new file mode 100644 index 0000000..4520498 --- /dev/null +++ b/bemade_sports_clinic/models/project_task_security_test.py @@ -0,0 +1,136 @@ +# -*- coding: utf-8 -*- + +from odoo import api, fields, models, _ +from odoo.exceptions import AccessError +import logging + +_logger = logging.getLogger(__name__) + + +class ProjectTaskSecurityTest(models.TransientModel): + _name = 'project.task.security.test' + _description = 'Project Task Security Testing Utility' + + task_id = fields.Many2one('project.task', string='Task to Test', required=True) + user_id = fields.Many2one('res.users', string='User to Test', required=True) + test_results = fields.Text(string='Test Results', readonly=True) + + def action_test_field_access(self): + """Test field access for the specified user and task""" + self.ensure_one() + + # List of critical fields to test + fields_to_test = [ + 'name', 'description', 'user_ids', 'project_id', 'stage_id', + 'tag_ids', 'partner_id', 'date_deadline', 'priority', 'sequence', + 'state', 'is_closed', 'date_start', 'date_end', + 'date_event_start', 'date_event_end' + ] + + results = [] + results.append(f"Testing field access for user: {self.user_id.name}") + results.append(f"Testing task: {self.task_id.name}") + results.append(f"User groups: {', '.join(self.user_id.groups_id.mapped('name'))}") + results.append("=" * 50) + + # Test as the specified user + task_as_user = self.task_id.sudo(self.user_id) + + for field_name in fields_to_test: + try: + # Attempt to read the field + field_value = getattr(task_as_user, field_name) + results.append(f"✅ {field_name}: ACCESSIBLE") + + # Test field groups if defined + field_obj = self.env['project.task']._fields.get(field_name) + if field_obj and hasattr(field_obj, 'groups') and field_obj.groups: + results.append(f" Groups: {field_obj.groups}") + + except AccessError as e: + results.append(f"❌ {field_name}: ACCESS DENIED - {str(e)}") + except Exception as e: + results.append(f"⚠️ {field_name}: ERROR - {str(e)}") + + results.append("=" * 50) + + # Test model-level access + try: + task_as_user.check_access_rights('read') + results.append("✅ Model READ access: GRANTED") + except AccessError: + results.append("❌ Model READ access: DENIED") + + try: + task_as_user.check_access_rights('write') + results.append("✅ Model WRITE access: GRANTED") + except AccessError: + results.append("❌ Model WRITE access: DENIED") + + try: + task_as_user.check_access_rights('create') + results.append("✅ Model CREATE access: GRANTED") + except AccessError: + results.append("❌ Model CREATE access: DENIED") + + # Test record-level access + try: + task_as_user.check_access_rule('read') + results.append("✅ Record READ access: GRANTED") + except AccessError: + results.append("❌ Record READ access: DENIED") + + try: + task_as_user.check_access_rule('write') + results.append("✅ Record WRITE access: GRANTED") + except AccessError: + results.append("❌ Record WRITE access: DENIED") + + # Test portal-specific access method + if hasattr(task_as_user, 'check_portal_task_access'): + try: + portal_access = task_as_user.check_portal_task_access() + results.append(f"✅ Portal task access: {'GRANTED' if portal_access else 'DENIED'}") + except Exception as e: + results.append(f"⚠️ Portal task access: ERROR - {str(e)}") + + self.test_results = '\n'.join(results) + + return { + 'type': 'ir.actions.act_window', + 'res_model': 'project.task.security.test', + 'res_id': self.id, + 'view_mode': 'form', + 'target': 'new', + } + + @api.model + def quick_test_portal_access(self, task_id=None, user_login=None): + """Quick test method for debugging - can be called from shell""" + if not task_id: + # Find any task + task = self.env['project.task'].search([], limit=1) + if not task: + return "No tasks found to test" + task_id = task.id + + if not user_login: + # Find a portal treatment professional + portal_group = self.env.ref('bemade_sports_clinic.group_portal_treatment_professional') + portal_users = portal_group.users + if not portal_users: + return "No portal treatment professionals found" + user_login = portal_users[0].login + + user = self.env['res.users'].search([('login', '=', user_login)], limit=1) + if not user: + return f"User {user_login} not found" + + # Create test record and run test + test_record = self.create({ + 'task_id': task_id, + 'user_id': user.id + }) + + test_record.action_test_field_access() + return test_record.test_results diff --git a/bemade_sports_clinic/models/res_partner.py b/bemade_sports_clinic/models/res_partner.py index 7698b7d..85a17a6 100644 --- a/bemade_sports_clinic/models/res_partner.py +++ b/bemade_sports_clinic/models/res_partner.py @@ -26,6 +26,13 @@ class Partner(models.Model): patient_ids = fields.One2many( comodel_name="sports.patient", inverse_name="partner_id" ) + + # Boolean field to identify venues + is_venue = fields.Boolean( + string='Is Venue', + default=False, + help='Check this box if this contact represents a venue/location' + ) def write(self, vals): if ( diff --git a/bemade_sports_clinic/models/sports_event.py b/bemade_sports_clinic/models/sports_event.py new file mode 100644 index 0000000..1bb573f --- /dev/null +++ b/bemade_sports_clinic/models/sports_event.py @@ -0,0 +1,407 @@ +from odoo import api, fields, models +from odoo.exceptions import ValidationError + + +class SportsEvent(models.Model): + _name = 'sports.event' + _description = 'Sports Event' + _inherit = ['mail.thread', 'mail.activity.mixin'] + _order = 'date_start desc, name' + _rec_name = 'name' + + # Portal access group definition - only authorized portal users + _portal_groups = 'base.group_user,bemade_sports_clinic.group_portal_treatment_professional,bemade_sports_clinic.group_portal_team_coach' + + # ======================================== + # CORE EVENT FIELDS (Portal Accessible) + # ======================================== + + name = fields.Char( + string='Event Name', + required=True, + tracking=True, + groups=_portal_groups, + help='Name of the sports event' + ) + + description = fields.Html( + string='Description', + groups=_portal_groups, + help='Detailed description of the event' + ) + + # Event timing + date_start = fields.Datetime( + string='Event Start Time', + required=True, + tracking=True, + index=True, + groups=_portal_groups, + help='When the event starts' + ) + + date_end = fields.Datetime( + string='Event End Time', + required=True, + tracking=True, + index=True, + groups=_portal_groups, + help='When the event ends' + ) + + # Therapist coverage timing (independent fields that sync with task) + therapist_start = fields.Datetime( + string='Therapist Start Time', + tracking=True, + index=True, + groups=_portal_groups, + help='When therapist coverage begins (may be before event start for preparation)' + ) + + therapist_end = fields.Datetime( + string='Therapist End Time', + tracking=True, + index=True, + groups=_portal_groups, + help='When therapist coverage ends (may be after event end for cleanup)' + ) + + # Event details + venue_id = fields.Many2one( + 'res.partner', + string='Venue', + domain=[('is_venue', '=', True)], + groups=_portal_groups, + help='Venue/location where the event takes place' + ) + + event_type = fields.Selection([ + ('game', 'Game'), + ('practice', 'Practice'), + ('training', 'Training'), + ('meeting', 'Team Meeting'), + ('other', 'Other') + ], string='Event Type', default='game', groups=_portal_groups, tracking=True) + + # Status and priority + state = fields.Selection([ + ('draft', 'Draft'), + ('confirmed', 'Confirmed'), + ('in_progress', 'In Progress'), + ('completed', 'Completed'), + ('cancelled', 'Cancelled') + ], string='Status', default='draft', tracking=True, groups=_portal_groups) + + # ======================================== + # RELATIONSHIPS (Portal Accessible) + # ======================================== + + team_id = fields.Many2one( + 'sports.team', + string='Team', + required=True, + tracking=True, + groups=_portal_groups, + help='The sports team this event is for' + ) + + # Staff assignments + assigned_staff_ids = fields.Many2many( + 'res.users', + 'sports_event_staff_rel', + 'event_id', + 'user_id', + string='Assigned Staff', + groups=_portal_groups, + help='Treatment professionals assigned to this event' + ) + + # ======================================== + # TASK INTEGRATION (Internal Management) + # ======================================== + + task_id = fields.Many2one( + 'project.task', + string='Management Task', + ondelete='set null', + groups='base.group_user', + help='Internal project task for managing this event' + ) + + project_id = fields.Many2one( + 'project.project', + string='Project', + groups='base.group_user', + help='Project this event belongs to' + ) + + # ======================================== + # COMPUTED FIELDS + # ======================================== + + partner_id = fields.Many2one( + 'res.partner', + string='Organization', + compute='_compute_partner_id', + store=True, + groups=_portal_groups, + help='Parent organization (computed from team)' + ) + + duration = fields.Float( + string='Event Duration (Hours)', + compute='_compute_duration', + store=True, + groups=_portal_groups, + help='Event duration in hours' + ) + + therapist_duration = fields.Float( + string='Therapist Coverage Duration (Hours)', + compute='_compute_therapist_duration', + store=True, + groups=_portal_groups, + help='Therapist coverage duration in hours' + ) + + is_today = fields.Boolean( + string='Is Today', + compute='_compute_is_today', + help='Whether the event is happening today' + ) + + is_upcoming = fields.Boolean( + string='Is Upcoming', + compute='_compute_is_upcoming', + help='Whether the event is in the future' + ) + + # ======================================== + # COMPUTED METHODS + # ======================================== + + @api.depends('team_id', 'team_id.parent_id') + def _compute_partner_id(self): + """Compute partner/organization from team""" + for event in self: + event.partner_id = event.team_id.parent_id if event.team_id else False + + def action_recompute_partner_ids(self): + """Recompute partner_id for all events (for fixing organization filter)""" + all_events = self.search([]) + all_events._compute_partner_id() + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'message': f'Recomputed partner_id for {len(all_events)} events', + 'type': 'success', + } + } + + @api.depends('date_start', 'date_end') + def _compute_duration(self): + """Calculate event duration in hours""" + for event in self: + if event.date_start and event.date_end: + delta = event.date_end - event.date_start + event.duration = delta.total_seconds() / 3600.0 + else: + event.duration = 0.0 + + @api.depends('therapist_start', 'therapist_end') + def _compute_therapist_duration(self): + """Calculate therapist coverage duration in hours""" + for event in self: + if event.therapist_start and event.therapist_end: + delta = event.therapist_end - event.therapist_start + event.therapist_duration = delta.total_seconds() / 3600.0 + else: + event.therapist_duration = 0.0 + + @api.depends('date_start') + def _compute_is_today(self): + """Check if event is today""" + from datetime import date + today = date.today() + for event in self: + if event.date_start: + event.is_today = event.date_start.date() == today + else: + event.is_today = False + + @api.depends('date_start') + def _compute_is_upcoming(self): + """Check if event is in the future""" + from datetime import datetime + now = datetime.now() + for event in self: + if event.date_start: + event.is_upcoming = event.date_start > now + else: + event.is_upcoming = False + + # ======================================== + # ONCHANGE METHODS + # ======================================== + + @api.onchange('date_start', 'date_end') + def _onchange_event_dates(self): + """Set default therapist times when event dates change""" + if self.date_start and self.date_end: + # Only set therapist times if they are currently blank + if not self.therapist_start: + # Set therapist start to 30 minutes before event start + from datetime import timedelta + self.therapist_start = self.date_start - timedelta(minutes=30) + + if not self.therapist_end: + # Set therapist end to event end time + self.therapist_end = self.date_end + + # ======================================== + # VALIDATION + # ======================================== + + @api.constrains('date_start', 'date_end') + def _check_event_dates(self): + """Validate that end date is after start date""" + for event in self: + if event.date_start and event.date_end: + if event.date_end <= event.date_start: + raise ValidationError("Event end time must be after start time.") + + @api.constrains('therapist_start', 'therapist_end') + def _check_therapist_dates(self): + """Validate that therapist end time is after start time""" + for event in self: + if event.therapist_start and event.therapist_end: + if event.therapist_end <= event.therapist_start: + raise ValidationError("Therapist end time must be after start time.") + + # ======================================== + # TASK INTEGRATION METHODS + # ======================================== + + def create_management_task(self): + """Create a project task for internal management of this event""" + self.ensure_one() + if self.task_id: + return self.task_id + + # Find or create a project for the team + project = self._get_or_create_team_project() + + task_vals = { + 'name': f"Event: {self.name}", + 'description': self.description or '', + 'project_id': project.id, + 'date_deadline': self.date_end, + 'user_ids': [(6, 0, self.assigned_staff_ids.ids)], + 'partner_id': self.partner_id.id if self.partner_id else False, + } + + task = self.env['project.task'].create(task_vals) + self.task_id = task.id + self.project_id = project.id + + return task + + def _get_or_create_team_project(self): + """Get or create a project for the organization (one project per partner for billing)""" + if self.project_id: + return self.project_id + + # Get the organization (partner) from the team + organization = self.team_id.parent_id if self.team_id else False + if not organization: + raise ValidationError("Team must have a parent organization to create events.") + + # Look for existing organization project (one project per partner) + project = self.env['project.project'].search([ + ('partner_id', '=', organization.id), + ], limit=1) + + if not project: + # Create new project for the organization + project = self.env['project.project'].create({ + 'name': f"{organization.name} - Sports Events", + 'partner_id': organization.id, + 'privacy_visibility': 'portal', + 'description': f"Event management for {organization.name} sports teams" + }) + + return project + + # ======================================== + # PORTAL ACCESS METHODS + # ======================================== + + def check_portal_access(self, user=None): + """Check if user has portal access to this event""" + if not user: + user = self.env.user + + # Internal users have full access + if user.has_group('base.group_user'): + return True + + # Portal treatment professionals need team access + if user.has_group('bemade_sports_clinic.group_portal_treatment_professional'): + partner = user.partner_id + team_staff_rels = partner.team_staff_rel_ids + authorized_teams = team_staff_rels.mapped('team_id') + return self.team_id in authorized_teams + + return False + + # ======================================== + # CRUD OVERRIDES + # ======================================== + + @api.model_create_multi + def create(self, vals_list): + """Override create to handle task integration (batch-optimized for Odoo 18)""" + events = super().create(vals_list) + + # Auto-create management tasks for events that need them + for event, vals in zip(events, vals_list): + if vals.get('auto_create_task', True): + event.create_management_task() + + return events + + def write(self, vals): + """Override write to sync with task""" + result = super().write(vals) + + # Sync changes to linked task (only for users with task access) + user = self.env.user + has_task_access = user.has_group('base.group_user') + + if has_task_access: + for event in self: + if event.task_id: + event._sync_to_task() + + return result + + def _sync_to_task(self): + """Sync event changes to linked task""" + if not self.task_id: + return + + task_vals = { + 'name': f"Event: {self.name}", + 'description': self.description or '', + 'date_deadline': self.date_end, + 'user_ids': [(6, 0, self.assigned_staff_ids.ids)], + } + + # Sync therapist coverage times to task start/end times + if self.therapist_start: + task_vals['date_start'] = self.therapist_start + if self.therapist_end: + task_vals['date_end'] = self.therapist_end + + self.task_id.write(task_vals) diff --git a/bemade_sports_clinic/security/ir.model.access.csv b/bemade_sports_clinic/security/ir.model.access.csv index b6ab431..ebdcfe1 100644 --- a/bemade_sports_clinic/security/ir.model.access.csv +++ b/bemade_sports_clinic/security/ir.model.access.csv @@ -4,7 +4,10 @@ access_patient_treatment_pro,Treatment Professional Access for Patients,model_sp access_patient_admin,Admin Access for Patients,model_sports_patient,group_sports_clinic_admin,1,1,1,1 access_patient_portal,Portal Access for Patients,model_sports_patient,base.group_portal,1,1,1,0 access_patient_portal_tp,Portal Treatment Professional Access for Patients,model_sports_patient,bemade_sports_clinic.group_portal_treatment_professional,1,1,1,0 -access_patient_portal_coach,Portal Coach Access for Patients,model_sports_patient,bemade_sports_clinic.group_portal_team_coach,1,1,1,0 +access_treatment_note_portal_coach,Portal Coach Access for Treatment Notes,model_sports_treatment_note,bemade_sports_clinic.group_portal_team_coach,1,0,0,0 +access_sports_event_user,User Access for Sports Events,model_sports_event,base.group_user,1,1,1,1 +access_sports_event_portal_treatment_professional,Portal Treatment Professional Access for Sports Events,model_sports_event,bemade_sports_clinic.group_portal_treatment_professional,1,1,1,0 +access_sports_event_portal_team_coach,Portal Team Coach Access for Sports Events,model_sports_event,bemade_sports_clinic.group_portal_team_coach,1,0,0,0 access_patient_contact_user,User Access for Patient Contacts,model_sports_patient_contact,group_sports_clinic_user,1,1,1,1 access_patient_contact_portal_tp,Portal TP Access for Patient Contacts,model_sports_patient_contact,bemade_sports_clinic.group_portal_treatment_professional,1,1,1,0 access_injury_treatment_pro,Treatment Professional Access for Injuries,model_sports_patient_injury,group_sports_clinic_treatment_professional,1,1,1,1 @@ -49,7 +52,16 @@ access_res_users_portal_coach,Portal Coach Access for Users,base.model_res_users access_res_partner_portal_tp,Portal TP Access for Partners,base.model_res_partner,bemade_sports_clinic.group_portal_treatment_professional,1,1,1,0 access_res_partner_portal_coach,Portal Coach Access for Partners,base.model_res_partner,bemade_sports_clinic.group_portal_team_coach,1,1,1,0 access_mail_followers_portal_tp,Portal TP Access for Followers,mail.model_mail_followers,bemade_sports_clinic.group_portal_treatment_professional,1,1,1,0 -access_mail_followers_portal_coach,Portal Coach Access for Followers,mail.model_mail_followers,bemade_sports_clinic.group_portal_team_coach,1,1,1,0 -access_bus_bus_portal_tp,Portal TP Access for Bus Messages,bus.model_bus_bus,bemade_sports_clinic.group_portal_treatment_professional,1,1,1,0 +access_mail_followers_portal_tp,Portal TP Access for Mail Followers,mail.model_mail_followers,bemade_sports_clinic.group_portal_treatment_professional,1,1,1,0 +access_bus_bus_portal_tp,Portal TP Access for Bus,bus.model_bus_bus,bemade_sports_clinic.group_portal_treatment_professional,1,1,1,0 +access_project_task_portal_tp,Portal TP Access for Project Tasks,project.model_project_task,bemade_sports_clinic.group_portal_treatment_professional,1,1,1,0 +access_project_task_portal_coach,Portal Coach Access for Project Tasks,project.model_project_task,bemade_sports_clinic.group_portal_team_coach,1,1,1,0 +access_task_to_event_wizard_user,User Access for Task to Event Wizard,model_task_to_event_wizard,base.group_user,1,1,1,1 +access_task_to_event_wizard_portal_tp,Portal TP Access for Task to Event Wizard,model_task_to_event_wizard,bemade_sports_clinic.group_portal_treatment_professional,1,1,1,1 +access_task_to_event_wizard_portal_coach,Portal Coach Access for Task to Event Wizard,model_task_to_event_wizard,bemade_sports_clinic.group_portal_team_coach,1,1,1,1 +access_project_project_portal_tp,Portal TP Access for Projects,project.model_project_project,bemade_sports_clinic.group_portal_treatment_professional,1,1,0,0 +access_project_task_type_portal_tp,Portal TP Access for Task Types,project.model_project_task_type,bemade_sports_clinic.group_portal_treatment_professional,1,0,0,0 +access_project_tags_portal_tp,Portal TP Access for Project Tags,project.model_project_tags,bemade_sports_clinic.group_portal_treatment_professional,1,0,0,0 +access_project_milestone_portal_tp,Portal TP Access for Project Milestones,project.model_project_milestone,bemade_sports_clinic.group_portal_treatment_professional,1,0,0,0 access_snailmail_letter_portal_tp,Portal TP Access for Snailmail Letters,snailmail.model_snailmail_letter,bemade_sports_clinic.group_portal_treatment_professional,1,0,0,0 access_snailmail_letter_portal_coach,Portal Coach Access for Snailmail Letters,snailmail.model_snailmail_letter,bemade_sports_clinic.group_portal_team_coach,1,0,0,0 diff --git a/bemade_sports_clinic/security/project_task_portal_rules.xml b/bemade_sports_clinic/security/project_task_portal_rules.xml new file mode 100644 index 0000000..9911983 --- /dev/null +++ b/bemade_sports_clinic/security/project_task_portal_rules.xml @@ -0,0 +1,84 @@ + + + + + + + Project Task: Portal Treatment Professional Access + + [ + '&', + # SECURITY: Only tasks from projects with portal visibility + ('project_id.privacy_visibility', '=', 'portal'), + '|', '|', '|', + # Tasks assigned to the user + ('user_ids', 'in', [user.id]), + # Tasks where user is a follower + ('message_partner_ids', 'in', [user.partner_id.id]), + # Tasks from projects where user is a follower + ('project_id.message_partner_ids', 'in', [user.partner_id.id]), + # Tasks from projects where user's teams are partners + ('project_id.partner_id', 'in', user.partner_id.team_staff_rel_ids.mapped('team_id.id') or [0]) + ] + + + + + + + + + + Project: Portal Treatment Professional Access + + [ + '&', + # SECURITY: Only projects with portal visibility + ('privacy_visibility', '=', 'portal'), + '|', + # Projects where user is explicitly a follower + ('message_partner_ids', 'in', [user.partner_id.id]), + # Projects where user's teams are partners + ('partner_id', 'in', user.partner_id.team_staff_rel_ids.mapped('team_id.id') or [0]) + ] + + + + + + + + + + Project Task Stage: Portal Treatment Professional Access + + [ + '|', '|', + # Stages from projects where user is a follower + ('project_ids.message_partner_ids', 'in', [user.partner_id.id]), + # Stages from projects where user's teams are partners + ('project_ids.partner_id', 'in', user.partner_id.team_staff_rel_ids.mapped('team_id.id') or [0]), + # Global stages (no project restriction) + ('project_ids', '=', False) + ] + + + + + + + + + + Project Tags: Portal Treatment Professional Access + + [(1, '=', 1)] + + + + + + + + + diff --git a/bemade_sports_clinic/security/sports_clinic_portal_rules.xml b/bemade_sports_clinic/security/sports_clinic_portal_rules.xml index f1cdd66..9c095e4 100644 --- a/bemade_sports_clinic/security/sports_clinic_portal_rules.xml +++ b/bemade_sports_clinic/security/sports_clinic_portal_rules.xml @@ -61,6 +61,18 @@ [(1, '=', 1)] + + + Portal Treatment Professional Access to Teams + + + + + + + [(1, '=', 1)] + + Portal Access to Injury Documents diff --git a/bemade_sports_clinic/security/sports_event_rules.xml b/bemade_sports_clinic/security/sports_event_rules.xml new file mode 100644 index 0000000..2d5b0bd --- /dev/null +++ b/bemade_sports_clinic/security/sports_event_rules.xml @@ -0,0 +1,46 @@ + + + + + + + + + Sports Event: Internal Users + + [(1, '=', 1)] + + + + + + + + + + Sports Event: Portal Treatment Professionals + + [(1, '=', 1)] + + + + + + + + + + Sports Event: Portal Team Coaches (Read-Only) + + [ + ('team_id', 'in', user.partner_id.team_staff_rel_ids.mapped('team_id.id') or [0]) + ] + + + + + + + + + diff --git a/bemade_sports_clinic/tests/__init__.py b/bemade_sports_clinic/tests/__init__.py index 15c0a7f..cfcffc7 100644 --- a/bemade_sports_clinic/tests/__init__.py +++ b/bemade_sports_clinic/tests/__init__.py @@ -6,3 +6,4 @@ from . import test_treatment_professional_consistency from . import test_player_removal from . import test_mail_activity_portal_access from . import test_mail_activity_portal_integration +from . import test_project_task_portal_security diff --git a/bemade_sports_clinic/tests/test_project_task_portal_security.py b/bemade_sports_clinic/tests/test_project_task_portal_security.py new file mode 100644 index 0000000..700129f --- /dev/null +++ b/bemade_sports_clinic/tests/test_project_task_portal_security.py @@ -0,0 +1,180 @@ +# -*- coding: utf-8 -*- + +from odoo.tests.common import TransactionCase +from odoo.exceptions import AccessError, UserError +from datetime import datetime, timedelta + + +class TestProjectTaskPortalSecurity(TransactionCase): + + def setUp(self): + super().setUp() + + # Create test users + self.portal_tp_user = self.env['res.users'].create({ + 'name': 'Portal Treatment Professional', + 'login': 'portal_tp_test', + 'email': 'portal_tp@test.com', + 'groups_id': [(4, self.env.ref('bemade_sports_clinic.group_portal_treatment_professional').id)] + }) + + self.regular_portal_user = self.env['res.users'].create({ + 'name': 'Regular Portal User', + 'login': 'portal_regular_test', + 'email': 'portal_regular@test.com', + 'groups_id': [(4, self.env.ref('base.group_portal').id)] + }) + + # Create test project with portal access + self.test_project = self.env['project.project'].create({ + 'name': 'Test Sports Project', + 'privacy_visibility': 'portal', + 'is_sports_clinic_project': True, + }) + + # Add portal TP as follower + self.test_project.message_subscribe(partner_ids=[self.portal_tp_user.partner_id.id]) + + # Create test task + self.test_task = self.env['project.task'].create({ + 'name': 'Test Event Task', + 'project_id': self.test_project.id, + 'user_ids': [(4, self.portal_tp_user.id)], + 'date_event_start': datetime.now() + timedelta(days=1), + 'date_event_end': datetime.now() + timedelta(days=1, hours=2), + }) + + def test_01_portal_tp_can_read_task_fields(self): + """Test that portal treatment professionals can read all required task fields""" + task_as_portal_tp = self.test_task.sudo(self.portal_tp_user) + + # Test core fields + self.assertTrue(task_as_portal_tp.name) + self.assertTrue(task_as_portal_tp.project_id) + self.assertTrue(task_as_portal_tp.user_ids) + + # Test custom event fields + self.assertTrue(task_as_portal_tp.date_event_start) + self.assertTrue(task_as_portal_tp.date_event_end) + + # Test overridden fields + self.assertIsNotNone(task_as_portal_tp.date_start) + self.assertIsNotNone(task_as_portal_tp.date_end) + + def test_02_portal_tp_can_write_task_fields(self): + """Test that portal treatment professionals can write to task fields""" + task_as_portal_tp = self.test_task.sudo(self.portal_tp_user) + + # Test writing to various fields + task_as_portal_tp.write({ + 'name': 'Updated Event Name', + 'description': 'Updated description', + 'priority': '1', + 'date_event_start': datetime.now() + timedelta(days=2), + 'date_event_end': datetime.now() + timedelta(days=2, hours=3), + }) + + self.assertEqual(task_as_portal_tp.name, 'Updated Event Name') + self.assertEqual(task_as_portal_tp.priority, '1') + + def test_03_portal_tp_can_create_tasks(self): + """Test that portal treatment professionals can create tasks""" + task_as_portal_tp = self.env['project.task'].sudo(self.portal_tp_user) + + new_task = task_as_portal_tp.create({ + 'name': 'New Portal Created Task', + 'project_id': self.test_project.id, + 'date_event_start': datetime.now() + timedelta(days=3), + 'date_event_end': datetime.now() + timedelta(days=3, hours=1), + }) + + self.assertTrue(new_task.exists()) + self.assertEqual(new_task.name, 'New Portal Created Task') + + def test_04_regular_portal_user_cannot_access_tasks(self): + """Test that regular portal users cannot access project tasks""" + task_as_regular_portal = self.test_task.sudo(self.regular_portal_user) + + # Should not be able to read task fields + with self.assertRaises(AccessError): + _ = task_as_regular_portal.name + + def test_05_portal_task_access_method(self): + """Test the custom portal task access checking method""" + # Test with portal TP user (should have access) + task_as_portal_tp = self.test_task.sudo(self.portal_tp_user) + self.assertTrue(task_as_portal_tp.check_portal_task_access()) + + # Test with regular portal user (should not have access) + task_as_regular_portal = self.test_task.sudo(self.regular_portal_user) + self.assertFalse(task_as_regular_portal.check_portal_task_access()) + + def test_06_project_access_for_portal_tp(self): + """Test that portal treatment professionals can access projects""" + project_as_portal_tp = self.test_project.sudo(self.portal_tp_user) + + # Should be able to read project fields + self.assertTrue(project_as_portal_tp.name) + self.assertTrue(project_as_portal_tp.privacy_visibility) + self.assertTrue(project_as_portal_tp.is_sports_clinic_project) + + def test_07_record_rule_filtering(self): + """Test that record rules properly filter accessible tasks""" + # Create a task that portal TP should NOT have access to + other_project = self.env['project.project'].create({ + 'name': 'Private Project', + 'privacy_visibility': 'employees', # Not portal accessible + }) + + private_task = self.env['project.task'].create({ + 'name': 'Private Task', + 'project_id': other_project.id, + }) + + # Portal TP should not be able to access this task + task_as_portal_tp = private_task.sudo(self.portal_tp_user) + self.assertFalse(task_as_portal_tp.check_portal_task_access()) + + def test_08_field_groups_configuration(self): + """Test that field groups are properly configured""" + task_model = self.env['project.task'] + + # Check that critical fields have portal groups + critical_fields = ['name', 'description', 'user_ids', 'project_id', 'date_event_start', 'date_event_end'] + + for field_name in critical_fields: + field_obj = task_model._fields.get(field_name) + self.assertIsNotNone(field_obj, f"Field {field_name} not found") + + if hasattr(field_obj, 'groups') and field_obj.groups: + self.assertIn('bemade_sports_clinic.group_portal_treatment_professional', + field_obj.groups, + f"Field {field_name} missing portal TP group access") + + def test_09_project_creation_helper(self): + """Test the project creation helper method""" + # Test creating a sports clinic project + new_project = self.env['project.project'].create_sports_clinic_project( + name='Test Helper Project', + description='Created via helper method' + ) + + self.assertTrue(new_project.exists()) + self.assertTrue(new_project.is_sports_clinic_project) + self.assertEqual(new_project.privacy_visibility, 'portal') + + # Check that treatment professionals are followers + portal_group = self.env.ref('bemade_sports_clinic.group_portal_treatment_professional') + tp_partners = portal_group.users.mapped('partner_id') + + for partner in tp_partners: + self.assertIn(partner, new_project.message_partner_ids) + + def test_10_default_project_creation(self): + """Test the default project creation method""" + default_project = self.env['project.project'].get_or_create_default_sports_project() + + self.assertTrue(default_project.exists()) + self.assertEqual(default_project.name, 'Sports Clinic Events') + self.assertTrue(default_project.is_sports_clinic_project) + self.assertEqual(default_project.privacy_visibility, 'portal') diff --git a/bemade_sports_clinic/views/events_portal_templates.xml b/bemade_sports_clinic/views/events_portal_templates.xml new file mode 100644 index 0000000..d987c74 --- /dev/null +++ b/bemade_sports_clinic/views/events_portal_templates.xml @@ -0,0 +1,287 @@ + + + + + + + diff --git a/bemade_sports_clinic/views/injury_management_portal_templates.xml b/bemade_sports_clinic/views/injury_management_portal_templates.xml index 4bd12e1..a73ff28 100644 --- a/bemade_sports_clinic/views/injury_management_portal_templates.xml +++ b/bemade_sports_clinic/views/injury_management_portal_templates.xml @@ -56,7 +56,7 @@

Injury Details

- +
@@ -73,10 +73,17 @@ Check N/A if exact date is unknown
- +
+
+ + + Expected recovery timeline +
+
- +
@@ -87,29 +94,7 @@
- - - -
-
-
- - - Expected recovery timeline -
-
-
-
- - - Actual recovery date (when resolved) -
-
-
- - +
@@ -125,22 +110,38 @@
- - - Hold Ctrl/Cmd to select multiple professionals + + + Actual recovery date (when resolved)
- -
+ +
+
+ +
+ +
+ + +
+
+
+ Select multiple professionals +
+
+
+ + + + +
+
+
+
+ + + diff --git a/bemade_sports_clinic/views/portal_event_detail_template.xml b/bemade_sports_clinic/views/portal_event_detail_template.xml new file mode 100644 index 0000000..816aa13 --- /dev/null +++ b/bemade_sports_clinic/views/portal_event_detail_template.xml @@ -0,0 +1,222 @@ + + + + + + + diff --git a/bemade_sports_clinic/views/portal_event_edit_template.xml b/bemade_sports_clinic/views/portal_event_edit_template.xml new file mode 100644 index 0000000..f00f4ee --- /dev/null +++ b/bemade_sports_clinic/views/portal_event_edit_template.xml @@ -0,0 +1,310 @@ + + + + + + + diff --git a/bemade_sports_clinic/views/project_task_security_test_views.xml b/bemade_sports_clinic/views/project_task_security_test_views.xml new file mode 100644 index 0000000..d24f348 --- /dev/null +++ b/bemade_sports_clinic/views/project_task_security_test_views.xml @@ -0,0 +1,49 @@ + + + + + + + project.task.security.test.form + project.task.security.test + +
+
+
+ + + + + + + + + + + +
+
+
+ + + + Project Task Security Test + project.task.security.test + form + new + + + + + +
+
diff --git a/bemade_sports_clinic/views/project_task_views.xml b/bemade_sports_clinic/views/project_task_views.xml new file mode 100644 index 0000000..dcfa9f3 --- /dev/null +++ b/bemade_sports_clinic/views/project_task_views.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/bemade_sports_clinic/views/res_partner_views.xml b/bemade_sports_clinic/views/res_partner_views.xml index 7a4f843..02ba36c 100644 --- a/bemade_sports_clinic/views/res_partner_views.xml +++ b/bemade_sports_clinic/views/res_partner_views.xml @@ -5,8 +5,17 @@ res.partner + + + + + + + + + @@ -19,6 +28,7 @@ - + + diff --git a/bemade_sports_clinic/views/sports_clinic_portal_views.xml b/bemade_sports_clinic/views/sports_clinic_portal_views.xml index aa85858..93b78ec 100644 --- a/bemade_sports_clinic/views/sports_clinic_portal_views.xml +++ b/bemade_sports_clinic/views/sports_clinic_portal_views.xml @@ -2,24 +2,92 @@ + + + + + + + + + + + +