feat: Update bemade_sports_clinic to v18.0.2.0.0 with comprehensive activity management

- Added integrated mail.activity system for task management
- Implemented portal access to activities for treatment professionals
- Added activity creation, completion, and reassignment functionality
- Enhanced player management with Canadian address validation
- Improved injury tracking with parental consent and document attachments
- Implemented layered security architecture (ACL + Record Rules + Controller filtering)
- Added French Canadian (fr_CA) localization support
- Enhanced portal UI with activity counts and navigation
- Implemented RPC security protection with buddy method pattern
- Added comprehensive demo data and integration features
- Updated manifest to reflect all new capabilities and improvements
This commit is contained in:
Denis Durepos 2025-08-12 09:23:05 -04:00
parent 0c0d047f2e
commit d2286d136e
37 changed files with 3716 additions and 157 deletions

View file

@ -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
<field name="domain_force">[
'|', '|', '|',
# 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
<field name="domain_force">[
'|',
# 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

View file

@ -1,2 +1,3 @@
from . import models
from . import controllers
from . import wizards

View file

@ -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,

View file

@ -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

View file

@ -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/<int: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/<int:event_id>'], 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/<int:event_id>/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/<int:event_id>/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}')

View file

@ -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')

View file

@ -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/<int: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 """

View file

@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Sports Clinic Events Project -->
<record id="project_sports_clinic_events" model="project.project">
<field name="name">Sports Clinic Events</field>
<field name="description">Default project for sports clinic events and activities</field>
<field name="is_sports_clinic_project">True</field>
<field name="privacy_visibility">portal</field>
<field name="partner_id" ref="team_carabins"/>
<field name="user_id" ref="base.user_admin"/>
</record>
<!-- Team-Specific Projects -->
<record id="project_team_carabins" model="project.project">
<field name="name">Carabins Team Events</field>
<field name="description">Events and activities for Carabins team</field>
<field name="is_sports_clinic_project">True</field>
<field name="privacy_visibility">portal</field>
<field name="partner_id" ref="team_carabins"/>
<field name="user_id" ref="base.user_admin"/>
</record>
<!-- Sample Project Tasks (Events) -->
<record id="task_team_meeting_carabins" model="project.task">
<field name="name">Team Meeting - Injury Prevention</field>
<field name="description">Monthly team meeting to discuss injury prevention strategies</field>
<field name="project_id" ref="project_team_carabins"/>
<field name="user_ids" eval="[(4, ref('base.user_admin'))]"/>
<field name="partner_id" ref="team_carabins"/>
<field name="date_deadline" eval="(DateTime.now() + timedelta(days=7)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="priority">1</field>
</record>
<record id="task_medical_checkup" model="project.task">
<field name="name">Pre-Season Medical Checkups</field>
<field name="description">Conduct medical checkups for all team members before season start</field>
<field name="project_id" ref="project_team_carabins"/>
<field name="user_ids" eval="[(4, ref('base.user_admin'))]"/>
<field name="partner_id" ref="team_carabins"/>
<field name="date_deadline" eval="(DateTime.now() + timedelta(days=14)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="priority">2</field>
</record>
<!-- Project Followers - Add treatment professional as follower -->
<record id="project_follower_carabins_therapist" model="mail.followers">
<field name="res_model">project.project</field>
<field name="res_id" ref="project_team_carabins"/>
<field name="partner_id" ref="partner_therapist_team_carabins"/>
</record>
<record id="project_follower_sports_clinic_therapist" model="mail.followers">
<field name="res_model">project.project</field>
<field name="res_id" ref="project_sports_clinic_events"/>
<field name="partner_id" ref="partner_therapist_team_carabins"/>
</record>
</data>
</odoo>

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 (

View file

@ -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)

View file

@ -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

1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
4 access_patient_admin Admin Access for Patients model_sports_patient group_sports_clinic_admin 1 1 1 1
5 access_patient_portal Portal Access for Patients model_sports_patient base.group_portal 1 1 1 0
6 access_patient_portal_tp Portal Treatment Professional Access for Patients model_sports_patient bemade_sports_clinic.group_portal_treatment_professional 1 1 1 0
7 access_patient_portal_coach access_treatment_note_portal_coach Portal Coach Access for Patients Portal Coach Access for Treatment Notes model_sports_patient model_sports_treatment_note bemade_sports_clinic.group_portal_team_coach 1 1 0 1 0 0
8 access_sports_event_user User Access for Sports Events model_sports_event base.group_user 1 1 1 1
9 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
10 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
11 access_patient_contact_user User Access for Patient Contacts model_sports_patient_contact group_sports_clinic_user 1 1 1 1
12 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
13 access_injury_treatment_pro Treatment Professional Access for Injuries model_sports_patient_injury group_sports_clinic_treatment_professional 1 1 1 1
52 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
53 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
54 access_mail_followers_portal_coach access_mail_followers_portal_tp Portal Coach Access for Followers Portal TP Access for Mail Followers mail.model_mail_followers bemade_sports_clinic.group_portal_team_coach bemade_sports_clinic.group_portal_treatment_professional 1 1 1 0
55 access_bus_bus_portal_tp Portal TP Access for Bus Messages Portal TP Access for Bus bus.model_bus_bus bemade_sports_clinic.group_portal_treatment_professional 1 1 1 0
56 access_snailmail_letter_portal_tp access_project_task_portal_tp Portal TP Access for Snailmail Letters Portal TP Access for Project Tasks snailmail.model_snailmail_letter project.model_project_task bemade_sports_clinic.group_portal_treatment_professional 1 0 1 0 1 0
57 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
58 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
59 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
60 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
61 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
62 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
63 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
64 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
65 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
66 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
67

View file

@ -0,0 +1,84 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Project Task Access Rules for Portal Treatment Professionals -->
<record model="ir.rule" id="project_task_portal_tp_rule">
<field name="name">Project Task: Portal Treatment Professional Access</field>
<field name="model_id" ref="project.model_project_task"/>
<field name="domain_force">[
'&amp;',
# 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])
]</field>
<field name="groups" eval="[(4, ref('bemade_sports_clinic.group_portal_treatment_professional'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- Project Access Rules for Portal Treatment Professionals -->
<record model="ir.rule" id="project_project_portal_tp_rule">
<field name="name">Project: Portal Treatment Professional Access</field>
<field name="model_id" ref="project.model_project_project"/>
<field name="domain_force">[
'&amp;',
# 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])
]</field>
<field name="groups" eval="[(4, ref('bemade_sports_clinic.group_portal_treatment_professional'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- Project Task Stage Access for Portal Treatment Professionals -->
<record model="ir.rule" id="project_task_stage_portal_tp_rule">
<field name="name">Project Task Stage: Portal Treatment Professional Access</field>
<field name="model_id" ref="project.model_project_task_type"/>
<field name="domain_force">[
'|', '|',
# 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)
]</field>
<field name="groups" eval="[(4, ref('bemade_sports_clinic.group_portal_treatment_professional'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- Project Tags Access for Portal Treatment Professionals -->
<record model="ir.rule" id="project_tags_portal_tp_rule">
<field name="name">Project Tags: Portal Treatment Professional Access</field>
<field name="model_id" ref="project.model_project_tags"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('bemade_sports_clinic.group_portal_treatment_professional'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
</data>
</odoo>

View file

@ -61,6 +61,18 @@
<field name="domain_force">[(1, '=', 1)]</field>
</record>
<!-- Portal treatment professionals can access all teams -->
<record id="portal_treatment_professional_team_access" model="ir.rule">
<field name="name">Portal Treatment Professional Access to Teams</field>
<field name="model_id" ref="model_sports_team"/>
<field name="groups" eval="[(6, 0, [ref('bemade_sports_clinic.group_portal_treatment_professional')])]"/>
<field name="perm_create" eval="False"/>
<field name="perm_write" eval="False"/>
<field name="perm_read" eval="True"/>
<field name="perm_unlink" eval="False"/>
<field name="domain_force">[(1, '=', 1)]</field>
</record>
<!-- Portal users can access injury documents for their team's players -->
<record id="portal_injury_document_access" model="ir.rule">
<field name="name">Portal Access to Injury Documents</field>

View file

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<!-- <data noupdate="1"> -->
<!-- Sports Event Access Rules -->
<!-- Internal Users: Full access to all events -->
<record id="sports_event_rule_user" model="ir.rule">
<field name="name">Sports Event: Internal Users</field>
<field name="model_id" ref="model_sports_event"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
<!-- Portal Treatment Professionals: Full access to all events -->
<record id="sports_event_rule_portal_treatment_professional" model="ir.rule">
<field name="name">Sports Event: Portal Treatment Professionals</field>
<field name="model_id" ref="model_sports_event"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('bemade_sports_clinic.group_portal_treatment_professional'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- Portal Team Coaches: Team-based read-only access -->
<record id="sports_event_rule_portal_team_coach" model="ir.rule">
<field name="name">Sports Event: Portal Team Coaches (Read-Only)</field>
<field name="model_id" ref="model_sports_event"/>
<field name="domain_force">[
('team_id', 'in', user.partner_id.team_staff_rel_ids.mapped('team_id.id') or [0])
]</field>
<field name="groups" eval="[(4, ref('bemade_sports_clinic.group_portal_team_coach'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- </data> -->
</odoo>

View file

@ -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

View file

@ -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')

View file

@ -0,0 +1,287 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<data>
<!-- Events List Template -->
<template id="portal_events_list" name="Portal Events List">
<t t-call="portal.portal_layout">
<t t-set="breadcrumbs_searchbar" t-value="True"/>
<t t-call="portal.portal_searchbar">
<t t-set="title">Events</t>
</t>
<div class="container-fluid">
<!-- Breadcrumbs -->
<div class="row">
<div class="col-lg-12">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a href="/my">Home</a>
</li>
<li class="breadcrumb-item active" aria-current="page">Events</li>
</ol>
</nav>
</div>
</div>
<!-- View Type Navigation -->
<div class="row mb-3">
<div class="col-12">
<ul class="nav nav-pills">
<li class="nav-item">
<a t-attf-class="nav-link #{'active' if view_type == 'all' else ''}"
t-attf-href="/my/events?view_type=all#{project_id and '&amp;project_id=%s' % project_id or ''}#{assigned_user_id and '&amp;assigned_user_id=%s' % assigned_user_id or ''}#{date_from and '&amp;date_from=%s' % date_from or ''}#{date_to and '&amp;date_to=%s' % date_to or ''}">
All Events
</a>
</li>
<li class="nav-item">
<a t-attf-class="nav-link #{'active' if view_type == 'my' else ''}"
t-attf-href="/my/events?view_type=my#{project_id and '&amp;project_id=%s' % project_id or ''}#{assigned_user_id and '&amp;assigned_user_id=%s' % assigned_user_id or ''}#{date_from and '&amp;date_from=%s' % date_from or ''}#{date_to and '&amp;date_to=%s' % date_to or ''}">
My Events
</a>
</li>
<li class="nav-item">
<a t-attf-class="nav-link #{'active' if view_type == 'unassigned' else ''}"
t-attf-href="/my/events?view_type=unassigned#{project_id and '&amp;project_id=%s' % project_id or ''}#{assigned_user_id and '&amp;assigned_user_id=%s' % assigned_user_id or ''}#{date_from and '&amp;date_from=%s' % date_from or ''}#{date_to and '&amp;date_to=%s' % date_to or ''}">
Unassigned Events
</a>
</li>
</ul>
</div>
<div class="col-md-6 text-end">
<a href="/my/events?view_type=all" class="btn btn-outline-secondary btn-sm">
<i class="fa fa-times"/> Clear Filters
</a>
</div>
</div>
<!-- Filters -->
<div class="row mb-3">
<div class="col-12">
<form method="get" class="o_portal_search_panel" action="/my/events">
<input type="hidden" name="view_type" t-att-value="view_type"/>
<!-- First Row: Main Filters -->
<div class="row g-2 mb-2">
<!-- Organization Filter -->
<div class="col-md-4">
<select name="organization_id" class="form-select" onchange="this.form.submit();">
<option value="">All Organizations</option>
<t t-foreach="organizations" t-as="organization">
<option t-att-value="organization.id" t-att-selected="'selected' if organization_id and organization_id == organization.id else None">
<t t-esc="organization.name"/>
</option>
</t>
</select>
</div>
<!-- Team Filter -->
<div class="col-md-4">
<select name="team_id" class="form-select" onchange="this.form.submit();">
<option value="">All Teams</option>
<t t-foreach="teams" t-as="team">
<option t-att-value="team.id" t-att-selected="team_id == team.id">
<t t-esc="team.name"/>
</option>
</t>
</select>
</div>
<!-- Assigned Professional Filter -->
<div class="col-md-4">
<select name="assigned_user_id" class="form-select" onchange="this.form.submit();">
<option value="">All Assigned Professionals</option>
<t t-foreach="treatment_professionals" t-as="user">
<option t-att-value="user.id" t-att-selected="assigned_user_id == user.id">
<t t-esc="user.name"/>
</option>
</t>
</select>
</div>
</div>
<!-- Second Row: Date Filters -->
<div class="row g-2">
<!-- Date From Filter -->
<div class="col-md-6">
<label for="date_from" class="form-label text-muted small">From Date</label>
<input type="date" id="date_from" name="date_from" class="form-control"
t-att-value="date_from" onchange="this.form.submit();"/>
</div>
<!-- Date To Filter -->
<div class="col-md-6">
<label for="date_to" class="form-label text-muted small">To Date</label>
<input type="date" id="date_to" name="date_to" class="form-control"
t-att-value="date_to" onchange="this.form.submit();"/>
</div>
</div>
</form>
</div>
</div>
<!-- Record Count Display -->
<div class="row mb-3">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<div>
<span class="text-muted">
<t t-if="events">
Showing <strong t-esc="len(events)"/> events
<t t-if="pager and pager.get('page_count', 1) > 1">
(Page <t t-esc="pager.get('page', {}).get('num', 1)"/> of <t t-esc="pager.get('page_count', 1)"/>)
</t>
</t>
<t t-if="not events">
<strong>0</strong> events found
</t>
</span>
</div>
<div>
<t t-if="view_type == 'all'">
<span class="badge bg-secondary">All Events</span>
</t>
<t t-if="view_type == 'my'">
<span class="badge bg-primary">My Events</span>
</t>
<t t-if="view_type == 'unassigned'">
<span class="badge bg-warning">Unassigned Events</span>
</t>
</div>
</div>
</div>
</div>
<!-- Events Table -->
<div class="row">
<div class="col-12">
<t t-if="not events">
<div class="alert alert-info text-center">
<h4>No events found</h4>
<p>There are no events matching your criteria.</p>
</div>
</t>
<t t-if="events">
<div class="table-responsive">
<table class="table table-hover">
<thead class="table-light">
<tr>
<th>
<a t-attf-href="/my/events?#{request.httprequest.query_string.decode()}&amp;sortby=name">
Event Name
<i t-if="sortby == 'name'" class="fa fa-sort-alpha-asc"/>
</a>
</th>
<th>
<a t-attf-href="/my/events?#{request.httprequest.query_string.decode()}&amp;sortby=date">
Date
<i t-if="sortby == 'date'" class="fa fa-sort-numeric-asc"/>
<i t-if="sortby == 'date_desc'" class="fa fa-sort-numeric-desc"/>
</a>
</th>
<th>Type</th>
<th>
<a t-attf-href="/my/events?#{request.httprequest.query_string.decode()}&amp;sortby=team">
Team
<i t-if="sortby == 'team'" class="fa fa-sort-alpha-asc"/>
</a>
</th>
<th>Venue</th>
<th>Assigned Staff</th>
<th t-if="can_edit">Actions</th>
</tr>
</thead>
<tbody>
<t t-foreach="events" t-as="event">
<tr class="clickable-row" style="cursor: pointer;"
t-att-onclick="'window.location.href=\'%s\'' % ('/my/event/%s' % event.id)">
<!-- Name -->
<td>
<strong t-esc="event.name"/>
<t t-if="event.description">
<br/><small class="text-muted" t-esc="event.description[:100] + '...' if len(event.description) > 100 else event.description"/>
</t>
</td>
<!-- Date -->
<td>
<t t-if="event.therapist_start">
<span t-esc="event.therapist_start" t-options="{'widget': 'date', 'format': 'yyyy-MM-dd'}"/>
<br/><span t-esc="event.therapist_start" t-options="{'widget': 'datetime', 'format': 'HH:mm'}"/>
<t t-if="event.date_start">
<br/><small class="text-muted">(<span t-esc="event.date_start" t-options="{'widget': 'datetime', 'format': 'HH:mm'}"/>)</small>
</t>
</t>
<t t-if="not event.therapist_start and event.date_start">
<span t-esc="event.date_start" t-options="{'widget': 'date', 'format': 'yyyy-MM-dd'}"/>
<br/><span t-esc="event.date_start" t-options="{'widget': 'datetime', 'format': 'HH:mm'}"/>
</t>
<t t-if="not event.therapist_start and not event.date_start">
<span class="text-muted">No date</span>
</t>
</td>
<!-- Type -->
<td>
<span class="badge bg-info">
<t t-esc="dict(event._fields['event_type'].selection).get(event.event_type, event.event_type)"/>
</span>
</td>
<!-- Team -->
<td>
<t t-if="event.team_id">
<i class="fa fa-users"/> <span t-esc="event.team_id.name"/>
</t>
<t t-if="not event.team_id">
<span class="text-muted">No Team</span>
</t>
</td>
<!-- Venue -->
<td>
<t t-if="event.venue_id">
<i class="fa fa-map-marker"/> <span t-esc="event.venue_id.name"/>
</t>
<t t-if="not event.venue_id">
<span class="text-muted">TBD</span>
</t>
</td>
<!-- Assigned Staff -->
<td>
<t t-if="event.assigned_staff_ids">
<t t-foreach="event.assigned_staff_ids" t-as="staff">
<span class="badge bg-primary me-1" t-esc="staff.name"/>
</t>
</t>
<t t-if="not event.assigned_staff_ids">
<span class="text-muted">Unassigned</span>
</t>
</td>
<!-- Actions -->
<td t-if="can_edit">
<a t-attf-href="/my/event/#{event.id}" class="btn btn-sm btn-outline-primary me-1">
<i class="fa fa-eye"/> View
</a>
<a t-attf-href="/my/event/#{event.id}/edit" class="btn btn-sm btn-outline-secondary">
<i class="fa fa-edit"/> Edit
</a>
</td>
</tr>
</t>
</tbody>
</table>
</div>
<!-- Pagination -->
<t t-call="portal.pager"/>
</t>
</div>
</div>
</div>
</t>
</template>
</data>
</odoo>

View file

@ -56,7 +56,7 @@
<h4 class="mb-0">Injury Details</h4>
</div>
<div class="card-body">
<!-- Basic injury information -->
<!-- Line 1: injury date, N/A checkbox, predicted return date -->
<div class="row">
<div class="col-lg-6">
<div class="form-group">
@ -73,10 +73,17 @@
<small class="text-muted">Check N/A if exact date is unknown</small>
</div>
</div>
<div t-if="is_treatment_prof" class="col-lg-6">
<div class="form-group">
<label for="predicted_resolution_date">Predicted Resolution Date</label>
<input type="date" name="predicted_resolution_date" id="predicted_resolution_date" class="form-control"
t-att-value="injury.predicted_resolution_date"/>
<small class="text-muted">Expected recovery timeline</small>
</div>
</div>
</div>
<!-- Diagnosis -->
<!-- Line 2: diagnosis -->
<div class="row mt-3">
<div class="col-lg-12">
<div class="form-group">
@ -87,29 +94,7 @@
</div>
</div>
<!-- Treatment dates and status -->
<div t-if="is_treatment_prof" class="row mt-3">
<div class="col-lg-6">
<div class="form-group">
<label for="predicted_resolution_date">Predicted Resolution Date</label>
<input type="date" name="predicted_resolution_date" id="predicted_resolution_date" class="form-control"
t-att-value="injury.predicted_resolution_date"/>
<small class="text-muted">Expected recovery timeline</small>
</div>
</div>
<div class="col-lg-6">
<div class="form-group">
<label for="resolution_date">Resolution Date</label>
<input type="date" name="resolution_date" id="resolution_date" class="form-control"
t-att-value="injury.resolution_date"/>
<small class="text-muted">Actual recovery date (when resolved)</small>
</div>
</div>
</div>
<!-- Status and treatment professionals -->
<!-- Line 3: Status, resolution date (only for treatment professionals) -->
<div t-if="is_treatment_prof" class="row mt-3">
<div class="col-lg-6">
<div class="form-group">
@ -125,22 +110,38 @@
</div>
<div class="col-lg-6">
<div class="form-group">
<label for="treatment_professional_ids">Treatment Professionals</label>
<select name="treatment_professional_ids" id="treatment_professional_ids" class="form-control" multiple="multiple">
<t t-foreach="treatment_professionals" t-as="tp">
<option t-att-value="tp.id" t-att-selected="tp.id in injury.treatment_professional_ids.ids">
<t t-esc="tp.name"/>
</option>
</t>
</select>
<small class="text-muted">Hold Ctrl/Cmd to select multiple professionals</small>
<label for="resolution_date">Resolution Date</label>
<input type="date" name="resolution_date" id="resolution_date" class="form-control"
t-att-value="injury.resolution_date"/>
<small class="text-muted">Actual recovery date (when resolved)</small>
</div>
</div>
</div>
<!-- Parental consent field (only visible to treatment professionals) -->
<div t-if="is_treatment_prof and parental_consent_options" class="row mt-3">
<!-- Line 4: Treatment professionals, parental consent -->
<div t-if="is_treatment_prof" class="row mt-3">
<div class="col-lg-6">
<div class="form-group">
<label class="form-label">Treatment Professionals</label>
<div class="border rounded p-3" style="max-height: 200px; overflow-y: auto;">
<t t-foreach="treatment_professionals" t-as="tp">
<div class="form-check">
<input class="form-check-input" type="checkbox"
t-att-id="'tp_%s' % tp.id"
name="treatment_professional_ids[]"
t-att-value="tp.id"
t-att-checked="tp.id in injury.treatment_professional_ids.ids"/>
<label class="form-check-label" t-att-for="'tp_%s' % tp.id">
<span t-esc="tp.name"/>
<small class="text-muted d-block" t-esc="tp.email"/>
</label>
</div>
</t>
</div>
<small class="text-muted">Select multiple professionals</small>
</div>
</div>
<div t-if="parental_consent_options" class="col-lg-6">
<div class="form-group">
<label for="parental_consent">Parental Consent</label>
<select name="parental_consent" id="parental_consent" class="form-control">
@ -154,7 +155,7 @@
</div>
</div>
<!-- Internal notes (only visible to treatment professionals) -->
<!-- Line 5: Internal notes -->
<div t-if="is_treatment_prof" class="row mt-3">
<div class="col-lg-12">
<div class="form-group">
@ -222,6 +223,12 @@
<a t-att-href="'/my/injury/documents?injury_id=%s' % injury.id" class="btn bg-o-color-2 text-white">
<i class="fa fa-file-medical"></i> Documents
</a>
<!-- Delete injury button (only for treatment professionals) -->
<button t-if="is_treatment_prof" type="button" class="btn btn-danger"
data-bs-toggle="modal" data-bs-target="#deleteInjuryModal"
t-att-onclick="'setDeleteInjuryId(%s)' % injury.id">
<i class="fa fa-trash"></i> Delete Injury
</button>
</div>
</div>
</div>
@ -230,6 +237,50 @@
</div>
</div>
</div>
<!-- Delete Injury Confirmation Modal -->
<div class="modal fade" id="deleteInjuryModal" tabindex="-1" aria-labelledby="deleteInjuryModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteInjuryModalLabel">Delete Injury</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="alert alert-warning" role="alert">
<i class="fa fa-exclamation-triangle"></i>
<strong>Warning:</strong> This action cannot be undone.
</div>
<p>Are you sure you want to permanently delete this injury record?</p>
<p><strong>This will also delete:</strong></p>
<ul>
<li>All treatment notes associated with this injury</li>
<li>All documents and attachments</li>
<li>All activity history</li>
</ul>
</div>
<div class="modal-footer">
<button type="button" class="btn bg-o-color-2 text-white" data-bs-dismiss="modal">Cancel</button>
<form method="post" action="/my/injury/delete" style="display: inline;">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<input type="hidden" name="injury_id" id="deleteInjuryId"/>
<input type="hidden" name="return_url" t-att-value="return_url"/>
<button type="submit" class="btn btn-danger">
<i class="fa fa-trash"></i> Yes, Delete Injury
</button>
</form>
</div>
</div>
</div>
</div>
<!-- JavaScript for modal functionality -->
<script>
function setDeleteInjuryId(injuryId) {
document.getElementById('deleteInjuryId').value = injuryId;
console.log('Delete modal opened for injury:', injuryId);
}
</script>
</t>
</template>

View file

@ -0,0 +1,222 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<data>
<!-- Event Detail Template -->
<template id="portal_event_detail" name="Portal Event Detail">
<t t-call="portal.portal_layout">
<t t-set="breadcrumbs_searchbar" t-value="True"/>
<div class="container-fluid">
<!-- Breadcrumbs -->
<div class="row">
<div class="col-lg-12">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a href="/my">Home</a>
</li>
<li class="breadcrumb-item">
<a href="/my/events">Events</a>
</li>
<li class="breadcrumb-item active" aria-current="page">
<t t-esc="event.name"/>
</li>
</ol>
</nav>
</div>
</div>
<!-- Header -->
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<div>
<h2 t-esc="event.name"/>
<p class="text-muted mb-0">
<i class="fa fa-users"/> <span t-esc="event.team_id.name"/>
<t t-if="event.venue_id">
| <i class="fa fa-map-marker"/> <span t-esc="event.venue_id.name"/>
</t>
</p>
</div>
<div>
<a href="/my/events" class="btn btn-outline-secondary me-2">
<i class="fa fa-arrow-left"/> Back to Events
</a>
<t t-if="can_edit">
<a t-attf-href="/my/event/#{event.id}/edit" class="btn btn-primary">
<i class="fa fa-edit"/> Edit Event
</a>
</t>
</div>
</div>
</div>
</div>
<!-- Success/Error Messages -->
<t t-if="request.params.get('success')">
<div class="alert alert-success alert-dismissible fade show" role="alert">
<i class="fa fa-check-circle"/> Event updated successfully!
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
</t>
<!-- Event Details -->
<div class="row">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="fa fa-info-circle"/> Event Details
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<dl class="row">
<dt class="col-sm-4">Type:</dt>
<dd class="col-sm-8">
<span class="badge bg-info">
<t t-esc="dict(event._fields['event_type'].selection).get(event.event_type, event.event_type)"/>
</span>
</dd>
<dt class="col-sm-4">Status:</dt>
<dd class="col-sm-8">
<span t-attf-class="badge #{event.state == 'completed' and 'bg-success' or event.state == 'cancelled' and 'bg-danger' or event.state == 'in_progress' and 'bg-warning' or 'bg-secondary'}">
<t t-esc="dict(event._fields['state'].selection).get(event.state, event.state)"/>
</span>
</dd>
<dt class="col-sm-4">Team:</dt>
<dd class="col-sm-8">
<i class="fa fa-users"/> <span t-esc="event.team_id.name"/>
</dd>
<dt class="col-sm-4">Venue:</dt>
<dd class="col-sm-8">
<t t-if="event.venue_id">
<i class="fa fa-map-marker"/> <span t-esc="event.venue_id.name"/>
</t>
<t t-if="not event.venue_id">
<span class="text-muted">To be determined</span>
</t>
</dd>
</dl>
</div>
<div class="col-md-6">
<dl class="row">
<dt class="col-sm-5">Event Start:</dt>
<dd class="col-sm-7">
<t t-if="event.date_start">
<i class="fa fa-calendar"/> <span t-esc="event.date_start" t-options="{'widget': 'datetime'}"/>
</t>
<t t-if="not event.date_start">
<span class="text-muted">Not scheduled</span>
</t>
</dd>
<dt class="col-sm-5">Event End:</dt>
<dd class="col-sm-7">
<t t-if="event.date_end">
<i class="fa fa-calendar"/> <span t-esc="event.date_end" t-options="{'widget': 'datetime'}"/>
</t>
<t t-if="not event.date_end">
<span class="text-muted">Not scheduled</span>
</t>
</dd>
<dt class="col-sm-5">Therapist Start:</dt>
<dd class="col-sm-7">
<t t-if="event.therapist_start">
<i class="fa fa-clock-o"/> <span t-esc="event.therapist_start" t-options="{'widget': 'datetime'}"/>
</t>
<t t-if="not event.therapist_start">
<span class="text-muted">Same as event</span>
</t>
</dd>
<dt class="col-sm-5">Therapist End:</dt>
<dd class="col-sm-7">
<t t-if="event.therapist_end">
<i class="fa fa-clock-o"/> <span t-esc="event.therapist_end" t-options="{'widget': 'datetime'}"/>
</t>
<t t-if="not event.therapist_end">
<span class="text-muted">Same as event</span>
</t>
</dd>
</dl>
</div>
</div>
<!-- Description -->
<t t-if="event.description">
<hr/>
<h6>Description:</h6>
<div t-field="event.description" class="oe_no_empty"/>
</t>
</div>
</div>
</div>
<div class="col-lg-4">
<!-- Assigned Staff -->
<div class="card mb-3">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="fa fa-user-md"/> Assigned Staff
</h5>
</div>
<div class="card-body">
<t t-if="event.assigned_staff_ids">
<t t-foreach="event.assigned_staff_ids" t-as="staff">
<div class="d-flex align-items-center mb-2">
<div class="avatar-sm bg-primary rounded-circle d-flex align-items-center justify-content-center me-2">
<i class="fa fa-user text-white"/>
</div>
<div>
<div class="fw-medium" t-esc="staff.name"/>
<small class="text-muted" t-esc="staff.email"/>
</div>
</div>
</t>
</t>
<t t-if="not event.assigned_staff_ids">
<p class="text-muted mb-0">
<i class="fa fa-info-circle"/> No staff assigned yet
</p>
</t>
</div>
</div>
<!-- Organization -->
<t t-if="event.partner_id">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="fa fa-building"/> Organization
</h5>
</div>
<div class="card-body">
<div class="d-flex align-items-center">
<div class="avatar-sm bg-secondary rounded-circle d-flex align-items-center justify-content-center me-2">
<i class="fa fa-building text-white"/>
</div>
<div>
<div class="fw-medium" t-esc="event.partner_id.name"/>
<t t-if="event.partner_id.email">
<small class="text-muted d-block" t-esc="event.partner_id.email"/>
</t>
</div>
</div>
</div>
</div>
</t>
</div>
</div>
</div>
</t>
</template>
</data>
</odoo>

View file

@ -0,0 +1,310 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<data>
<!-- Event Edit Template -->
<template id="portal_event_edit" name="Portal Event Edit">
<t t-call="portal.portal_layout">
<t t-set="breadcrumbs_searchbar" t-value="True"/>
<div class="container-fluid">
<!-- Breadcrumbs -->
<div class="row">
<div class="col-lg-12">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a href="/my">Home</a>
</li>
<li class="breadcrumb-item">
<a href="/my/events">Events</a>
</li>
<li class="breadcrumb-item">
<a t-attf-href="/my/event/#{event.id}">
<t t-esc="event.name"/>
</a>
</li>
<li class="breadcrumb-item active" aria-current="page">Edit</li>
</ol>
</nav>
</div>
</div>
<!-- Header -->
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<div>
<h2>Edit Event</h2>
<p class="text-muted mb-0">
<i class="fa fa-edit"/> Editing: <span t-esc="event.name"/>
</p>
</div>
<div>
<a t-attf-href="/my/event/#{event.id}" class="btn btn-outline-secondary">
<i class="fa fa-arrow-left"/> Cancel
</a>
</div>
</div>
</div>
</div>
<!-- Error Messages -->
<t t-if="request.params.get('error')">
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="fa fa-exclamation-circle"/> Error: <span t-esc="request.params.get('error')"/>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
</t>
<!-- Edit Form -->
<form t-attf-action="/my/event/#{event.id}/save" method="post" class="needs-validation" novalidate="true">
<div class="row">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="fa fa-info-circle"/> Event Information
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<!-- Event Name -->
<div class="mb-3">
<label for="name" class="form-label">Event Name *</label>
<input type="text" class="form-control" id="name" name="name"
t-att-value="event.name" required="true"/>
<div class="invalid-feedback">
Please provide a valid event name.
</div>
</div>
<!-- Event Type -->
<div class="mb-3">
<label for="event_type" class="form-label">Event Type</label>
<select class="form-select" id="event_type" name="event_type">
<option value="game" t-att-selected="event.event_type == 'game'">Game</option>
<option value="practice" t-att-selected="event.event_type == 'practice'">Practice</option>
<option value="training" t-att-selected="event.event_type == 'training'">Training</option>
<option value="meeting" t-att-selected="event.event_type == 'meeting'">Team Meeting</option>
<option value="other" t-att-selected="event.event_type == 'other'">Other</option>
</select>
</div>
<!-- Status -->
<div class="mb-3">
<label for="state" class="form-label">Status</label>
<select class="form-select" id="state" name="state">
<option value="draft" t-att-selected="event.state == 'draft'">Draft</option>
<option value="confirmed" t-att-selected="event.state == 'confirmed'">Confirmed</option>
<option value="in_progress" t-att-selected="event.state == 'in_progress'">In Progress</option>
<option value="completed" t-att-selected="event.state == 'completed'">Completed</option>
<option value="cancelled" t-att-selected="event.state == 'cancelled'">Cancelled</option>
</select>
</div>
</div>
<div class="col-md-6">
<!-- Team -->
<div class="mb-3">
<label for="team_id" class="form-label">Team *</label>
<select class="form-select" id="team_id" name="team_id" required="true">
<option value="">Select a team...</option>
<t t-foreach="teams" t-as="team">
<option t-att-value="team.id" t-att-selected="event.team_id.id == team.id">
<t t-esc="team.name"/>
</option>
</t>
</select>
<div class="invalid-feedback">
Please select a team.
</div>
</div>
<!-- Venue -->
<div class="mb-3">
<label for="venue_id" class="form-label">Venue</label>
<select class="form-select" id="venue_id" name="venue_id">
<option value="">Select a venue...</option>
<t t-foreach="venues" t-as="venue">
<option t-att-value="venue.id" t-att-selected="event.venue_id.id == venue.id">
<t t-esc="venue.name"/>
</option>
</t>
</select>
</div>
</div>
</div>
<!-- Description -->
<div class="mb-3">
<label for="description" class="form-label">Description</label>
<textarea class="form-control" id="description" name="description" rows="4"
t-esc="event.description"></textarea>
</div>
</div>
</div>
<!-- Timing Information -->
<div class="card mt-3">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="fa fa-clock-o"/> Timing Information
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<!-- Event Start -->
<div class="mb-3">
<label for="date_start" class="form-label">Event Start *</label>
<input type="datetime-local" class="form-control" id="date_start" name="date_start"
t-att-value="event.date_start and event.date_start.strftime('%Y-%m-%dT%H:%M')" required="true"/>
<div class="invalid-feedback">
Please provide an event start time.
</div>
</div>
<!-- Therapist Start -->
<div class="mb-3">
<label for="therapist_start" class="form-label">Therapist Start</label>
<input type="datetime-local" class="form-control" id="therapist_start" name="therapist_start"
t-att-value="event.therapist_start and event.therapist_start.strftime('%Y-%m-%dT%H:%M')"/>
<small class="form-text text-muted">Leave empty to use event start time</small>
</div>
</div>
<div class="col-md-6">
<!-- Event End -->
<div class="mb-3">
<label for="date_end" class="form-label">Event End *</label>
<input type="datetime-local" class="form-control" id="date_end" name="date_end"
t-att-value="event.date_end and event.date_end.strftime('%Y-%m-%dT%H:%M')" required="true"/>
<div class="invalid-feedback">
Please provide an event end time.
</div>
</div>
<!-- Therapist End -->
<div class="mb-3">
<label for="therapist_end" class="form-label">Therapist End</label>
<input type="datetime-local" class="form-control" id="therapist_end" name="therapist_end"
t-att-value="event.therapist_end and event.therapist_end.strftime('%Y-%m-%dT%H:%M')"/>
<small class="form-text text-muted">Leave empty to use event end time</small>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<!-- Assigned Staff -->
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="fa fa-user-md"/> Assigned Staff
</h5>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Treatment Professionals</label>
<t t-foreach="treatment_professionals" t-as="professional">
<div class="form-check">
<input class="form-check-input" type="checkbox"
t-att-id="'staff_%s' % professional.id"
name="assigned_staff_ids[]"
t-att-value="professional.id"
t-att-checked="professional in event.assigned_staff_ids"
data-debug-name="assigned_staff_ids[]"
t-att-data-debug-value="professional.id"/>
<label class="form-check-label" t-att-for="'staff_%s' % professional.id">
<span t-esc="professional.name"/>
<small class="text-muted d-block" t-esc="professional.email"/>
</label>
</div>
</t>
</div>
</div>
</div>
</div>
</div>
<!-- Form Actions -->
<div class="row mt-4">
<div class="col-12">
<div class="d-flex justify-content-end">
<a t-attf-href="/my/event/#{event.id}" class="btn btn-outline-secondary me-2">
<i class="fa fa-times"/> Cancel
</a>
<button type="submit" class="btn btn-primary">
<i class="fa fa-save"/> Save Changes
</button>
</div>
</div>
</div>
</form>
</div>
<!-- Form Validation Script -->
<script>
(function() {
'use strict';
window.addEventListener('load', function() {
var forms = document.getElementsByClassName('needs-validation');
var validation = Array.prototype.filter.call(forms, function(form) {
form.addEventListener('submit', function(event) {
if (form.checkValidity() === false) {
event.preventDefault();
event.stopPropagation();
}
form.classList.add('was-validated');
// Debug: Check all checkboxes on the page
var allStaffCheckboxes = form.querySelectorAll('input[name="assigned_staff_ids[]"]');
console.log('Total staff checkboxes found:', allStaffCheckboxes.length);
// Debug: Check which ones are selected
var checkedStaff = form.querySelectorAll('input[name="assigned_staff_ids[]"]:checked');
console.log('Checked staff checkboxes:', checkedStaff.length);
// Log the values being submitted for debugging
var staffValues = [];
checkedStaff.forEach(function(checkbox) {
staffValues.push(checkbox.value);
console.log('Checkbox checked:', checkbox.id, 'value:', checkbox.value, 'name:', checkbox.name);
});
console.log('Staff values being submitted:', staffValues);
// WORKAROUND: Remove all existing staff checkboxes and add single hidden input
// with comma-separated values
console.log('Applying workaround for staff selection...');
// Remove the name attribute from all checkboxes to prevent them from being submitted
allStaffCheckboxes.forEach(function(checkbox) {
checkbox.removeAttribute('name');
});
// Create a single hidden input with comma-separated staff IDs
var hiddenInput = document.createElement('input');
hiddenInput.type = 'hidden';
hiddenInput.name = 'assigned_staff_ids';
if (checkedStaff.length > 0) {
hiddenInput.value = staffValues.join(',');
console.log('Added hidden input with comma-separated staff IDs:', hiddenInput.value);
} else {
hiddenInput.value = '';
console.log('Added empty hidden input to clear staff selection');
}
form.appendChild(hiddenInput);
}, false);
});
}, false);
})();
</script>
</t>
</template>
</data>
</odoo>

View file

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<!-- Project Task Security Test Form View -->
<record id="view_project_task_security_test_form" model="ir.ui.view">
<field name="name">project.task.security.test.form</field>
<field name="model">project.task.security.test</field>
<field name="arch" type="xml">
<form string="Project Task Security Test">
<header>
<button name="action_test_field_access"
string="Run Field Access Test"
type="object"
class="btn-primary"/>
</header>
<sheet>
<group>
<group>
<field name="task_id"/>
<field name="user_id"/>
</group>
</group>
<group string="Test Results">
<field name="test_results" widget="text" nolabel="1"/>
</group>
</sheet>
</form>
</field>
</record>
<!-- Project Task Security Test Action -->
<record id="action_project_task_security_test" model="ir.actions.act_window">
<field name="name">Project Task Security Test</field>
<field name="res_model">project.task.security.test</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
<!-- Menu Item for Security Testing -->
<menuitem id="menu_project_task_security_test"
name="Task Security Test"
parent="sports_clinic_root"
action="action_project_task_security_test"
sequence="90"
groups="bemade_sports_clinic.group_sports_clinic_admin"/>
</data>
</odoo>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<data>
<!-- Project Task views cleaned up - event fields moved to sports.event model -->
<!-- Keep this file for future project.task customizations if needed -->
</data>
</odoo>

View file

@ -5,8 +5,17 @@
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="model">res.partner</field>
<field name="arch" type="xml">
<!-- Add is_venue field to the form using simple field position -->
<field name="name" position="after">
<group col="2">
<field name="is_venue" string="Is Venue" help="Check this box if this contact represents a venue/location" widget="boolean_toggle"/>
</group>
</field>
<page name="contact_addresses" position="after">
<page name="Teams">
<group>
<field name="is_venue" string="Is Venue" help="Check this box if this contact represents a venue/location"/>
</group>
<field invisible="is_company == False" name="owned_team_ids" string="Teams"/>
<field invisible="is_company == True" name="teams_served_ids" string="Teams Served">
<list editable="bottom" create="False" delete="True">
@ -19,6 +28,7 @@
</list>
</field>
</page>
</page></field>
</page>
</field>
</record>
</odoo>

View file

@ -2,24 +2,92 @@
<odoo>
<data>
<template id="portal_my_home" inherit_id="portal.portal_my_home">
<xpath expr="//div[hasclass('o_portal_docs')]" position="inside">
<t t-call="portal.portal_docs_entry">
<t t-set="title">Teams</t>
<t t-set="url">/my/teams</t>
<t t-set="placeholder_count">teams_count</t>
</t>
<t t-call="portal.portal_docs_entry">
<t t-set="title">Players</t>
<t t-set="url">/my/players</t>
<t t-set="placeholder_count">players_count</t>
</t>
<t t-call="portal.portal_docs_entry">
<t t-set="title">Activities</t>
<t t-set="url">/my/activities</t>
<t t-set="placeholder_count">activities_count</t>
</t>
<xpath expr="//div[@id='portal_common_category']" position="before">
<div class="o_portal_category row g-2 mt-3" id="portal_sports_category">
<t t-call="portal.portal_docs_entry">
<t t-set="title">Teams</t>
<t t-set="url">/my/teams</t>
<t t-set="placeholder_count">teams_count</t>
</t>
<t t-call="portal.portal_docs_entry">
<t t-set="title">Players</t>
<t t-set="url">/my/players</t>
<t t-set="placeholder_count">players_count</t>
</t>
<t t-call="portal.portal_docs_entry">
<t t-set="title">Activities</t>
<t t-set="url">/my/activities</t>
<t t-set="placeholder_count">activities_count</t>
<t t-set="config_card" t-value="request.env.user.has_group('bemade_sports_clinic.group_portal_treatment_professional')"/>
</t>
<t t-call="portal.portal_docs_entry">
<t t-set="title">Events</t>
<t t-set="url">/my/events</t>
<t t-set="placeholder_count">events_count</t>
<t t-set="config_card" t-value="request.env.user.has_group('bemade_sports_clinic.group_portal_treatment_professional')"/>
</t>
</div>
</xpath>
</template>
<!-- Hide Invoice cards for coaches and treatment professionals -->
<template id="portal_my_home_invoice_coach_override" inherit_id="account.portal_my_home_invoice" priority="50">
<xpath expr="//div[@id='portal_client_category']" position="attributes">
<attribute name="t-if">not request.env.user.has_group('bemade_sports_clinic.group_portal_team_coach') and not request.env.user.has_group('bemade_sports_clinic.group_portal_treatment_professional')</attribute>
</xpath>
<xpath expr="//div[@id='portal_vendor_category']" position="attributes">
<attribute name="t-if">not request.env.user.has_group('bemade_sports_clinic.group_portal_team_coach') and not request.env.user.has_group('bemade_sports_clinic.group_portal_treatment_professional')</attribute>
</xpath>
</template>
<!-- Hide Project/Tasks cards for coaches and treatment professionals -->
<template id="portal_my_home_project_coach_override" inherit_id="project.portal_my_home" priority="50">
<xpath expr="//div[@id='portal_service_category']" position="attributes">
<attribute name="t-if">not request.env.user.has_group('bemade_sports_clinic.group_portal_team_coach') and not request.env.user.has_group('bemade_sports_clinic.group_portal_treatment_professional')</attribute>
</xpath>
</template>
<!-- Add custom CSS and classes to hide cards for sports clinic users -->
<template id="portal_my_home_sports_clinic_styles" inherit_id="portal.portal_my_home" priority="60">
<xpath expr="//div[hasclass('o_portal_my_home')]" position="attributes">
<attribute name="t-attf-class">o_portal_my_home #{request.env.user.has_group('bemade_sports_clinic.group_portal_team_coach') and 'o_portal_user_coach' or ''} #{request.env.user.has_group('bemade_sports_clinic.group_portal_treatment_professional') and 'o_portal_user_therapist' or ''}</attribute>
</xpath>
<xpath expr="//div[hasclass('o_portal_my_home')]" position="before">
<style>
/* Hide knowledge cards for coaches only (keep visible for therapists) */
.o_portal_user_coach .o_portal_docs_entry[href*="/my/knowledge"],
.o_portal_user_coach .o_portal_category [href*="/my/knowledge"],
.o_portal_user_coach .o_portal_category [href*="knowledge"] {
display: none !important;
}
/* Hide Activities cards for coaches only (keep visible for therapists) */
.o_portal_user_coach .o_portal_docs_entry[href*="/my/activities"],
.o_portal_user_coach .o_portal_category [href*="/my/activities"] {
display: none !important;
}
/* Hide payment cards for both coaches and therapists */
.o_portal_user_coach .o_portal_docs_entry[href*="/my/payment"],
.o_portal_user_coach .o_portal_category [href*="/my/payment"],
.o_portal_user_therapist .o_portal_docs_entry[href*="/my/payment"],
.o_portal_user_therapist .o_portal_category [href*="/my/payment"] {
display: none !important;
}
/* Hide website/ecommerce cards for both coaches and therapists */
.o_portal_user_coach .o_portal_docs_entry[href*="/shop"],
.o_portal_user_coach .o_portal_category [href*="/shop"],
.o_portal_user_therapist .o_portal_docs_entry[href*="/shop"],
.o_portal_user_therapist .o_portal_category [href*="/shop"] {
display: none !important;
}
</style>
</xpath>
</template>
<template id="portal_my_teams">
<t t-call="portal.portal_layout">
<h1>Your Teams</h1>
@ -30,8 +98,8 @@
<th>Parent Organization</th>
<th>Total Players</th>
<th>Injured</th>
<th>Activities</th>
<th>Actions</th>
<th t-if="request.env.user.has_group('bemade_sports_clinic.group_portal_treatment_professional')">Activities</th>
<th t-if="request.env.user.has_group('bemade_sports_clinic.group_portal_treatment_professional')">Actions</th>
</tr>
</thead>
<tbody>
@ -46,11 +114,11 @@
<td><span t-field="team.parent_id"/></td>
<td><span t-field="team.player_count"/></td>
<td><span t-field="team.injured_count"/></td>
<td>
<td t-if="request.env.user.has_group('bemade_sports_clinic.group_portal_treatment_professional')">
<t t-set="team_activity_count" t-value="team.activity_count or 0"/>
<span t-esc="team_activity_count"/>
</td>
<td>
<td t-if="request.env.user.has_group('bemade_sports_clinic.group_portal_treatment_professional')">
<a t-attf-href="/my/activities?model=sports.team&amp;res_id={{ team.id }}"
class="btn bg-o-color-1 text-white btn-sm">
<i class="fa fa-tasks"></i> Activities
@ -109,7 +177,7 @@
</t>
</h1>
<div>
<a t-attf-href="/my/activities?model=sports.team&amp;res_id={{ team.id }}" class="btn bg-o-color-2 text-white me-2">
<a t-if="request.env.user.has_group('bemade_sports_clinic.group_portal_treatment_professional')" t-attf-href="/my/activities?model=sports.team&amp;res_id={{ team.id }}" class="btn bg-o-color-2 text-white me-2">
<i class="fa fa-tasks"></i> Activities
</a>
<a t-if="is_treatment_prof" t-attf-href="/my/activity/create?model=sports.team&amp;res_id={{ team.id }}" class="btn bg-o-color-2 text-white me-2">
@ -334,9 +402,9 @@
<i class="fa fa-edit"></i> Edit Player
</a>
<a t-att-href="'/my/patient/injury/new?patient_id=%s' % player.id" class="btn bg-o-color-1 text-white me-2">
<i class="fa fa-plus"></i> Report Injury
<i class="fa fa-plus"></i> <span t-if="request.env.user.has_group('bemade_sports_clinic.group_portal_treatment_professional')">Add Injury</span><span t-else="">Report Injury</span>
</a>
<a t-attf-href="/my/activities?model=sports.patient&amp;res_id={{ player.id }}" class="btn bg-o-color-2 text-white me-2">
<a t-if="request.env.user.has_group('bemade_sports_clinic.group_portal_treatment_professional')" t-attf-href="/my/activities?model=sports.patient&amp;res_id={{ player.id }}" class="btn bg-o-color-2 text-white me-2">
<i class="fa fa-tasks"></i> Activities
</a>
<a t-if="is_treatment_prof" t-att-href="'/my/activity/create?model=sports.patient&amp;res_id=%s' % player.id" class="btn bg-o-color-2 text-white me-2">
@ -456,7 +524,7 @@
<t t-if="not is_treatment_prof">
<th>External Notes</th>
</t>
<th>Activities</th>
<th t-if="request.env.user.has_group('bemade_sports_clinic.group_portal_treatment_professional')">Activities</th>
<t t-if="is_treatment_prof">
<th class="text-end">Actions</th>
</t>
@ -493,7 +561,7 @@
<div t-else="" class="text-muted">No external notes</div>
</td>
</t>
<td>
<td t-if="request.env.user.has_group('bemade_sports_clinic.group_portal_treatment_professional')">
<div class="d-flex align-items-center">
<t t-set="injury_activity_count" t-value="injury.activity_count or 0"/>
<span t-esc="injury_activity_count" class="me-2"/>
@ -530,7 +598,7 @@
<i class="fa fa-ambulance fa-3x mb-3"></i>
<p>No injuries recorded</p>
<a t-att-href="'/my/patient/injury/new?patient_id=%s' % player.id" class="btn bg-o-color-1 text-white">
<i class="fa fa-plus"></i> Report First Injury
<i class="fa fa-plus"></i> <span t-if="request.env.user.has_group('bemade_sports_clinic.group_portal_treatment_professional')">Add First Injury</span><span t-else="">Report First Injury</span>
</a>
</div>
</div>

View file

@ -0,0 +1,213 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<data>
<!-- Sports Event List View -->
<record id="sports_event_view_list" model="ir.ui.view">
<field name="name">sports.event.list</field>
<field name="model">sports.event</field>
<field name="arch" type="xml">
<list string="Sports Events" default_order="date_start desc">
<field name="name"/>
<field name="description" widget="html"/>
<field name="event_type"/>
<field name="team_id"/>
<field name="partner_id"/>
<field name="date_start" widget="datetime"/>
<field name="date_end" widget="datetime"/>
<field name="duration" widget="float_time"/>
<field name="therapist_start" widget="datetime"/>
<field name="therapist_end" widget="datetime"/>
<field name="therapist_duration" widget="float_time"/>
<field name="venue_id"/>
<field name="assigned_staff_ids" widget="many2many_tags"/>
</list>
</field>
</record>
<!-- Sports Event Form View -->
<record id="sports_event_view_form" model="ir.ui.view">
<field name="name">sports.event.form</field>
<field name="model">sports.event</field>
<field name="arch" type="xml">
<form string="Sports Event">
<header>
<button name="create_management_task" type="object"
string="Create Management Task"
class="btn-primary"
invisible="task_id != False"
groups="base.group_user"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,confirmed,in_progress,completed"
clickable="1"/>
</header>
<sheet>
<div class="oe_title">
<h1>
<field name="name" placeholder="Event Name"/>
</h1>
</div>
<group>
<group string="Event Details">
<field name="event_type"/>
<field name="team_id" required="1"/>
<field name="partner_id" readonly="1"/>
<field name="venue_id"/>
</group>
<group string="Event Timing">
<field name="date_start" widget="datetime"/>
<field name="date_end" widget="datetime"/>
<field name="duration" widget="float_time" readonly="1"/>
<field name="is_today" invisible="1"/>
<field name="is_upcoming" invisible="1"/>
</group>
</group>
<group string="Therapist Coverage">
<group>
<field name="therapist_start" widget="datetime"/>
<field name="therapist_end" widget="datetime"/>
</group>
<group>
<field name="therapist_duration" widget="float_time" readonly="1"/>
</group>
</group>
<notebook>
<page string="Description">
<field name="description" widget="html"/>
</page>
<page string="Staff Assignment">
<group>
<label for="assigned_staff_ids" string="Assigned Staff"/>
<field name="assigned_staff_ids" widget="many2many_checkboxes" nolabel="1">
<list>
<field name="name"/>
<field name="email"/>
<field name="phone"/>
</list>
</field>
</group>
</page>
<page string="Task Integration" groups="base.group_user">
<group>
<field name="task_id" readonly="1"/>
<field name="project_id" readonly="1"/>
</group>
<div class="alert alert-info" role="alert" invisible="task_id != False">
<p><strong>No management task created yet.</strong></p>
<p>Click "Create Management Task" to create an internal project task for managing this event.</p>
</div>
</page>
</notebook>
</sheet>
<chatter reload_on_post="True"/>
</form>
</field>
</record>
<!-- Sports Event Search View -->
<record id="sports_event_view_search" model="ir.ui.view">
<field name="name">sports.event.search</field>
<field name="model">sports.event</field>
<field name="arch" type="xml">
<search string="Sports Events">
<field name="name"/>
<field name="team_id"/>
<field name="partner_id"/>
<field name="venue_id"/>
<field name="assigned_staff_ids"/>
<field name="event_type"/>
<filter name="today" string="Today"
domain="[('date_start', '>=', context_today().strftime('%Y-%m-%d')),
('date_start', '&lt;', (context_today() + datetime.timedelta(days=1)).strftime('%Y-%m-%d'))]"/>
<filter name="upcoming" string="Upcoming"
domain="[('date_start', '>=', context_today().strftime('%Y-%m-%d'))]"/>
<filter name="this_week" string="This Week"
domain="[('date_start', '>=', (context_today() - datetime.timedelta(days=context_today().weekday())).strftime('%Y-%m-%d')),
('date_start', '&lt;', (context_today() + datetime.timedelta(days=7-context_today().weekday())).strftime('%Y-%m-%d'))]"/>
<separator/>
<filter name="draft" string="Draft"
domain="[('state', '=', 'draft')]"/>
<filter name="confirmed" string="Confirmed"
domain="[('state', '=', 'confirmed')]"/>
<filter name="in_progress" string="In Progress"
domain="[('state', '=', 'in_progress')]"/>
<filter name="completed" string="Completed"
domain="[('state', '=', 'completed')]"/>
<separator/>
<group expand="0" string="Group By">
<filter name="group_partner" string="Organization"
context="{'group_by': 'partner_id'}"/>
<filter name="group_team" string="Team"
context="{'group_by': 'team_id'}"/>
<filter name="group_event_type" string="Event Type"
context="{'group_by': 'event_type'}"/>
<filter name="group_state" string="Status"
context="{'group_by': 'state'}"/>
<filter name="group_date" string="Date"
context="{'group_by': 'date_start:day'}"/>
</group>
</search>
</field>
</record>
<!-- Sports Event Calendar View -->
<record id="sports_event_view_calendar" model="ir.ui.view">
<field name="name">sports.event.calendar</field>
<field name="model">sports.event</field>
<field name="arch" type="xml">
<calendar string="Sports Events Calendar"
date_start="therapist_start"
date_stop="therapist_end"
color="team_id"
event_open_popup="True">
<field name="name"/>
<field name="description"/>
<field name="team_id"/>
<field name="partner_id"/>
<field name="venue_id"/>
<field name="event_type"/>
<field name="date_start"/>
<field name="date_end"/>
</calendar>
</field>
</record>
<!-- Sports Event Action -->
<record id="sports_event_action" model="ir.actions.act_window">
<field name="name">Sports Events</field>
<field name="res_model">sports.event</field>
<field name="view_mode">calendar,list,form</field>
<field name="search_view_id" ref="sports_event_view_search"/>
<field name="context">{
'search_default_upcoming': 1,
}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create your first sports event!
</p>
<p>
Sports events help you manage games, practices, training sessions, and team meetings.
Each event can be linked to a project task for internal management.
</p>
</field>
</record>
<!-- Menu Items -->
<menuitem id="sports_event_menu"
name="Events"
parent="sports_clinic_root"
action="sports_event_action"
sequence="30"/>
</data>
</odoo>

View file

@ -4,54 +4,190 @@
<!-- Template for creating a new injury via the portal -->
<template id="portal_create_injury">
<t t-call="portal.portal_layout">
<div class="container">
<div class="container pt-3">
<div class="row">
<div class="col-md-12">
<h1>Report Injury for <t t-esc="patient.name"/></h1>
<form action="/my/patient/injury/create" method="post" class="mt-3">
<div class="col-lg-12">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a t-att-href="'/my/player?player_id=%s' % patient.id">
<t t-esc="patient.name"/>
</a>
</li>
<li class="breadcrumb-item active" aria-current="page">
<span t-if="is_treatment_prof">Add Injury</span>
<span t-else="">Report Injury</span>
</li>
</ol>
</nav>
</div>
</div>
<div class="row mt-3">
<div class="col-lg-12">
<h2><span t-if="is_treatment_prof">Add Injury for</span><span t-else="">Report Injury for</span> <t t-esc="patient.name"/></h2>
<form action="/my/patient/injury/create" method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<input type="hidden" name="patient_id" t-att-value="patient.id"/>
<div class="form-group row">
<label for="injury_date" class="col-md-3 col-form-label">Injury Date</label>
<div class="col-md-9">
<input type="date" class="form-control" id="injury_date" name="injury_date" required="required"/>
</div>
</div>
<div class="form-group row">
<label for="diagnosis" class="col-md-3 col-form-label">Diagnosis</label>
<div class="col-md-9">
<input type="text" class="form-control" id="diagnosis" name="diagnosis" required="required"/>
</div>
</div>
<div class="form-group row">
<label for="predicted_resolution_date" class="col-md-3 col-form-label">Predicted Resolution Date</label>
<div class="col-md-9">
<input type="date" class="form-control" id="predicted_resolution_date" name="predicted_resolution_date"/>
</div>
</div>
<!-- Only show parental consent field for treatment professionals -->
<t t-if="is_treatment_prof">
<div class="form-group row">
<label for="parental_consent" class="col-md-3 col-form-label">Consent for Disclosure to Parent</label>
<div class="col-md-9">
<select class="form-control" id="parental_consent" name="parental_consent" required="required">
<option value="yes">Yes</option>
<option value="no">No</option>
<option value="na">Not Applicable</option>
</select>
<input type="hidden" name="return_url" t-att-value="return_url"/>
<!-- Top Action Buttons -->
<div class="row mb-4">
<div class="col-lg-12">
<div class="d-flex justify-content-between">
<a t-att-href="return_url" class="btn bg-o-color-2 text-white">
<i class="fa fa-times"></i> Cancel
</a>
<button type="submit" class="btn bg-o-color-1 text-white">
<i class="fa fa-save"></i> <span t-if="is_treatment_prof">Create Injury</span><span t-else="">Submit Report</span>
</button>
</div>
</div>
</t>
<div class="form-group row">
<label for="external_notes" class="col-md-3 col-form-label">Notes</label>
<div class="col-md-9">
<textarea class="form-control" id="external_notes" name="external_notes" rows="5"></textarea>
</div>
<!-- Injury Details Section -->
<div class="card mb-4">
<div class="card-header bg-primary text-white">
<h4 class="mb-0">Injury Details</h4>
</div>
<div class="card-body">
<!-- Basic injury information -->
<div class="row">
<div class="col-lg-6">
<div class="form-group">
<label for="injury_date">Date of Injury</label>
<div class="d-flex align-items-center">
<input type="date" name="injury_date" id="injury_date" class="form-control"
required="required"/>
<div class="form-check ml-3">
<input type="checkbox" name="injury_date_na" id="injury_date_na" class="form-check-input"/>
<label for="injury_date_na" class="form-check-label">N/A</label>
</div>
</div>
<small class="text-muted">Check N/A if exact date is unknown</small>
</div>
</div>
<div t-if="is_treatment_prof" class="col-lg-6">
<div class="form-group">
<label for="predicted_resolution_date">Predicted Resolution Date</label>
<input type="date" name="predicted_resolution_date" id="predicted_resolution_date" class="form-control"/>
<small class="text-muted">Expected recovery timeline</small>
</div>
</div>
</div>
<!-- Diagnosis -->
<div class="row mt-3">
<div class="col-lg-12">
<div class="form-group">
<label for="diagnosis">Diagnosis/Description</label>
<textarea name="diagnosis" id="diagnosis" class="form-control"
rows="3" required="required"></textarea>
</div>
</div>
</div>
<!-- Treatment professionals and parental consent (only for treatment professionals) -->
<div t-if="is_treatment_prof" class="row mt-3">
<div class="col-lg-6">
<div class="form-group">
<label class="form-label">Treatment Professionals</label>
<div class="border rounded p-3" style="max-height: 200px; overflow-y: auto;">
<t t-foreach="treatment_professionals" t-as="tp">
<div class="form-check">
<input class="form-check-input" type="checkbox"
t-att-id="'tp_%s' % tp.id"
name="treatment_professional_ids[]"
t-att-value="tp.id"/>
<label class="form-check-label" t-att-for="'tp_%s' % tp.id">
<span t-esc="tp.name"/>
<small class="text-muted d-block" t-esc="tp.email"/>
</label>
</div>
</t>
</div>
<small class="text-muted">Select multiple professionals (you will be added automatically)</small>
</div>
</div>
<div class="col-lg-6">
<div class="form-group">
<label for="parental_consent">Parental Consent</label>
<select name="parental_consent" id="parental_consent" class="form-control" required="required">
<t t-foreach="parental_consent_options" t-as="option">
<option t-att-value="option[0]">
<t t-esc="option[1]"/>
</option>
</t>
</select>
</div>
</div>
</div>
<!-- Internal notes (only visible to treatment professionals) -->
<div t-if="is_treatment_prof" class="row mt-3">
<div class="col-lg-12">
<div class="form-group">
<label for="internal_notes">Internal Notes (Medical Staff Only)</label>
<textarea name="internal_notes" id="internal_notes" class="form-control"
rows="3"></textarea>
<small class="text-muted">Notes visible only to treatment professionals</small>
</div>
</div>
</div>
</div>
</div>
<div class="clearfix mb-3">
<button type="submit" class="btn bg-o-color-1 text-white float-right">Submit</button>
<a t-if="return_url" t-att-href="return_url" class="btn bg-o-color-2 text-white float-left">Cancel</a>
<!-- Add Treatment Note Section (only for treatment professionals) -->
<div t-if="is_treatment_prof" class="card mb-4">
<div class="card-header bg-info text-white">
<h4 class="mb-0">Add Treatment Note</h4>
</div>
<div class="card-body">
<div class="row">
<div class="col-lg-12">
<div class="form-group">
<label for="treatment_note">Treatment Note</label>
<textarea name="treatment_note" id="treatment_note" class="form-control" rows="4"
placeholder="Enter treatment note (will be saved with the current date)"></textarea>
<small class="text-muted">Optional: Add an initial treatment note if treatment was provided during injury assessment</small>
</div>
</div>
</div>
</div>
</div>
<!-- External Notes Section -->
<div class="card mb-4">
<div class="card-header bg-secondary text-white">
<h4 class="mb-0">External Notes</h4>
</div>
<div class="card-body">
<div class="row">
<div class="col-lg-12">
<div class="form-group">
<label for="external_notes">External Notes</label>
<textarea name="external_notes" id="external_notes" class="form-control"
rows="4"></textarea>
<small class="text-muted">Notes visible to all team members</small>
</div>
</div>
</div>
</div>
</div>
<!-- Action buttons -->
<div class="row mt-4">
<div class="col-lg-12">
<div class="float-left">
<a t-att-href="return_url" class="btn bg-o-color-2 text-white">Cancel</a>
<!-- Submit button -->
<button type="submit" class="btn bg-o-color-1 text-white">
<span t-if="is_treatment_prof">Create Injury</span><span t-else="">Submit Report</span>
</button>
</div>
</div>
</div>
</form>
</div>

View file

@ -49,6 +49,17 @@
widget="many2many_tags_avatar"/>
</group>
</group>
<notebook>
<page string="Treatment Notes">
<field name="treatment_note_ids">
<list string="Treatment Notes" editable="bottom">
<field name="date"/>
<field name="note"/>
<field name="user_id" readonly="1"/>
</list>
</field>
</page>
</notebook>
</sheet>
<chatter reload_on_post="True"/>
</form>

View file

@ -152,6 +152,17 @@
<page string="Injuries">
<field name="injury_ids"/>
</page>
<page string="Treatment Notes">
<field name="treatment_note_ids">
<list string="Treatment Notes" editable="bottom">
<field name="date"/>
<field name="injury_id" domain="[('patient_id', '=', parent.id)]"
options="{'no_create': True, 'no_open': False}"/>
<field name="note"/>
<field name="user_id" readonly="1"/>
</list>
</field>
</page>
<page string="Contacts">
<group>
<group string="Patient Phone &amp; Email">

View file

@ -0,0 +1,74 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<data>
<!-- Custom list view for project tasks in wizard -->
<record id="view_project_task_list_wizard" model="ir.ui.view">
<field name="name">project.task.list.wizard</field>
<field name="model">project.task</field>
<field name="type">list</field>
<field name="arch" type="xml">
<list>
<field name="name"/>
<field name="project_id"/>
<field name="date_start"/>
<field name="date_end"/>
<field name="date_deadline"/>
<field name="user_ids" widget="many2many_tags"/>
</list>
</field>
</record>
<!-- Task to Event Conversion Wizard Form -->
<record id="view_task_to_event_wizard_form" model="ir.ui.view">
<field name="name">task.to.event.wizard.form</field>
<field name="model">task.to.event.wizard</field>
<field name="arch" type="xml">
<form string="Convert Tasks to Sports Events">
<group>
<group>
<field name="task_count" readonly="1"/>
<field name="team_id"/>
<field name="event_type"/>
</group>
<group>
<field name="venue_id"/>
</group>
</group>
<notebook>
<page string="Selected Tasks" name="tasks">
<field name="task_ids" readonly="1" context="{'list_view_ref': 'bemade_sports_clinic.view_project_task_list_wizard'}"/>
</page>
</notebook>
<div class="alert alert-info" role="alert">
<strong>Conversion Details:</strong>
<ul>
<li>Each task will be converted to a separate sports event</li>
<li>Event dates will be taken from task start/end dates or deadline</li>
<li>If no end date exists, it will be set to start date + 2 hours</li>
<li>Task assignees will become event assigned staff</li>
<li>Original tasks will be linked to the created events</li>
</ul>
</div>
<footer>
<button string="Convert to Events" name="action_convert_tasks" type="object" class="btn-primary"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<!-- Wizard Action -->
<record id="action_task_to_event_wizard" model="ir.actions.act_window">
<field name="name">Convert Tasks to Sports Events</field>
<field name="res_model">task.to.event.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="binding_model_id" ref="project.model_project_task"/>
<field name="binding_view_types">list</field>
</record>
</data>
</odoo>

View file

@ -85,11 +85,4 @@
</field>
</record>
<!-- Menu Item -->
<menuitem id="menu_treatment_notes"
name="Treatment Notes"
parent="sports_clinic_root"
action="action_treatment_notes"
sequence="30"/>
</odoo>

View file

@ -0,0 +1 @@
from . import task_to_event_wizard

View file

@ -0,0 +1,202 @@
from odoo import api, fields, models
from odoo.exceptions import UserError
from datetime import timedelta
class TaskToEventWizard(models.TransientModel):
_name = 'task.to.event.wizard'
_description = 'Convert Project Tasks to Sports Events'
task_ids = fields.Many2many(
'project.task',
string='Selected Tasks',
required=True,
help='Tasks to convert to sports events'
)
team_id = fields.Many2one(
'sports.team',
string='Team',
required=True,
help='Team to assign to all created events'
)
event_type = fields.Selection([
('game', 'Game'),
('practice', 'Practice'),
('training', 'Training'),
('meeting', 'Team Meeting'),
('other', 'Other')
], string='Event Type', default='other', required=True,
help='Type of event for all created events')
venue_id = fields.Many2one(
'res.partner',
string='Venue',
domain=[('is_venue', '=', True)],
help='Default venue for all events (can be changed individually later)'
)
task_count = fields.Integer(
string='Number of Tasks',
compute='_compute_task_count'
)
@api.depends('task_ids')
def _compute_task_count(self):
for wizard in self:
wizard.task_count = len(wizard.task_ids)
def _extract_venue_from_description(self, description):
"""Extract venue from task description and find or create venue"""
import re
# Look for "Location: " in the description (case insensitive)
match = re.search(r'location:\s*(.+?)(?:\n|$)', description, re.IGNORECASE)
if not match:
return False
venue_name = match.group(1).strip()
if not venue_name:
return False
# Try to find existing venue by name
existing_venue = self.env['res.partner'].search([
('name', '=', venue_name),
('is_venue', '=', True)
], limit=1)
if existing_venue:
return existing_venue.id
# Create new venue if not found
new_venue = self.env['res.partner'].create({
'name': venue_name,
'is_company': True,
'is_venue': True,
'supplier_rank': 0,
'customer_rank': 0,
})
return new_venue.id
@api.model
def default_get(self, fields_list):
"""Set default task_ids from context"""
res = super().default_get(fields_list)
# Get selected tasks from context
active_ids = self.env.context.get('active_ids', [])
if active_ids and 'task_ids' in fields_list:
res['task_ids'] = [(6, 0, active_ids)]
return res
def action_convert_tasks(self):
"""Convert selected tasks to sports events"""
if not self.task_ids:
raise UserError("No tasks selected for conversion.")
if not self.team_id:
raise UserError("Please select a team for the events.")
# Ensure we have unique tasks (prevent duplicates)
unique_tasks = self.task_ids.filtered(lambda t: t.id)
if not unique_tasks:
raise UserError("No valid tasks found for conversion.")
# Check if any tasks have already been converted by this wizard
from datetime import timedelta as dt_timedelta
existing_events = self.env['sports.event'].search([
('task_id', 'in', unique_tasks.ids),
('create_date', '>=', fields.Datetime.now() - dt_timedelta(minutes=5)) # Recent conversions
])
if existing_events:
task_names = existing_events.mapped('task_id.name')
raise UserError(f"Some tasks appear to have been recently converted: {', '.join(task_names)}. "
f"Please check existing events before proceeding.")
created_events = self.env['sports.event']
processed_task_ids = set() # Track processed tasks to prevent duplicates
for task in unique_tasks:
# Skip if we've already processed this task (prevent duplicates within same run)
if task.id in processed_task_ids:
continue
processed_task_ids.add(task.id)
# Validate task has required information
if not task.name:
raise UserError(f"Task {task.id} has no name and cannot be converted.")
# Determine event dates from task planned dates
date_start = task.planned_date_begin or task.date_deadline
date_end = task.date_deadline
if not date_start:
raise UserError(f"Task '{task.name}' has no planned start date or deadline and cannot be converted.")
# If no end date, set it to start date + 2 hours (reasonable default)
if not date_end:
from datetime import timedelta
date_end = date_start + timedelta(hours=2)
# Ensure end date is after start date
if date_end <= date_start:
from datetime import timedelta
date_end = date_start + timedelta(hours=2)
# Extract venue from task description if available
venue_id = self.venue_id.id if self.venue_id else False
if task.description and not venue_id:
venue_id = self._extract_venue_from_description(task.description)
# Create the sports event
event_vals = {
'name': task.name,
'description': task.description or '',
'date_start': date_start,
'date_end': date_end,
'therapist_start': date_start, # Therapist start = event start
'therapist_end': date_end, # Therapist end = event end
'team_id': self.team_id.id,
'event_type': self.event_type,
'venue_id': venue_id,
'task_id': task.id, # Link back to original task
'project_id': task.project_id.id if task.project_id else False,
'state': 'confirmed',
}
# Set assigned staff from task users
if task.user_ids:
event_vals['assigned_staff_ids'] = [(6, 0, task.user_ids.ids)]
event = self.env['sports.event'].create(event_vals)
created_events |= event
# Add a note to the task about the conversion (without sending emails)
task.with_context(mail_notrack=True, mail_create_nolog=True).message_post(
body=f"Task converted to Sports Event: <a href='/web#id={event.id}&model=sports.event'>{event.name}</a>",
subject="Converted to Sports Event"
)
# Return action to view created events
if len(created_events) == 1:
return {
'type': 'ir.actions.act_window',
'name': 'Created Sports Event',
'res_model': 'sports.event',
'res_id': created_events.id,
'view_mode': 'form',
'target': 'current',
}
else:
return {
'type': 'ir.actions.act_window',
'name': 'Created Sports Events',
'res_model': 'sports.event',
'view_mode': 'list,form',
'domain': [('id', 'in', created_events.ids)],
'target': 'current',
}