diff --git a/fusion_projects/fusion_project_portal/__init__.py b/fusion_projects/fusion_project_portal/__init__.py new file mode 100644 index 00000000..e046e49f --- /dev/null +++ b/fusion_projects/fusion_project_portal/__init__.py @@ -0,0 +1 @@ +from . import controllers diff --git a/fusion_projects/fusion_project_portal/__manifest__.py b/fusion_projects/fusion_project_portal/__manifest__.py new file mode 100644 index 00000000..df7428c3 --- /dev/null +++ b/fusion_projects/fusion_project_portal/__manifest__.py @@ -0,0 +1,24 @@ +{ + 'name': 'Fusion Project Portal', + 'version': '19.0.3.0.0', + 'category': 'Project', + 'summary': 'Customer portal hierarchy + task creation, plus surfaces logged timesheets on the portal', + 'description': """ +- Hides sub-tasks from /my/projects/ so portal lists only top-level tasks. +- Adds + New Task / + New Sub-task buttons on portal (inline forms). +- Surfaces logged timesheets on the customer portal task page. +- Light UX inherit on the standard hr_timesheet stop-timer wizard so the + description prompt is mandatory and a bigger textarea is shown. The + Start/Pause/Stop button itself is the built-in one provided by + hr_timesheet/timesheet_grid -- we no longer ship a parallel timer. +""", + 'depends': ['project', 'hr_timesheet', 'timesheet_grid'], + 'data': [ + 'views/portal_templates.xml', + 'views/wizard_views.xml', + ], + 'installable': True, + 'application': False, + 'license': 'LGPL-3', + 'author': 'Fusion Central', +} diff --git a/fusion_projects/fusion_project_portal/controllers/__init__.py b/fusion_projects/fusion_project_portal/controllers/__init__.py new file mode 100644 index 00000000..8c3feb6f --- /dev/null +++ b/fusion_projects/fusion_project_portal/controllers/__init__.py @@ -0,0 +1 @@ +from . import portal diff --git a/fusion_projects/fusion_project_portal/controllers/portal.py b/fusion_projects/fusion_project_portal/controllers/portal.py new file mode 100644 index 00000000..781e691e --- /dev/null +++ b/fusion_projects/fusion_project_portal/controllers/portal.py @@ -0,0 +1,244 @@ +from odoo import http, _ +from odoo.exceptions import AccessError, MissingError +from odoo.http import request + +from odoo.addons.project.controllers.portal import ProjectCustomerPortal + + +class FusionProjectCustomerPortal(ProjectCustomerPortal): + + def _task_get_searchbar_sortings(self, milestones_allowed, project=False): + values = super()._task_get_searchbar_sortings(milestones_allowed, project) + values['sequence, id'] = { + 'label': _('Manual'), 'order': 'sequence, id', 'sequence': 5, + } + return values + + def _project_get_page_view_values(self, project, access_token, page=1, date_begin=None, + date_end=None, sortby=None, search=None, + search_in='content', groupby=None, **kwargs): + values = super()._project_get_page_view_values( + project, access_token, page, date_begin, date_end, + sortby, search, search_in, groupby, **kwargs, + ) + values['can_create_task'] = self._fp_can_create_task(project) + + groups = values.get('grouped_tasks') + depth_map = {} + if isinstance(groups, list): + new_groups = [] + for tasks in groups: + if not tasks: + new_groups.append(tasks) + continue + ordered_ids = self._fp_hierarchical_order(tasks, depth_map) + new_groups.append(tasks.browse(ordered_ids)) + values['grouped_tasks'] = new_groups + values['fp_task_depth'] = depth_map + return values + + def _fp_hierarchical_order(self, tasks, depth_map): + task_ids = set(tasks.ids) + children = {tid: [] for tid in task_ids} + roots = [] + for t in tasks: + if t.parent_id and t.parent_id.id in task_ids: + children[t.parent_id.id].append(t.id) + else: + roots.append((t.sequence, t.id)) + roots.sort() + + all_tasks_by_id = {t.id: t for t in tasks} + for tid in children: + children[tid].sort(key=lambda cid: (all_tasks_by_id[cid].sequence, cid)) + + order = [] + + def walk(tid, depth): + order.append(tid) + depth_map[tid] = depth + for child in children.get(tid, []): + walk(child, depth + 1) + + for _seq, r in roots: + walk(r, 0) + return order + + def _task_get_page_view_values(self, task, access_token, **kwargs): + values = super()._task_get_page_view_values(task, access_token, **kwargs) + descendants = [] + depth_map = {} + Task = request.env['project.task'].sudo() + + def walk(parent_id, depth): + children = Task.search( + [('parent_id', '=', parent_id)], + order='sequence, id', + ) + for c in children: + descendants.append(c) + depth_map[c.id] = depth + walk(c.id, depth + 1) + + walk(task.id, 0) + values['fp_task_descendants'] = descendants + values['fp_task_depth_inside'] = depth_map + values['fp_can_create_task'] = self._fp_can_create_task(task.project_id) + values['fp_priority_options'] = request.env['project.task']._fields['priority']._description_selection(request.env) + timesheets = request.env['account.analytic.line'].sudo().search( + [('task_id', '=', task.id)], + order='date desc, id desc', + ) + values['fp_timesheets'] = timesheets + values['fp_timesheets_total_hours'] = sum(timesheets.mapped('unit_amount')) + values['fp_state_options'] = task._fields['state']._description_selection(request.env) + values['fp_state_label'] = dict(values['fp_state_options']).get(task.state, task.state) + return values + + def _fp_can_create_task(self, project): + user = request.env.user + if not project or not project.exists(): + return False + if not user or user.id == request.env.ref('base.public_user').id: + return False + if user._is_internal(): + return True + partner = user.partner_id + if not partner: + return False + if project.partner_id and ( + partner == project.partner_id + or partner.parent_id == project.partner_id + or partner.commercial_partner_id == project.partner_id.commercial_partner_id + ): + return True + follower = request.env['mail.followers'].sudo().search_count([ + ('res_model', '=', 'project.project'), + ('res_id', '=', project.id), + ('partner_id', '=', partner.id), + ], limit=1) + return bool(follower) + + @http.route( + ['/my/projects//task//state'], + type='http', auth='user', website=True, methods=['POST'], + csrf=True, + ) + def fp_portal_task_set_state(self, project_id, task_id, **post): + try: + project_sudo = self._document_check_access('project.project', project_id) + except (AccessError, MissingError): + return request.redirect('/my') + + if not self._fp_can_create_task(project_sudo): + return request.redirect(f'/my/projects/{project_id}/task/{task_id}') + + task = request.env['project.task'].sudo().search([ + ('id', '=', task_id), + ('project_id', '=', project_id), + ], limit=1) + if not task: + return request.redirect(f'/my/projects/{project_id}') + + new_state = post.get('state') + allowed = ['01_in_progress', '02_changes_requested', '03_approved', '1_done'] + if new_state not in allowed: + return request.redirect(f'/my/projects/{project_id}/task/{task_id}') + + comment = (post.get('comment') or '').strip() + partner = request.env.user.partner_id + labels = dict(task._fields['state']._description_selection(request.env)) + label = labels.get(new_state, new_state) + + body = f'Status set to {label} by {partner.name} from the customer portal.' + if comment: + body += f'

Comment:
{comment}' + + task.write({'state': new_state}) + task.message_post( + body=body, + author_id=partner.id, + message_type='comment', + subtype_xmlid='mail.mt_comment', + ) + return request.redirect(f'/my/projects/{project_id}/task/{task_id}') + + @http.route( + [ + '/my/projects//task/new', + '/my/projects//task//subtask/new', + ], + type='http', auth='user', website=True, methods=['GET', 'POST'], + csrf=True, + ) + def fp_portal_task_new(self, project_id, parent_task_id=None, **post): + try: + project_sudo = self._document_check_access('project.project', project_id) + except (AccessError, MissingError): + return request.redirect('/my') + + if not self._fp_can_create_task(project_sudo): + return request.redirect(f'/my/projects/{project_id}') + + parent_task = None + if parent_task_id: + parent_task = request.env['project.task'].sudo().search([ + ('id', '=', parent_task_id), + ('project_id', '=', project_id), + ], limit=1) + if not parent_task: + return request.redirect(f'/my/projects/{project_id}') + + priority_selection = request.env['project.task']._fields['priority']._description_selection(request.env) + + render_values = { + 'project': project_sudo, + 'parent_task': parent_task, + 'priority_options': priority_selection, + 'errors': {}, + 'values': {}, + 'page_name': 'fp_new_task', + } + + if request.httprequest.method == 'POST': + name = (post.get('name') or '').strip() + description = (post.get('description') or '').strip() + priority = post.get('priority') or '0' + if priority not in dict(priority_selection): + priority = '0' + + errors = {} + if not name: + errors['name'] = 'Please enter a title.' + + if errors: + if parent_task: + return request.redirect(f'/my/projects/{project_id}/task/{parent_task.id}') + render_values.update({'errors': errors, 'values': post}) + return request.render('fusion_project_portal.portal_new_task_form', render_values) + + partner = request.env.user.partner_id + vals = { + 'name': name, + 'description': description, + 'priority': priority, + 'project_id': project_sudo.id, + 'partner_id': project_sudo.partner_id.id or partner.id, + 'message_partner_ids': [(4, partner.id)], + } + if parent_task: + vals['parent_id'] = parent_task.id + vals['display_in_project'] = False + + task = request.env['project.task'].sudo().create(vals) + task.sudo().message_post( + body=f'Submitted from customer portal by {partner.name}.', + author_id=partner.id, + message_type='comment', + subtype_xmlid='mail.mt_comment', + ) + if parent_task: + return request.redirect(f'/my/projects/{project_id}/task/{parent_task.id}') + return request.redirect(f'/my/projects/{project_id}/task/{task.id}') + + return request.render('fusion_project_portal.portal_new_task_form', render_values) diff --git a/fusion_projects/fusion_project_portal/views/portal_templates.xml b/fusion_projects/fusion_project_portal/views/portal_templates.xml new file mode 100644 index 00000000..82d6b32c --- /dev/null +++ b/fusion_projects/fusion_project_portal/views/portal_templates.xml @@ -0,0 +1,335 @@ + + + + +