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/i18n/fr.po b/project_worksheet/i18n/fr.po index 7167f09e..bf0a551e 100644 --- a/project_worksheet/i18n/fr.po +++ b/project_worksheet/i18n/fr.po @@ -6,8 +6,8 @@ msgid "" msgstr "" "Project-Id-Version: Odoo Server 14.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-16 08:53+0000\n" -"PO-Revision-Date: 2026-09-16 08:53+0000\n" +"POT-Creation-Date: 2026-10-02 09:10+0000\n" +"PO-Revision-Date: 2026-10-02 09:10+0000\n" "Last-Translator: \n" "Language-Team: \n" "MIME-Version: 1.0\n" @@ -15,6 +15,11 @@ msgstr "" "Content-Transfer-Encoding: \n" "Plural-Forms: \n" +#. module: project_worksheet +#: model:mail.template,report_name:project_worksheet.email_template_worksheet_confirmed +msgid "${object.name}" +msgstr "Feuille_installation_${object.name}" + #. module: project_worksheet #: model:ir.actions.report,print_report_name:project_worksheet.action_report_project_worksheet msgid "'Worksheet - %s' % (object.name)" @@ -158,6 +163,11 @@ msgstr "Date et HeurePrinted Name" msgstr "Nom imprimé" +#. module: project_worksheet +#: model_terms:ir.ui.view,arch_db:project_worksheet.portal_worksheet_template +msgid "Notes:" +msgstr "Notes :" + #. module: project_worksheet #: model_terms:ir.ui.view,arch_db:project_worksheet.portal_worksheet_template msgid "Project:" @@ -203,6 +213,11 @@ msgstr "Statut de l'activité" msgid "Activity Type Icon" msgstr "Icône de type d'activité" +#. module: project_worksheet +#: model_terms:ir.ui.view,arch_db:project_worksheet.project_worksheet_view_form +msgid "Add internal notes here..." +msgstr "Ajouter des notes internes ici..." + #. module: project_worksheet #: model:ir.model,name:project_worksheet.model_account_analytic_line msgid "Analytic Line" @@ -301,6 +316,7 @@ msgstr "URL du portail client" #. module: project_worksheet #: model:ir.model.fields,field_description:project_worksheet.field_project_worksheet_line__date +#: model_terms:ir.ui.view,arch_db:project_worksheet.portal_worksheet_template #: model_terms:ir.ui.view,arch_db:project_worksheet.report_worksheet_document msgid "Date" msgstr "Date" @@ -493,6 +509,11 @@ msgstr "Délai d'approbation responsable" msgid "Manager Approval Rules" msgstr "Règles d'approbation responsable" +#. module: project_worksheet +#: model:ir.model.fields,field_description:project_worksheet.field_project_worksheet__manager_approver_id +msgid "Manager Approver" +msgstr "Approbateur responsable" + #. module: project_worksheet #: model:ir.model.fields,field_description:project_worksheet.field_project_worksheet__message_has_error msgid "Message Delivery error" @@ -545,6 +566,12 @@ msgstr "" "Aucun montant n'apparaît sur ce document. La facturation est établie " "séparément par l'administration." +#. module: project_worksheet +#: model:ir.model.fields,field_description:project_worksheet.field_project_worksheet__internal_notes +#: model_terms:ir.ui.view,arch_db:project_worksheet.project_worksheet_view_form +msgid "Notes" +msgstr "Notes" + #. module: project_worksheet #: model:ir.model.fields,field_description:project_worksheet.field_project_worksheet__message_needaction_counter msgid "Number of Actions" @@ -582,6 +609,14 @@ msgstr "Nombre de messages avec erreur d'envoi" msgid "Number of unread messages" msgstr "Nombre de messages non lus" +#. module: project_worksheet +#: code:addons/project_worksheet/models/project_worksheet.py:0 +#, python-format +msgid "Only a worksheet manager can reset a confirmed worksheet to draft." +msgstr "" +"Seul un responsable des feuilles d'installation peut remettre en brouillon " +"une feuille confirmée." + #. module: project_worksheet #: model:ir.model.fields.selection,name:project_worksheet.selection__project_worksheet__state__open #: model_terms:ir.ui.view,arch_db:project_worksheet.project_worksheet_view_form @@ -679,6 +714,11 @@ msgstr "Rechercher des feuilles d'installation" msgid "Security Token" msgstr "Jeton de sécurité" +#. module: project_worksheet +#: model:ir.model.fields,field_description:project_worksheet.field_project_worksheet_line__sequence +msgid "Sequence" +msgstr "Séquence" + #. module: project_worksheet #: model:ir.actions.act_window,name:project_worksheet.project_worksheet_config_settings_action #: model:ir.ui.menu,name:project_worksheet.menu_project_worksheet_settings @@ -690,6 +730,16 @@ msgstr "Paramètres" msgid "Site" msgstr "Site" +#. module: project_worksheet +#: code:addons/project_worksheet/models/project_worksheet.py:0 +#, python-format +msgid "" +"Some timesheets are already locked. A timesheet administrator must unlock " +"them first." +msgstr "" +"Certaines feuilles de temps sont déjà verrouillées. Un administrateur des " +"feuilles de temps doit d'abord les déverrouiller." + #. module: project_worksheet #: model:ir.model.fields,field_description:project_worksheet.field_project_worksheet__state #: model_terms:ir.ui.view,arch_db:project_worksheet.project_worksheet_view_search @@ -732,7 +782,19 @@ msgstr "Tâche" #: code:addons/project_worksheet/models/project_worksheet_line.py:0 #, python-format msgid "The date of a worksheet line must fall within the worksheet period." -msgstr "La date d'une ligne de feuille d'installation doit être comprise dans la période de la feuille." +msgstr "" +"La date d'une ligne de feuille d'installation doit être comprise dans la " +"période de la feuille." + +#. module: project_worksheet +#: code:addons/project_worksheet/models/project_worksheet.py:0 +#, python-format +msgid "" +"The period start and end dates must fall within the same calendar week " +"(Monday to Sunday)." +msgstr "" +"Les dates de début et de fin de période doivent se situer dans la même " +"semaine calendaire (du lundi au dimanche)." #. module: project_worksheet #: code:addons/project_worksheet/models/project_worksheet.py:0 @@ -839,16 +901,17 @@ msgstr "Feuille d'installation approuvée par le client depuis l'IP : %s" msgid "Worksheet approved by the client." msgstr "Feuille d'installation approuvée par le client." +#. module: project_worksheet +#: code:addons/project_worksheet/models/project_worksheet.py:0 +#, python-format +msgid "Worksheet approved internally by manager: %s" +msgstr "Feuille d'installation approuvée en interne par le responsable : %s" + #. module: project_worksheet #: model:ir.model.fields,help:project_worksheet.field_account_analytic_line__worksheet_id msgid "Worksheet associated with this timesheet entry." msgstr "Feuille d'installation associée à cette entrée de feuille de temps." -#. module: project_worksheet -#: model:mail.template,report_name:project_worksheet.email_template_worksheet_confirmed -msgid "Worksheet_${object.name}" -msgstr "Feuille_installation_${object.name}" - #. module: project_worksheet #: model:ir.actions.act_window,name:project_worksheet.project_worksheet_action #: model:ir.module.category,name:project_worksheet.module_category_project_worksheet @@ -864,19 +927,37 @@ msgstr "Feuilles d'installation" #: model:ir.cron,cron_name:project_worksheet.ir_cron_remind_pending_worksheets #: model:ir.cron,name:project_worksheet.ir_cron_remind_pending_worksheets msgid "Worksheets: Remind pending client approvals" -msgstr "Feuilles d'installation : Relancer les approbations clients en attente" +msgstr "Feuilles de chantier : Relancer les approbations clients en attente" + +#. module: project_worksheet +#: model_terms:ir.ui.view,arch_db:project_worksheet.portal_worksheet_template +msgid "You are not authorized to approve this worksheet." +msgstr "Vous n'êtes pas autorisé à approuver cette feuille d'installation." + +#. module: project_worksheet +#: code:addons/project_worksheet/models/project_worksheet.py:0 +#, python-format +msgid "You can only delete a worksheet in the 'New' state" +msgstr "" +"Vous ne pouvez supprimer une feuille d'installation que lorsqu'elle est à " +"l'état « Nouveau »." #. module: project_worksheet #: code:addons/project_worksheet/models/project_worksheet.py:0 #, python-format msgid "You cannot approve a worksheet with no lines or zero total hours." -msgstr "Vous ne pouvez pas approuver une feuille d'installation sans lignes ou avec un total d'heures à zéro." +msgstr "" +"Vous ne pouvez pas approuver une feuille d'installation sans lignes ou avec " +"un total d'heures à zéro." #. module: project_worksheet -#: code:addons/project_worksheet/models/account_analytic_line.py:0 +#: code:addons/project_worksheet/models/project_worksheet.py:0 #, python-format -msgid "You cannot delete a timesheet line linked to a worksheet." -msgstr "Vous ne pouvez pas supprimer une ligne de feuille de temps liée à une feuille d'installation." +msgid "" +"You cannot have overlapping worksheets for the same project and supervisor." +msgstr "" +"Vous ne pouvez pas avoir de feuilles d'installation qui se chevauchent pour " +"le même projet et le même superviseur." #. module: project_worksheet #: code:addons/project_worksheet/models/account_analytic_line.py:0 @@ -886,12 +967,6 @@ msgstr "" "Vous ne pouvez pas modifier les feuilles de temps liées à une feuille " "d'installation approuvée." -#. module: project_worksheet -#: code:addons/project_worksheet/models/project_worksheet_line.py:0 -#, python-format -msgid "You cannot record timesheets on a parent task." -msgstr "Vous ne pouvez pas enregistrer de feuilles de temps sur une tâche parente." - #. module: project_worksheet #: model_terms:ir.ui.view,arch_db:project_worksheet.portal_worksheet_template msgid "You must check the confirmation box to proceed." @@ -905,9 +980,4 @@ msgstr "jours" #. module: project_worksheet #: model_terms:ir.ui.view,arch_db:project_worksheet.report_worksheet_document msgid "to" -msgstr "au" - -#. module: project_worksheet -#: model_terms:ir.ui.view,arch_db:project_worksheet.portal_worksheet_template -msgid "You are not authorized to approve this worksheet." -msgstr "Vous n'êtes pas autorisé à approuver cette feuille d'installation." \ No newline at end of file +msgstr "au" \ No newline at end of file diff --git a/project_worksheet/models/account_analytic_line.py b/project_worksheet/models/account_analytic_line.py index ca323c84..47ed95ae 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,19 @@ 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..8b014e3e 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,133 @@ 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): @@ -170,7 +292,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 +312,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 +383,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 +449,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 @@