From b9152320d5c309aa3a741e1849e49f07780e1f1f Mon Sep 17 00:00:00 2001 From: Abdellatif Benzbiria Date: Fri, 2 Oct 2026 11:08:22 +0200 Subject: [PATCH 1/3] [14.0][IMP] project_worksheet --- project_worksheet/__manifest__.py | 2 +- project_worksheet/controllers/main.py | 15 ++ .../models/account_analytic_line.py | 29 +++- project_worksheet/models/project_worksheet.py | 162 ++++++++++++++++-- .../models/project_worksheet_line.py | 5 + .../reports/project_worksheet_report.xml | 2 +- .../security/project_worksheet_security.xml | 20 +++ .../views/portal_worksheet_templates.xml | 8 + .../views/project_worksheet_views.xml | 10 +- 9 files changed, 223 insertions(+), 30 deletions(-) diff --git a/project_worksheet/__manifest__.py b/project_worksheet/__manifest__.py index 7ae5d83a..5fa3f056 100644 --- a/project_worksheet/__manifest__.py +++ b/project_worksheet/__manifest__.py @@ -4,7 +4,7 @@ { "name": "Project Worksheet", "summary": "Manage field worksheets for project interventions", - "version": "14.0.2.1.0", + "version": "14.0.3.0.0", "category": "Project Management", "website": "https://github.com/OCA/project", "author": "Numigi, Odoo Community Association (OCA)", diff --git a/project_worksheet/controllers/main.py b/project_worksheet/controllers/main.py index 7c36049f..0f4b951c 100644 --- a/project_worksheet/controllers/main.py +++ b/project_worksheet/controllers/main.py @@ -15,11 +15,26 @@ class ProjectWorksheetPortal(http.Controller): ) def portal_worksheet_view(self, worksheet_id, access_token=None, **kw): worksheet = self._get_worksheet(worksheet_id, access_token) + backend_redirect = self._redirect_internal_user_to_backend(worksheet) + if backend_redirect: + return backend_redirect return request.render( "project_worksheet.portal_worksheet_template", {"worksheet": worksheet, "error": kw.get("error")} ) + def _redirect_internal_user_to_backend(self, worksheet): + # The portal link shared with the client must not expose the client + # portal page to internal Odoo users: send them to the backend form + # instead (same behaviour as a sale order). + user = request.env.user + if user.has_group("base.group_user"): + return request.redirect(self._get_backend_form_url(worksheet)) + return None + + def _get_backend_form_url(self, worksheet): + return "/web#id=%s&model=%s&view_type=form" % (worksheet.id, worksheet._name) + @http.route( ["/my/worksheet//accept"], type="http", diff --git a/project_worksheet/models/account_analytic_line.py b/project_worksheet/models/account_analytic_line.py index ca323c84..a1c58656 100644 --- a/project_worksheet/models/account_analytic_line.py +++ b/project_worksheet/models/account_analytic_line.py @@ -15,11 +15,19 @@ class AccountAnalyticLine(models.Model): help="Worksheet associated with this timesheet entry.", ) - @api.constrains("worksheet_id", "unit_amount", "task_id") + @api.constrains("worksheet_id", "unit_amount", "task_id", "date", "employee_id", "name") def _check_locked_worksheet(self): - # Prevent modification if the associated worksheet is approved - locked_lines = (line for line in self if self._is_worksheet_approved(line)) - for _line in locked_lines: + if self._is_worksheet_lock_bypassed(): + return + for line in self: + line._validate_not_linked_to_worksheet() + + def _is_worksheet_lock_bypassed(self): + # Verify if the action is triggered internally by the worksheet synchronization + return self.env.context.get("bypass_worksheet_lock") + + def _validate_not_linked_to_worksheet(self): + if self.worksheet_id: self._raise_locked_worksheet_error() def _is_worksheet_approved(self, line): @@ -35,15 +43,20 @@ def _raise_locked_worksheet_error(self): ) def unlink(self): - self._check_unlink_conditions() + if not self._is_worksheet_lock_bypassed(): + self._check_unlink_conditions() return super().unlink() def _check_unlink_conditions(self): - linked_lines = (line for line in self if line.worksheet_id) - for _line in linked_lines: + for line in self: + line._validate_unlink_allowed() + + def _validate_unlink_allowed(self): + if self.worksheet_id: self._raise_unlink_linked_line_error() def _raise_unlink_linked_line_error(self): raise UserError( - _("You cannot delete a timesheet line linked to a worksheet.") + "You cannot delete a timesheet line linked to a worksheet." ) + diff --git a/project_worksheet/models/project_worksheet.py b/project_worksheet/models/project_worksheet.py index 72da493f..ef40cc99 100644 --- a/project_worksheet/models/project_worksheet.py +++ b/project_worksheet/models/project_worksheet.py @@ -46,6 +46,12 @@ class ProjectWorksheet(models.Model): tracking=True, default=lambda self: self._default_supervisor_id(), ) + manager_approver_id = fields.Many2one( + comodel_name="res.users", + string="Manager Approver", + readonly=True, + copy=False, + ) date_start = fields.Date( string="Period Start", required=True, @@ -107,6 +113,9 @@ class ProjectWorksheet(models.Model): compute="_compute_total_hours", store=True, ) + internal_notes = fields.Text( + string="Notes", + ) def _default_supervisor_id(self): return self.env["hr.employee"].search([("user_id", "=", self.env.uid)], limit=1) @@ -126,20 +135,131 @@ def _get_parent_company(self): return False return self.project_id.partner_id.commercial_partner_id - @api.constrains("date_start", "date_end") - def _check_date_range(self): + @api.constrains("date_start", "date_end", "project_id", "supervisor_id") + def _check_period_and_overlap(self): for worksheet in self: worksheet._validate_date_chronology() + worksheet._validate_same_week() + worksheet._validate_no_overlap() + + def _validate_date_chronology(self): + if self._is_date_range_invalid(): + self._raise_date_range_error() + + def _is_date_range_invalid(self): + if not self.date_start or not self.date_end: + return False + return self.date_start > self.date_end + + def _raise_date_range_error(self): + raise ValidationError(_("Period Start cannot be strictly greater than Period End.")) + + def _validate_same_week(self): + if self._has_both_dates() and not self._is_same_week(): + self._raise_same_week_error() + + def _has_both_dates(self): + # Ensure both dates are set before performing calendar calculations + return bool(self.date_start and self.date_end) + + def _is_same_week(self): + # Extract ISO year and ISO week number to compare calendar weeks + start_iso = self.date_start.isocalendar() + end_iso = self.date_end.isocalendar() + return start_iso[0] == end_iso[0] and start_iso[1] == end_iso[1] + + def _raise_same_week_error(self): + raise ValidationError( + _("The period start and end dates must fall within the same calendar week (Monday to Sunday).") + ) + + def _validate_no_overlap(self): + if self._has_overlapping_worksheet(): + self._raise_overlap_error() + + def _has_overlapping_worksheet(self): + # Query the database to find any overlapping worksheet for the same context + domain = self._get_overlap_domain() + return bool(self.search_count(domain)) + + def _get_overlap_domain(self): + return [ + ("id", "!=", self.id), + ("project_id", "=", self.project_id.id), + ("supervisor_id", "=", self.supervisor_id.id), + ("date_start", "<=", self.date_end), + ("date_end", ">=", self.date_start), + ] + + def _raise_overlap_error(self): + raise ValidationError( + _("You cannot have overlapping worksheets for the same project and supervisor.") + ) def action_reset_to_draft(self): """ Allow supervisor to reset the worksheet to waiting approval state. """ self.ensure_one() - if self.state == 'open': - self.state = 'new' + self._check_reset_rights() + self._process_timesheets_for_reset() + self._invalidate_access_token() + self.write({"state": "new"}) + + def _check_reset_rights(self): + if self._is_worksheet_confirmed(): + self._check_user_is_manager() + + def _is_worksheet_confirmed(self): + return self.state == "confirmed" + + def _check_user_is_manager(self): + if not self._is_user_manager(): + self._raise_manager_access_error() + + def _is_user_manager(self): + return self.env.user.has_group("project_worksheet.group_project_worksheet_manager") + + def _raise_manager_access_error(self): + raise UserError( + _("Only a worksheet manager can reset a confirmed worksheet to draft.") + ) + + def _process_timesheets_for_reset(self): + if self.timesheet_ids: + self._check_and_unlink_timesheets() + + def _check_and_unlink_timesheets(self): + self._validate_no_locked_timesheets() + self._unlink_timesheets() + + def _validate_no_locked_timesheets(self): + if self._has_locked_timesheets(): + self._raise_locked_timesheet_error() + + def _has_locked_timesheets(self): + locked_timesheets = (t for t in self.timesheet_ids if self._is_timesheet_locked(t)) + return any(locked_timesheets) + + def _is_timesheet_locked(self, timesheet): + # A timesheet is locked if its state has progressed past draft or new + return timesheet.sheet_id.state in ("confirm", "done") + + def _raise_locked_timesheet_error(self): + raise UserError( + _("Some timesheets are already locked. A timesheet administrator must unlock them first.") + ) + + def _unlink_timesheets(self): + # Bypass timesheet deletion restrictions during worksheet reset + self.timesheet_ids.with_context(bypass_worksheet_lock=True).unlink() + + def _invalidate_access_token(self): + # Clear token so previously sent client links become invalid + self.access_token = False def action_open(self): self.ensure_one() self._validate_approval_conditions() + self._generate_timesheets_if_empty() self.write({"state": "open"}) def _validate_approval_conditions(self): @@ -156,7 +276,6 @@ def _raise_empty_worksheet_error(self): def action_send_to_client(self): self.ensure_one() self._portal_ensure_token() - self._generate_timesheets_if_empty() self._send_approval_email() self.write({ "state": "pending", @@ -170,7 +289,10 @@ def action_remind_client(self): def action_manager_confirm(self): self.ensure_one() + self.manager_approver_id = self.env.user self._confirm_worksheet("manager") + message = _("Worksheet approved internally by manager: %s") % self.env.user.name + self.message_post(body=message) def action_client_confirm(self): self.ensure_one() @@ -187,18 +309,6 @@ def _compute_access_url(self): worksheet.access_token, ) - def _validate_date_chronology(self): - if self._is_date_range_invalid(): - self._raise_date_range_error() - - def _is_date_range_invalid(self): - if not self.date_start or not self.date_end: - return False - return self.date_start > self.date_end - - def _raise_date_range_error(self): - raise ValidationError(_("Period Start cannot be strictly greater than Period End.")) - def _generate_timesheets_if_empty(self): if not self.timesheet_ids: self._generate_timesheets() @@ -270,7 +380,9 @@ def _get_root_user_record(self): return self.with_user(root_user) def _generate_timesheets(self): - timesheet_model = self.env["account.analytic.line"].sudo() + timesheet_model = self.env["account.analytic.line"].sudo().with_context( + bypass_worksheet_lock=True + ) for line in self.line_ids: self._create_single_timesheet(timesheet_model, line) @@ -334,3 +446,17 @@ def _get_reminder_user_id(self): if self.supervisor_id.user_id: return self.supervisor_id.user_id.id return self.create_uid.id + + def unlink(self): + self._check_unlink_allowed() + return super().unlink() + + def _check_unlink_allowed(self): + for worksheet in self: + worksheet._validate_state_for_unlink() + + def _validate_state_for_unlink(self): + if not self.state == "new": + raise UserError( + _("You can only delete a worksheet in the 'New' state") + ) diff --git a/project_worksheet/models/project_worksheet_line.py b/project_worksheet/models/project_worksheet_line.py index b630dde8..60fa7c58 100644 --- a/project_worksheet/models/project_worksheet_line.py +++ b/project_worksheet/models/project_worksheet_line.py @@ -8,6 +8,11 @@ class ProjectWorksheetLine(models.Model): _name = "project.worksheet.line" _description = "Project Worksheet Line" + _order = "sequence, date, id" + + sequence = fields.Integer( + default=10, + ) worksheet_id = fields.Many2one( comodel_name="project.worksheet", diff --git a/project_worksheet/reports/project_worksheet_report.xml b/project_worksheet/reports/project_worksheet_report.xml index 3a38674a..aded4703 100644 --- a/project_worksheet/reports/project_worksheet_report.xml +++ b/project_worksheet/reports/project_worksheet_report.xml @@ -107,7 +107,7 @@
- +
diff --git a/project_worksheet/security/project_worksheet_security.xml b/project_worksheet/security/project_worksheet_security.xml index efb2dc06..2c261080 100644 --- a/project_worksheet/security/project_worksheet_security.xml +++ b/project_worksheet/security/project_worksheet_security.xml @@ -30,5 +30,25 @@ ['|', ('worksheet_id.company_id', '=', False), ('worksheet_id.company_id', 'in', company_ids)] + + + Worksheet: Supervisor Visibility + + + ['|', + ('supervisor_id.user_id', '=', user.id), + ('supervisor_id.parent_id.user_id', '=', user.id) + ] + + + + + + + Worksheet: Manager Visibility + + [(1, '=', 1)] + + \ No newline at end of file diff --git a/project_worksheet/views/portal_worksheet_templates.xml b/project_worksheet/views/portal_worksheet_templates.xml index 966638bb..2ca08c76 100644 --- a/project_worksheet/views/portal_worksheet_templates.xml +++ b/project_worksheet/views/portal_worksheet_templates.xml @@ -44,6 +44,14 @@
Total Hours:
+ +
+
+
Notes:
+

+

+
+
diff --git a/project_worksheet/views/project_worksheet_views.xml b/project_worksheet/views/project_worksheet_views.xml index ff211f6c..f291b983 100644 --- a/project_worksheet/views/project_worksheet_views.xml +++ b/project_worksheet/views/project_worksheet_views.xml @@ -76,7 +76,7 @@