Portal Timesheets Feature\n- Add new portal timesheets list with edit modal: views/portal_timesheets_templates.xml\n- New controller: controllers/timesheets_portal.py\n- New model for event timesheets: models/sports_event_timesheet.py\n- Security: sports_event_timesheet_rules.xml + ir.model.access.csv updates\n\nEvent/Portal Integration\n- Wire timesheets into events portal: controllers/events_portal.py, views/portal_event_* templates, menus\n- Sports event model enhancements: models/sports_event.py\n\nGeneral Portal/View Updates\n- Updates across player/team/user/partner views for consistency and UX\n\nAsset Cleanup\n- Remove unused 24h datetime initializer from frontend bundles\n * Dropped bemade_sports_clinic/static/src/js/portal_datetime_24h.js from __manifest__.py assets\n\nNotes\n- No functional dependency on the 24h initializer remains; inputs use native datetime-local or explicit formatting in templates.
220 lines
10 KiB
Python
220 lines
10 KiB
Python
from odoo.addons.portal.controllers.portal import CustomerPortal, pager
|
|
from odoo import http, _, fields
|
|
from odoo.exceptions import AccessError, UserError
|
|
from datetime import datetime
|
|
import pytz
|
|
import logging
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class TimesheetsPortal(CustomerPortal):
|
|
def _parse_portal_datetime(self, val):
|
|
"""Parse a datetime-local input (YYYY-MM-DDTHH:MM[:SS]) from the portal
|
|
as user-local time and convert to UTC string suitable for fields.Datetime.
|
|
Returns False if empty.
|
|
"""
|
|
if not val:
|
|
return False
|
|
dt = None
|
|
try:
|
|
if 'T' in val:
|
|
try:
|
|
dt = datetime.strptime(val, '%Y-%m-%dT%H:%M')
|
|
except ValueError:
|
|
dt = datetime.strptime(val, '%Y-%m-%dT%H:%M:%S')
|
|
else:
|
|
try:
|
|
dt = datetime.strptime(val, '%Y-%m-%d %H:%M')
|
|
except ValueError:
|
|
dt = datetime.strptime(val, '%Y-%m-%d %H:%M:%S')
|
|
except ValueError:
|
|
# Fallback: let ORM try
|
|
return val
|
|
|
|
tz_name = (http.request.context.get('tz') if http.request and http.request.context else None) or \
|
|
(http.request.env.user.tz if http.request else None) or 'UTC'
|
|
try:
|
|
user_tz = pytz.timezone(tz_name)
|
|
except Exception:
|
|
user_tz = pytz.UTC
|
|
local_dt = user_tz.localize(dt)
|
|
utc_dt = local_dt.astimezone(pytz.UTC)
|
|
return fields.Datetime.to_string(utc_dt)
|
|
|
|
def _prepare_home_portal_values(self, counters):
|
|
vals = super()._prepare_home_portal_values(counters)
|
|
if 'event_timesheets_count' in counters:
|
|
user = http.request.env.user
|
|
if user.has_group('bemade_sports_clinic.group_portal_treatment_professional') or user.has_group('base.group_system'):
|
|
# Count timesheets owned by the current user OR by an internal user sharing the same partner
|
|
count_domain = ['|', ('user_id', '=', user.id), ('user_id.partner_id', '=', user.partner_id.id)]
|
|
count = http.request.env['sports.event.timesheet'].search_count(count_domain)
|
|
vals['event_timesheets_count'] = count
|
|
_logger.debug(
|
|
"[TimesheetsPortal] Home counter (event_timesheets_count) user=%s(partner=%s) domain=%s -> count=%s",
|
|
user.id, user.partner_id.id, count_domain, count,
|
|
)
|
|
return vals
|
|
|
|
def _prepare_timesheets_domain(self, user_only=True):
|
|
user = http.request.env.user
|
|
if not (user.has_group('bemade_sports_clinic.group_portal_treatment_professional') or user.has_group('base.group_system')):
|
|
# No access for non-therapists in portal
|
|
return [('id', '=', 0)]
|
|
domain = []
|
|
if user_only:
|
|
# Show records owned by the exact user or by an internal user sharing the same partner
|
|
domain.extend(['|', ('user_id', '=', user.id), ('user_id.partner_id', '=', user.partner_id.id)])
|
|
_logger.debug(
|
|
"[TimesheetsPortal] _prepare_timesheets_domain user=%s(partner=%s) user_only=%s -> %s",
|
|
user.id, user.partner_id.id, user_only, domain,
|
|
)
|
|
return domain
|
|
|
|
@http.route(['/my/sc/timesheets', '/my/sc/timesheets/page/<int:page>'], type='http', auth='user', website=True)
|
|
def view_timesheets(self, page=1, date_from=None, date_to=None, team_id=None, organization_id=None, group_by=None, sortby=None, search=None, **kw):
|
|
_logger.warning("[TimesheetsPortal] ENTER /my/sc/timesheets page=%s", page)
|
|
user = http.request.env.user
|
|
if not (user.has_group('bemade_sports_clinic.group_portal_treatment_professional') or user.has_group('base.group_system')):
|
|
raise AccessError(_("You don't have access to timesheets."))
|
|
|
|
domain = self._prepare_timesheets_domain(user_only=True)
|
|
|
|
# Filters
|
|
_logger.debug(
|
|
"[TimesheetsPortal] Incoming filters user=%s(partner=%s) date_from=%s date_to=%s team_id=%s org_id=%s group_by=%s sortby=%s search=%s",
|
|
user.id, user.partner_id.id, date_from, date_to, team_id, organization_id, group_by, sortby, search,
|
|
)
|
|
if date_from:
|
|
try:
|
|
dt = fields.Datetime.from_string(date_from)
|
|
domain.append(('coverage_start', '>=', dt))
|
|
except Exception:
|
|
pass
|
|
if date_to:
|
|
try:
|
|
dt = fields.Datetime.from_string(date_to)
|
|
domain.append(('coverage_start', '<=', dt))
|
|
except Exception:
|
|
pass
|
|
if team_id:
|
|
try:
|
|
domain.append(('event_id.team_id', '=', int(team_id)))
|
|
except Exception:
|
|
pass
|
|
if organization_id:
|
|
try:
|
|
domain.append(('event_id.partner_id', '=', int(organization_id)))
|
|
except Exception:
|
|
pass
|
|
if search:
|
|
domain.extend(['|', '|',
|
|
('event_id.name', 'ilike', search),
|
|
('event_id.team_id.name', 'ilike', search),
|
|
('event_id.partner_id.name', 'ilike', search)])
|
|
|
|
# Sorting
|
|
sort_options = {
|
|
'date': 'coverage_start asc',
|
|
'date_desc': 'coverage_start desc',
|
|
'team': 'event_id.team_id',
|
|
'org': 'event_id.partner_id',
|
|
'state': 'state',
|
|
}
|
|
order = sort_options.get(sortby, 'coverage_start asc')
|
|
|
|
Timesheet = http.request.env['sports.event.timesheet']
|
|
_logger.debug("[TimesheetsPortal] Final domain=%s order=%s", domain, order)
|
|
total = Timesheet.search_count(domain)
|
|
_logger.debug("[TimesheetsPortal] search_count=%s", total)
|
|
pgr = pager(url='/my/sc/timesheets', total=total, page=page, step=self._items_per_page,
|
|
url_args={'date_from': date_from, 'date_to': date_to, 'team_id': team_id, 'organization_id': organization_id,
|
|
'group_by': group_by, 'sortby': sortby, 'search': search})
|
|
timesheets = Timesheet.search(domain, order=order, limit=self._items_per_page, offset=pgr['offset'])
|
|
_logger.debug("[TimesheetsPortal] search result ids=%s", timesheets.ids)
|
|
|
|
# Filters data sources
|
|
teams = http.request.env['sports.team'].search([])
|
|
orgs = teams.mapped('parent_id').filtered(lambda p: p).sorted('name')
|
|
_logger.debug(
|
|
"[TimesheetsPortal] UI sources: teams=%s orgs=%s",
|
|
len(teams), len(orgs),
|
|
)
|
|
|
|
# Grouping structure
|
|
grouped = None
|
|
if group_by in ('date', 'organization', 'team'):
|
|
from collections import OrderedDict
|
|
grouped = OrderedDict()
|
|
for ts in timesheets:
|
|
if group_by == 'date':
|
|
key = (fields.Date.to_string(fields.Datetime.context_timestamp(user, ts.coverage_start).date()) if ts.coverage_start else 'No Date')
|
|
label = key
|
|
elif group_by == 'organization':
|
|
key = ts.event_id.partner_id.id or 0
|
|
label = ts.event_id.partner_id.name or _('No Organization')
|
|
else: # team
|
|
key = ts.event_id.team_id.id or 0
|
|
label = ts.event_id.team_id.name or _('No Team')
|
|
if key not in grouped:
|
|
grouped[key] = {'label': label, 'items': []}
|
|
grouped[key]['items'].append(ts)
|
|
grouped = list(grouped.values())
|
|
|
|
# Build user-local datetime strings for datetime-local inputs in the edit modal
|
|
# Format: '%Y-%m-%dT%H:%M' expected by HTML input type=datetime-local
|
|
local_dt_map = {}
|
|
for ts in timesheets:
|
|
def _fmt(dt):
|
|
if not dt:
|
|
return ''
|
|
try:
|
|
return fields.Datetime.context_timestamp(user, dt).strftime('%Y-%m-%dT%H:%M')
|
|
except Exception:
|
|
# Fallback to raw string
|
|
return fields.Datetime.to_string(dt)[:16].replace(' ', 'T')
|
|
local_dt_map[ts.id] = {
|
|
'travel_start': _fmt(ts.travel_start),
|
|
'coverage_start': _fmt(ts.coverage_start),
|
|
'coverage_end': _fmt(ts.coverage_end),
|
|
'travel_end': _fmt(ts.travel_end),
|
|
}
|
|
|
|
values = {
|
|
'page_name': 'timesheets',
|
|
'timesheets': timesheets,
|
|
'pager': pgr,
|
|
'default_url': '/my/sc/timesheets',
|
|
'date_from': date_from,
|
|
'date_to': date_to,
|
|
'team_id': int(team_id) if team_id else None,
|
|
'organization_id': int(organization_id) if organization_id else None,
|
|
'group_by': group_by,
|
|
'sortby': sortby,
|
|
'search': search,
|
|
'teams': teams,
|
|
'organizations': orgs,
|
|
'grouped': grouped,
|
|
# Mapping of timesheet.id -> localized datetime strings for edit modal fields
|
|
'timesheet_local_dt': local_dt_map,
|
|
}
|
|
return http.request.render('bemade_sports_clinic.portal_timesheets_list', values)
|
|
|
|
@http.route(['/my/sc/timesheet/<int:ts_id>/edit'], type='http', auth='user', website=True, methods=['POST'], csrf=False)
|
|
def edit_timesheet(self, ts_id, **post):
|
|
user = http.request.env.user
|
|
if not (user.has_group('bemade_sports_clinic.group_portal_treatment_professional') or user.has_group('base.group_system')):
|
|
raise AccessError(_("You don't have permission to edit timesheets."))
|
|
ts = http.request.env['sports.event.timesheet'].browse(ts_id)
|
|
if not ts.exists() or ts.user_id.id != user.id:
|
|
raise AccessError(_("You can only edit your own timesheets."))
|
|
if ts.state == 'invoiced':
|
|
raise UserError(_('This timesheet is invoiced and cannot be edited.'))
|
|
vals = {}
|
|
for k in ['travel_start', 'coverage_start', 'coverage_end', 'travel_end']:
|
|
if post.get(k):
|
|
vals[k] = self._parse_portal_datetime(post.get(k))
|
|
if vals:
|
|
ts.write(vals)
|
|
return http.request.redirect('/my/sc/timesheets?updated=1')
|