diff --git a/.copier-answers.yml b/.copier-answers.yml index e9949bd3..566fcde6 100644 --- a/.copier-answers.yml +++ b/.copier-answers.yml @@ -1,5 +1,5 @@ # Do NOT update manually; changes here will be overwritten by Copier -_commit: 8677dea +_commit: 1173c5f _src_path: https://github.com/ingadhoc/addons-repo-template.git description: '' is_private: false diff --git a/.github/workflows/sync-19.0.yml b/.github/workflows/sync-19.0.yml new file mode 100644 index 00000000..65be7d6a --- /dev/null +++ b/.github/workflows/sync-19.0.yml @@ -0,0 +1,23 @@ +name: "Sync Fork Branch 19.0" + +on: + schedule: + - cron: '0 8 * * 4' # weekly on Thursday at 8am + workflow_dispatch: # on button click + +jobs: + sync: + + runs-on: ubuntu-latest + + steps: + - uses: tgymnich/fork-sync@v1.3 + with: + owner: ingadhoc + github_token: ${{ secrets.FORK_SYNC_TOKEN }} + personal_token: ${{ secrets.FORK_SYNC_SIREKO_TOKEN }} + base: "19.0" + head: "19.0" + merge_method: merge + pr_title: "Fork Sync Branch 19.0" + auto_approve: true diff --git a/base_bg/README.rst b/base_bg/README.rst index 0b722a5d..dd3ae7f4 100644 --- a/base_bg/README.rst +++ b/base_bg/README.rst @@ -35,7 +35,7 @@ Job States * **Running**: Job is currently being executed * **Done**: Job completed successfully * **Failed**: Job failed after all retry attempts -* **Canceled**: Job was manually canceled +* **Canceled**: Job was manually canceled, or every record it pointed to was deleted before it could run (orphan job) Usage ===== @@ -239,6 +239,7 @@ A separate scheduled action monitors running jobs: * Detects jobs running longer than 5 hours (configurable) * Automatically marks them as failed with timeout error +* Cancels (instead of failing) jobs whose records no longer exist * Prevents stuck jobs from blocking the queue Security and Permissions diff --git a/base_bg/__manifest__.py b/base_bg/__manifest__.py index 099962d4..ae004330 100644 --- a/base_bg/__manifest__.py +++ b/base_bg/__manifest__.py @@ -19,7 +19,7 @@ ############################################################################## { "name": "Base Background Jobs", - "version": "19.0.1.0.1", + "version": "19.0.1.1.0", "category": "Technical", "author": "ADHOC SA", "website": "https://www.adhoc.com.ar", diff --git a/base_bg/i18n/base_bg.pot b/base_bg/i18n/base_bg.pot index ea784970..38f674cc 100644 --- a/base_bg/i18n/base_bg.pot +++ b/base_bg/i18n/base_bg.pot @@ -6,8 +6,8 @@ msgid "" msgstr "" "Project-Id-Version: Odoo Server 19.0+e\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-02-27 18:42+0000\n" -"PO-Revision-Date: 2026-02-27 18:42+0000\n" +"POT-Creation-Date: 2026-08-21 20:53+0000\n" +"PO-Revision-Date: 2026-08-21 20:53+0000\n" "Last-Translator: \n" "Language-Team: \n" "MIME-Version: 1.0\n" @@ -20,6 +20,13 @@ msgstr "" msgid "Args Json" msgstr "" +#. module: base_bg +#: model:ir.model.fields,help:base_bg.field_bg_job__transient_retry_count +msgid "" +"Attempts lost to transient DB contention, counted separately from the normal" +" retry budget." +msgstr "" + #. module: base_bg #: model:ir.model,name:base_bg.model_bg_job #: model_terms:ir.ui.view,arch_db:base_bg.view_bg_job_form @@ -119,11 +126,6 @@ msgstr "" msgid "Creation Date" msgstr "" -#. module: base_bg -#: model:ir.model.fields,help:base_bg.field_bg_job__retry_count -msgid "Current number of retry attempts" -msgstr "" - #. module: base_bg #: model:ir.model.fields,help:base_bg.field_bg_job__state msgid "Current state of the job" @@ -146,6 +148,11 @@ msgstr "" msgid "Duration" msgstr "" +#. module: base_bg +#: model:ir.model.fields,help:base_bg.field_bg_job__next_retry_at +msgid "Earliest time this job may be retried after a transient failure." +msgstr "" + #. module: base_bg #: model:ir.model.fields,field_description:base_bg.field_bg_job__end_time msgid "End Time" @@ -278,6 +285,11 @@ msgstr "" msgid "Next Job" msgstr "" +#. module: base_bg +#: model:ir.model.fields,field_description:base_bg.field_bg_job__next_retry_at +msgid "Next Retry At" +msgstr "" + #. module: base_bg #: model:ir.model.fields,help:base_bg.field_bg_job__next_job_id msgid "Next job in the batch sequence" @@ -323,6 +335,12 @@ msgstr "" msgid "Previous job in batch failed" msgstr "" +#. module: base_bg +#. odoo-python +#: code:addons/base_bg/models/bg_job.py:0 +msgid "Previous job in batch was canceled" +msgstr "" + #. module: base_bg #: model:ir.model.fields,field_description:base_bg.field_bg_job__priority msgid "Priority" @@ -388,6 +406,12 @@ msgstr "" msgid "The model name on which the job method will be executed" msgstr "" +#. module: base_bg +#. odoo-python +#: code:addons/base_bg/models/bg_job.py:0 +msgid "The records of this job no longer exist" +msgstr "" + #. module: base_bg #: model_terms:ir.ui.view,arch_db:base_bg.view_bg_job_search msgid "This Week" @@ -398,6 +422,16 @@ msgstr "" msgid "Today" msgstr "" +#. module: base_bg +#: model:ir.model.fields,help:base_bg.field_bg_job__retry_count +msgid "Total number of attempts made so far (transient + non-transient)." +msgstr "" + +#. module: base_bg +#: model:ir.model.fields,field_description:base_bg.field_bg_job__transient_retry_count +msgid "Transient Retry Count" +msgstr "" + #. module: base_bg #: model_terms:ir.ui.view,arch_db:base_bg.view_bg_job_search msgid "User" diff --git a/base_bg/i18n/es.po b/base_bg/i18n/es.po index 5ddb043e..a9b50eca 100644 --- a/base_bg/i18n/es.po +++ b/base_bg/i18n/es.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: Odoo Server 19.0+e\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-02-27 18:42+0000\n" +"POT-Creation-Date: 2026-08-21 20:53+0000\n" "PO-Revision-Date: 2025-12-11 18:10+0000\n" "Last-Translator: BAF \n" "Language-Team: Spanish bool: diff --git a/base_bg/models/bg_job.py b/base_bg/models/bg_job.py index 8fa6d735..916b202a 100644 --- a/base_bg/models/bg_job.py +++ b/base_bg/models/bg_job.py @@ -3,13 +3,14 @@ # directory ############################################################################## import logging +import random from datetime import timedelta import psycopg2 from markupsafe import Markup from odoo import _, api, fields, models, tools from odoo.exceptions import UserError -from odoo.fields import Domain +from odoo.service.model import PG_CONCURRENCY_EXCEPTIONS_TO_RETRY _logger = logging.getLogger(__name__) @@ -78,7 +79,12 @@ class BgJob(models.Model): retry_count = fields.Integer( default=0, readonly=True, - help="Current number of retry attempts", + help="Total number of attempts made so far (transient + non-transient).", + ) + transient_retry_count = fields.Integer( + default=0, + readonly=True, + help="Attempts lost to transient DB contention, counted separately from the normal retry budget.", ) start_time = fields.Datetime( readonly=True, @@ -112,6 +118,10 @@ class BgJob(models.Model): readonly=True, help="Next job in the batch sequence", ) + next_retry_at = fields.Datetime( + readonly=True, + help="Earliest time this job may be retried after a transient failure.", + ) @api.depends("start_time", "end_time") def _compute_duration(self): @@ -172,12 +182,22 @@ def action_open_batch_jobs(self) -> dict: def run(self): """ - Executes the job + Executes the job. + + Retried on transient failures, so the method may run more than once + (at-least-once); side-effecting methods should be idempotent. + + The ORM cache is dropped after each job (``invalidate_all``): the runner + loops many jobs inside one long-lived cron process, so releasing each job's + cache flattens the runner's memory curve between jobs. """ self.ensure_one() if self.state != "running": raise UserError(_("Only running jobs can be executed")) + if self._cancel_if_orphaned(): + return + self.env.cr.commit() # pylint: disable=invalid-commit try: context = dict(self.context_json or {}) @@ -196,20 +216,24 @@ def run(self): self._notify_user(result) self.env.cr.commit() # pylint: disable=invalid-commit + self.env.invalidate_all() except Exception as e: self.env.cr.rollback() # pylint: disable=invalid-commit + self.env.invalidate_all() self._handle_job_error(e) self.env.cr.commit() # pylint: disable=invalid-commit def enqueue(self, retry: bool = False): - """Mark the job as enqueued.""" + """Mark the job as enqueued, clearing any pending backoff gate.""" data: dict[str, str | int | bool] = { "state": "enqueued", + "next_retry_at": False, } if retry: data.update( { "retry_count": 0, + "transient_retry_count": 0, "error_message": False, } ) @@ -217,8 +241,11 @@ def enqueue(self, retry: bool = False): def finish(self): """ - Mark the job as done and set the end time. - Also enqueue the next job in the batch if it exists. + Mark the job as done and enqueue the next job in the batch. + + The cron is NOT triggered here: ``_cron_run_enqueued_jobs`` re-triggers + itself while eligible jobs remain, so triggering per finish only churns + ``ir_cron`` under load. """ self.write( { @@ -227,7 +254,6 @@ def finish(self): } ) self.filtered("next_job_id").mapped("next_job_id").enqueue() - self.env["base.bg"].sudo()._trigger_crons() def wait(self): """Mark the job as waiting for the previous job to complete.""" @@ -244,13 +270,17 @@ def fail(self, error_message: str, notify: bool = True): "state": "failed", "end_time": fields.Datetime.now(), "error_message": error_message, + "next_retry_at": False, } ) if notify: message = _("Job %s failed: %s") % (self.name, error_message) - records = self._get_records().mapped(lambda r: r and r._get_html_link()) + # exists(): a job can outlive its records, and browsing a dropped id is truthy — + # _get_html_link() reads display_name on it and would raise MissingError. + records = self._get_records().exists() if records: - message += "
" + _("Related records: %s") % (", ".join(records)) + links = [record._get_html_link() for record in records] + message += "
" + _("Related records: %s") % (", ".join(links)) self._notify_user(message) def cancel(self, message: str | None = None): @@ -260,6 +290,7 @@ def cancel(self, message: str | None = None): "state": "canceled", "cancel_time": fields.Datetime.now(), "error_message": message, + "next_retry_at": False, } ) @@ -274,22 +305,147 @@ def _get_records(self) -> models.BaseModel: records = self.env[self.model].browse(record_ids) return records - def _handle_job_error(self, error: Exception | str): + def _cancel_if_orphaned(self) -> bool: """ - Handle job execution error + Cancel this job (and the rest of its batch) when every record it points to is gone. + + A job can outlive its records (a GC or an FK cascade wins the race). Such a job did + not fail — there is nothing left to run it on — so it is canceled instead of failed, + keeping failure metrics and notifications meaningful. Jobs that point to no records + at all (model-level methods) are not orphans. - :param error: The exception raised during job execution + :return: True if the job was canceled as an orphan + """ + self.ensure_one() + records = self._get_records() + if not records or records.exists(): + return False + _logger.info("Job %s canceled: the records it points to no longer exist", self.name) + self.cancel(message=_("The records of this job no longer exist")) + self._get_next_jobs().cancel(message=_("Previous job in batch was canceled")) + return True + + def _handle_job_error(self, error: Exception | str) -> bool: + """ + Handle a job execution error and decide whether to retry. + + Transient DB contention (serialization / deadlock / lock timeout) is retried + with exponential backoff on its own budget (``transient_retry_count`` vs + ``base_bg.transient_max_retries``), separate from ``max_retries`` so a + contention spike does not consume the retries meant for real errors. Other + errors keep the immediate-retry-then-fail behavior. ``retry_count`` tracks + every attempt (for display); the non-transient budget is measured on + non-transient attempts. + + :return: True if re-enqueued, False if failed permanently (overrides use this). """ error_msg = str(error) self.retry_count += 1 - if self.retry_count < self.max_retries: + if self._is_transient_error(error): + self.transient_retry_count += 1 + if self.transient_retry_count < self._get_transient_max_retries(): + delay = self._compute_backoff_delay(self.transient_retry_count) + next_retry_at = fields.Datetime.now() + timedelta(seconds=delay) + self.write( + { + "state": "enqueued", + "next_retry_at": next_retry_at, + "error_message": error_msg, + } + ) + # Wake a runner when the backoff elapses (a backing-off job is + # skipped by the self-retrigger, so nothing else would). + self.env["base.bg"].sudo()._trigger_crons(at=next_retry_at) + _logger.warning( + "Job %s hit transient contention, backing off %.1fs before retry #%d: %s", + self.name, + delay, + self.transient_retry_count, + error_msg, + ) + return True + _logger.error( + "Job %s failed permanently after %d transient retries: %s", + self.name, + self.transient_retry_count, + error_msg, + ) + return self._give_up(error_msg) + + # Non-transient error: budget measured on non-transient attempts only. + non_transient_attempts = self.retry_count - self.transient_retry_count + if non_transient_attempts < self.max_retries: self.enqueue() _logger.warning("Job %s failed, scheduling retry #%d: %s", self.name, self.retry_count, error_msg) - else: - # Max retries reached, mark as failed - self.fail(error_msg) - self._get_next_jobs().cancel(message=_("Previous job in batch failed")) - _logger.error("Job %s failed permanently: %s", self.name, error_msg) + return True + _logger.error("Job %s failed permanently: %s", self.name, error_msg) + return self._give_up(error_msg) + + def _give_up(self, error_msg: str) -> bool: + """Fail this job permanently and cancel the rest of its batch. Returns False.""" + self.fail(error_msg) + self._get_next_jobs().cancel(message=_("Previous job in batch failed")) + return False + + @api.model + def _is_transient_error(self, error: Exception | str) -> bool: + """ + Whether ``error`` is a transient PG concurrency failure, safe to retry. + Reuses Odoo's ``PG_CONCURRENCY_EXCEPTIONS_TO_RETRY`` and walks the + ``__cause__`` / ``__context__`` chain so a wrapped failure is still caught. + A plain string (the reaper's "Job timed out") is never transient. + """ + seen: set[int] = set() + exc = error if isinstance(error, BaseException) else None + while exc is not None and id(exc) not in seen: + seen.add(id(exc)) + if isinstance(exc, PG_CONCURRENCY_EXCEPTIONS_TO_RETRY): + return True + exc = exc.__cause__ or exc.__context__ + return False + + @api.model + def _compute_backoff_delay(self, attempt: int) -> float: + """ + Exponential backoff (seconds) with jitter, capped at 5 minutes. ``attempt`` + is 1-based. + """ + base, ceiling, exp_cap = 5, 300, 8 + delay = min(ceiling, base * 2 ** min(attempt - 1, exp_cap)) + return delay + random.uniform(0, delay * 0.25) + + @api.model + def _get_int_param(self, key: str, default: int) -> int: + """ + Read an int system parameter, falling back to ``default`` (with a warning) + on a missing or non-integer value, so a bad param cannot crash the runner. + """ + value = self.env["ir.config_parameter"].sudo().get_param(key, default) + try: + return int(value) + except (TypeError, ValueError): + _logger.warning("Invalid value %r for system parameter %s; using default %s.", value, key, default) + return default + + @api.model + def _get_transient_max_retries(self) -> int: + """Retry budget for transient concurrency failures (separate from ``max_retries``).""" + return max(1, self._get_int_param("base_bg.transient_max_retries", 10)) + + @api.model + def _has_eligible_jobs(self) -> bool: + """Whether at least one enqueued job is past its backoff window.""" + return bool( + self.search_count( + [ + ("state", "=", "enqueued"), + "|", + ("next_retry_at", "=", False), + ("next_retry_at", "<=", fields.Datetime.now()), + ], + limit=1, + ) + ) def _notify_user(self, result: str): """ @@ -340,6 +496,9 @@ def _get_next_job(self) -> "BgJob": caller (_cron_run_enqueued_jobs) with a bounded retry, so it must not be logged as a "bad query" ERROR — hence log_exceptions=False. + Backed-off jobs (``next_retry_at`` in the future) are skipped, using a Python + UTC timestamp rather than SQL ``NOW()`` (which is session-tz dependent). + :return: The next BgJob record to process, or an empty recordset if none available """ self.env.cr.execute( @@ -348,17 +507,19 @@ def _get_next_job(self) -> "BgJob": SELECT id FROM bg_job WHERE state = 'enqueued' + AND (next_retry_at IS NULL OR next_retry_at <= %(now)s) ORDER BY priority, create_date, id FOR UPDATE SKIP LOCKED LIMIT 1 ) UPDATE bg_job j SET state = 'running', - start_time = NOW() + start_time = %(now)s FROM candidate WHERE j.id = candidate.id RETURNING j.id; """, + {"now": fields.Datetime.now()}, log_exceptions=False, ) row = self.env.cr.fetchone() @@ -403,8 +564,9 @@ def _cron_run_enqueued_jobs(self): return job.run() - # Trigger cron again if there are more jobs to process - if self.search_count(Domain("state", "=", "enqueued")): + # Re-trigger only if more *eligible* jobs remain: a backing-off job + # (next_retry_at in the future) must not spin the scheduler. + if self._has_eligible_jobs(): self.env["base.bg"].sudo()._trigger_crons() @api.model @@ -418,8 +580,35 @@ def _cron_check_running_jobs(self): ("state", "=", "running"), ] ) + timeout_msg = _("Job timed out") for job in jobs: - job._handle_job_error(_("Job timed out")) - if job.state == "failed": - message = _("Job %s timed out") % job._get_html_link(title=job.name) - job._notify_user(message) + job_name = job.name + try: + # Per-job savepoint: a job that raises would otherwise abort the whole reaper + # and leave every other timed-out job running forever. + with self.env.cr.savepoint(): + if job._cancel_if_orphaned(): + continue + job._handle_job_error(timeout_msg) + if job.state == "failed": + job._notify_user(_("Job %s timed out") % job._get_html_link(title=job_name)) + except Exception as error: + # No invalidation needed: the savepoint rollback already cleared the cache + # and the pending updates (_FlushingSavepoint.rollback -> cr.clear()). + if self._is_transient_error(error): + _logger.warning("Job %s not timed out yet, transient error: %s", job_name, error) + continue + _logger.exception("Could not time out job %s, giving up on it", job_name) + try: + # Own savepoint: the recovery path writes through the registry too, so a + # model override raising here would abort the whole reaper as well. + with self.env.cr.savepoint(): + # Base implementations on purpose: the rollback restored the job to + # running and whatever the model added on top is what just raised. + BgJob.fail(job, timeout_msg, notify=False) + job._get_next_jobs().cancel(message=_("Previous job in batch failed")) + except Exception as fallback_error: + if self._is_transient_error(fallback_error): + _logger.warning("Job %s not failed yet, transient error: %s", job_name, fallback_error) + else: + _logger.exception("Could not fail job %s either, leaving it for the next run", job_name) diff --git a/base_bg/tests/test_bg_job.py b/base_bg/tests/test_bg_job.py index 7494e1db..9906f42b 100644 --- a/base_bg/tests/test_bg_job.py +++ b/base_bg/tests/test_bg_job.py @@ -110,6 +110,108 @@ def test_cron_check_running_jobs(self): job = self.BgJob.browse(job.id) self.assertEqual(job.state, "failed") + def test_fail_notifies_when_records_were_deleted(self): + """fail() must not raise when the job outlived the records it points to.""" + partner = self.env["res.partner"].create({"name": "Gone"}) + job = self._create_job(name="Orphan Job", state="running", kwargs_json={"_record_ids": [partner.id]}) + partner.unlink() # linking it in the notification would read display_name and raise + + job.fail("boom") + + self.assertEqual(job.state, "failed") + self.assertEqual(job.error_message, "boom") + + def _create_timed_out_job(self, name, **vals): + """Build a running job whose start_time is already past any cron timeout.""" + old_time = fields.Datetime.now() - timedelta(hours=6) + return self._create_job(name=name, state="running", start_time=old_time, max_retries=1, **vals) + + def test_cron_check_running_jobs_skips_poisoned_job(self): + """A job that raises while being timed out must not abort the reaper for the rest.""" + poisoned = self._create_timed_out_job("Poisoned Job") + chained = self._create_job(name="Chained Job", batch_key=poisoned.batch_key, state="waiting") + poisoned.next_job_id = chained.id + healthy = self._create_timed_out_job("Healthy Job") + base_fail = type(self.BgJob).fail + + def poisoned_fail(job_self, error_message, notify=True): + """Stand in for a model override of fail() that raises (the real-world poison).""" + if job_self.name == "Poisoned Job": + raise ValueError("boom while failing the job") + return base_fail(job_self, error_message, notify=notify) + + self._set_cron_timeout(300) + with patch.object(type(self.BgJob), "fail", poisoned_fail), tools.mute_logger( + "odoo.addons.base_bg.models.bg_job" + ): + self.BgJob._cron_check_running_jobs() + + self.assertEqual(poisoned.state, "failed", "the poisoned job is bare-failed instead of poisoning every run") + self.assertEqual(chained.state, "canceled", "its batch is cancelled, as on any other permanent failure") + self.assertEqual(healthy.state, "failed", "the remaining jobs are still timed out") + + def test_cron_check_running_jobs_defers_transient_error(self): + """A transient PG error is not a poisoned job: the job is left for the next run.""" + job = self._create_timed_out_job("Contended Job") + + self._set_cron_timeout(300) + with patch.object( + type(self.BgJob), "_handle_job_error", side_effect=self._serialization_error() + ), tools.mute_logger("odoo.addons.base_bg.models.bg_job"): + self.BgJob._cron_check_running_jobs() + + self.assertEqual(job.state, "running") + + def test_run_cancels_orphaned_job(self): + """run() cancels a job whose records were all deleted, along with the rest of its batch.""" + partner = self.env["res.partner"].create({"name": "Gone"}) + job = self._create_job(name="Orphan Run Job", state="running", kwargs_json={"_record_ids": [partner.id]}) + chained = self._create_job(name="Chained After Orphan", batch_key=job.batch_key, state="waiting") + job.next_job_id = chained.id + partner.unlink() + + with patch.object(type(self.BgJob), "_notify_user") as mock_notify: + job.run() + + self.assertEqual(job.state, "canceled", "an orphan job is canceled, not failed") + self.assertEqual(chained.state, "canceled") + mock_notify.assert_not_called() + + def test_run_with_partially_deleted_records_is_not_orphaned(self): + """One surviving record is enough: the job is not an orphan and runs normally.""" + keep = self.env["res.partner"].create({"name": "Keep"}) + gone = self.env["res.partner"].create({"name": "Gone"}) + job = self._create_job(state="running", kwargs_json={"_record_ids": [keep.id, gone.id]}) + gone.unlink() + + with patch.object(self.env.cr, "commit"), patch.object(type(self.BgJob), "_notify_user"): + job.run() + + self.assertEqual(job.state, "done") + + def test_run_with_no_records_is_not_orphaned(self): + """A job that points to no records at all (model-level method) runs normally.""" + job = self._create_job(state="running", kwargs_json={"_record_ids": []}) + + with patch.object(self.env.cr, "commit"): + job.run() + + self.assertEqual(job.state, "done") + + def test_cron_check_running_jobs_cancels_orphaned_job(self): + """The reaper cancels a timed-out job whose records are gone, and still times out the rest.""" + partner = self.env["res.partner"].create({"name": "Gone"}) + orphan = self._create_timed_out_job("Orphan Timed Out", kwargs_json={"_record_ids": [partner.id]}) + healthy = self._create_timed_out_job("Healthy Timed Out") + partner.unlink() + + self._set_cron_timeout(300) + with patch.object(type(self.BgJob), "_notify_user"), tools.mute_logger("odoo.addons.base_bg.models.bg_job"): + self.BgJob._cron_check_running_jobs() + + self.assertEqual(orphan.state, "canceled", "an orphan job is canceled, not failed") + self.assertEqual(healthy.state, "failed") + def test_cron_check_running_jobs_recent(self): """Test that recent running jobs are not marked as timed out.""" # Create a job that started recently @@ -568,3 +670,207 @@ def test_cron_run_reraises_unexpected_error(self): # Not retried, and the transaction is rolled back before propagating. self.assertEqual(mock_get.call_count, 1) mock_rollback.assert_called_once() + + # --- Serialization backoff, eligibility and runner memory cleanup ------- + + def _serialization_error(self): + """Build a real psycopg2 serialization failure (SQLSTATE 40001).""" + return psycopg2.errors.SerializationFailure("could not serialize access due to concurrent update") + + def _set_param(self, key, value): + """Set a system parameter and restore its prior state on cleanup. + + ir.config_parameter is cached in a process-global ormcache that survives + the TransactionCase rollback, so tests must undo their own set_param. + """ + icp = self.env["ir.config_parameter"].sudo() + original = icp.get_param(key, False) + + def _restore(): + if original is False: + param = icp.search([("key", "=", key)]) + if param: + param.unlink() + else: + icp.set_param(key, original) + + self.addCleanup(_restore) + icp.set_param(key, value) + + def test_is_transient_error_detects_serialization(self): + """40001 failures are transient whether raw, chained via __cause__, or via __context__.""" + job = self._create_job() + self.assertTrue(job._is_transient_error(self._serialization_error())) + # Wrapped with an explicit cause (raise ... from) ... + try: + try: + raise self._serialization_error() + except psycopg2.errors.SerializationFailure as exc: + raise ValueError("wrapped") from exc + except ValueError as chained: + self.assertTrue(job._is_transient_error(chained)) + # ... and with implicit chaining (__context__) + try: + try: + raise self._serialization_error() + except psycopg2.errors.SerializationFailure: + raise ValueError("implicitly chained") + except ValueError as chained_ctx: + self.assertTrue(job._is_transient_error(chained_ctx)) + # Unrelated exceptions, and plain strings that merely contain the phrase, are NOT transient + self.assertFalse(job._is_transient_error(ValueError("boom"))) + self.assertFalse(job._is_transient_error("could not serialize access due to concurrent update")) + + def test_transient_error_backs_off_and_reenqueues(self): + """A serialization failure re-enqueues with a future backoff gate and schedules a wake-up.""" + job = self._create_job(name="Transient Job", state="running", max_retries=3) + with patch("odoo.addons.base_bg.models.bg_job._logger.warning"), patch.object( + type(self.env["base.bg"]), "_trigger_crons" + ) as mock_trigger: + requeued = job._handle_job_error(self._serialization_error()) + + self.assertTrue(requeued) + job.invalidate_recordset() + self.assertEqual(job.state, "enqueued") + self.assertEqual(job.transient_retry_count, 1) + self.assertEqual(job.retry_count, 1) + self.assertTrue(job.next_retry_at, "A backoff gate must be set for transient errors") + self.assertGreater(job.next_retry_at, fields.Datetime.now()) + # A wake-up is scheduled for when the backoff elapses. + mock_trigger.assert_called_once() + self.assertIn("at", mock_trigger.call_args.kwargs) + + def test_transient_error_does_not_consume_normal_retry_budget(self): + """Serialization retries use their own budget (default 10), not max_retries (3).""" + job = self._create_job( + name="Persistent Transient", state="running", max_retries=3, retry_count=5, transient_retry_count=5 + ) + with patch("odoo.addons.base_bg.models.bg_job._logger.warning"), patch.object( + type(self.env["base.bg"]), "_trigger_crons" + ): + requeued = job._handle_job_error(self._serialization_error()) + + self.assertTrue(requeued) + job.invalidate_recordset() + self.assertEqual(job.state, "enqueued") # not failed despite retry_count > max_retries + self.assertEqual(job.transient_retry_count, 6) + + def test_transient_error_fails_after_serialization_cap(self): + """Once the serialization retry budget is exhausted, the job fails permanently.""" + self._set_param("base_bg.transient_max_retries", "3") + job = self._create_job(name="Exhausted Transient", state="running", retry_count=2, transient_retry_count=2) + with patch("odoo.addons.base_bg.models.bg_job._logger.error"): + requeued = job._handle_job_error(self._serialization_error()) + + self.assertFalse(requeued) + job.invalidate_recordset() + self.assertEqual(job.state, "failed") + + def test_non_transient_error_keeps_immediate_retry(self): + """Non-serialization errors keep the original retry-then-fail behavior, no backoff gate.""" + job = self._create_job(name="Real Error Job", state="running", max_retries=2) + with patch("odoo.addons.base_bg.models.bg_job._logger.warning"): + requeued = job._handle_job_error("Some real failure") + + self.assertTrue(requeued) + job.invalidate_recordset() + self.assertEqual(job.state, "enqueued") + self.assertEqual(job.retry_count, 1) + self.assertEqual(job.transient_retry_count, 0) + self.assertFalse(job.next_retry_at, "Non-transient retries must not set a backoff gate") + + def test_transient_retries_do_not_starve_real_error_budget(self): + """A job that burned transient retries still gets its full non-transient budget.""" + job = self._create_job(name="Mixed", state="running", max_retries=3, retry_count=4, transient_retry_count=4) + with patch("odoo.addons.base_bg.models.bg_job._logger.warning"): + requeued = job._handle_job_error("A genuine business error") + + self.assertTrue(requeued, "the first real error must retry even after transient retries") + job.invalidate_recordset() + self.assertEqual(job.state, "enqueued") + self.assertEqual(job.retry_count, 5) # non-transient attempts = 5 - 4 = 1 < max_retries(3) + + def test_malformed_int_param_falls_back_to_default(self): + """A non-integer system parameter must not crash the runner; it falls back to the default.""" + self._set_param("base_bg.transient_max_retries", "not-a-number") + with patch("odoo.addons.base_bg.models.bg_job._logger.warning"): + self.assertEqual(self.BgJob._get_transient_max_retries(), 10) + + def test_fail_and_cancel_clear_backoff_gate(self): + """fail() and cancel() clear next_retry_at so no stale gate lingers on the row.""" + future = fields.Datetime.now() + timedelta(minutes=5) + job = self._create_job(name="Fail Clears Gate", state="running", next_retry_at=future) + job.fail("done for", notify=False) + job.invalidate_recordset() + self.assertFalse(job.next_retry_at) + + job2 = self._create_job(name="Cancel Clears Gate", state="enqueued", next_retry_at=future) + job2.cancel("nope") + job2.invalidate_recordset() + self.assertFalse(job2.next_retry_at) + + def test_get_next_job_skips_jobs_in_backoff(self): + """Jobs whose backoff window is still in the future are not picked up.""" + self.BgJob.search([("state", "=", "enqueued")]).write({"state": "canceled"}) + future = fields.Datetime.now() + timedelta(minutes=10) + self._create_job(name="Backing Off", state="enqueued", next_retry_at=future) + self.assertEqual(self.BgJob._get_next_job(), self.env["bg.job"]) + + eligible = self._create_job(name="Ready", state="enqueued") + self.assertEqual(self.BgJob._get_next_job(), eligible) + + def test_finish_does_not_trigger_cron(self): + """finish() must not hammer the cron on every completed job.""" + job = self._create_job(name="Finish No Trigger", state="running") + with patch.object(type(self.env["base.bg"]), "_trigger_crons") as mock_trigger: + job.finish() + + job.invalidate_recordset() + self.assertEqual(job.state, "done") + mock_trigger.assert_not_called() + + def test_finish_enqueues_next_and_marks_it_eligible(self): + """finish() enqueues the next batch job, which the runner then sees as eligible.""" + self.BgJob.search([("state", "=", "enqueued")]).write({"state": "canceled"}) + job1 = self._create_job(name="Batch head", state="running") + job2 = self._create_job(name="Batch tail", state="waiting") + job1.next_job_id = job2 + job1.finish() + + job2.invalidate_recordset() + self.assertEqual(job2.state, "enqueued") + self.assertTrue(self.BgJob._has_eligible_jobs(), "the freshly-enqueued next job must be pickable") + + def test_run_releases_orm_cache(self): + """run() drops the ORM cache after each job (success and error) to flatten the + long-lived runner's memory between jobs.""" + # Success path + partner = self.env["res.partner"].create({"name": "Mem partner"}) + ok_job = self._create_job( + name="Mem OK", + state="running", + model="res.partner", + method="exists", + kwargs_json={"_record_ids": [partner.id]}, + ) + with patch.object(self.env.cr, "commit"), patch.object(type(ok_job), "_notify_user"), patch.object( + type(self.env), "invalidate_all" + ) as mock_inv: + ok_job.run() + mock_inv.assert_called() + + # Error path + err_job = self._create_job( + name="Mem ERR", + state="running", + model="bg.job", + method="dummy_boom", + kwargs_json={"_record_ids": [ok_job.id]}, + ) + with patch.object(type(err_job), "dummy_boom", create=True, side_effect=ValueError("boom")), patch.object( + self.env.cr, "commit" + ), patch.object(self.env.cr, "rollback"), patch( + "odoo.addons.base_bg.models.bg_job._logger.warning" + ), patch.object(type(self.env), "invalidate_all") as mock_inv_err: + err_job.run() + mock_inv_err.assert_called() diff --git a/base_company_dependent/models/base.py b/base_company_dependent/models/base.py index 9c1345f8..ced7a450 100644 --- a/base_company_dependent/models/base.py +++ b/base_company_dependent/models/base.py @@ -49,6 +49,9 @@ def _check_company_dependent_m2o(self): cd_fields = [f for f in self._fields.values() if f.type == "many2one" and f.company_dependent] for field in cd_fields: comodel = self.env[field.comodel_name] + if "company_id" not in comodel and "company_ids" not in comodel: + # comodel shared by every company: nothing to cross-check + continue company_domain = comodel._check_company_domain(company) if not company_domain: continue diff --git a/base_company_dependent/tests/company_dependent_models.py b/base_company_dependent/tests/company_dependent_models.py index 1ba87839..96ca6673 100644 --- a/base_company_dependent/tests/company_dependent_models.py +++ b/base_company_dependent/tests/company_dependent_models.py @@ -31,3 +31,4 @@ class CompanyDependentTester(models.Model): name = fields.Char() partner_id = fields.Many2one("res.partner", company_dependent=True) partner_regular_id = fields.Many2one("res.partner") + currency_id = fields.Many2one("res.currency", company_dependent=True) diff --git a/base_company_dependent/tests/test_company_cross_check.py b/base_company_dependent/tests/test_company_cross_check.py index 91fab91d..ca7a83c3 100644 --- a/base_company_dependent/tests/test_company_cross_check.py +++ b/base_company_dependent/tests/test_company_cross_check.py @@ -107,6 +107,14 @@ def test_load_non_company_dependent_field_ignored(self): ) self.assertTrue(result.get("ids")) + def test_load_comodel_without_company_field(self): + """load() skips a company_dependent Many2one to a comodel without company.""" + result = self._env_a()[self.MODEL].load( + ["name", "currency_id/.id"], + [["Tester", str(self.env.ref("base.USD").id)]], + ) + self.assertTrue(result.get("ids")) + def test_sibling_branch_domain_behavior_documented(self): """Lock the sibling-branch behavior, whatever _check_company_domain does. diff --git a/base_import_mobile_ux/README.rst b/base_import_mobile_ux/README.rst new file mode 100644 index 00000000..53ac8714 --- /dev/null +++ b/base_import_mobile_ux/README.rst @@ -0,0 +1,54 @@ +========================== +Base Import Mobile UX +========================== + +Show the Import action in the list view gear menu on small screens + +Características +=============== + +- Muestra la opción "Importar registros" en el menú de acciones (engranaje) + de la vista lista cuando la pantalla es chica (mobile), donde el core de + Odoo la oculta por defecto. +- No cambia ningún otro criterio de visibilidad: sigue respetando el tipo de + acción, el tipo de vista, y los atributos ``import``/``create`` del arch, + tal como los define el core. + +Detalles Técnicos +================= + +- No agrega modelos nuevos ni hereda modelos existentes. +- ``static/src/import_records.js``: re-registra la entrada ``import-menu`` + del registry ``cogMenu`` (``registry.category("cogMenu")``), reutilizando + ``importRecordsItem`` de ``base_import`` y forzando ``isSmall`` a ``false`` + al evaluar su condición ``isDisplayed``, sin duplicar el resto de la lógica + original. + +Uso +=== + +Con el módulo instalado, al abrir cualquier vista lista desde una pantalla +angosta (mobile), el menú de acciones (ícono de engranaje) incluye la opción +"Importar registros" además de las que ya se mostraban. + +Arquitectura +============ + +Módulo puramente de frontend: un único asset JS cargado en +``web.assets_backend`` que sobreescribe una entrada del registry ``cogMenu`` +del core. No tiene lógica Python, vistas ni datos. + +Dependencias +============ + +- base_import + +Autor +===== + +ADHOC SA + +Licencia +======== + +AGPL-3 diff --git a/base_import_mobile_ux/__init__.py b/base_import_mobile_ux/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/base_import_mobile_ux/__manifest__.py b/base_import_mobile_ux/__manifest__.py new file mode 100644 index 00000000..4f688059 --- /dev/null +++ b/base_import_mobile_ux/__manifest__.py @@ -0,0 +1,40 @@ +############################################################################## +# +# Copyright (C) 2026 ADHOC SA (http://www.adhoc.com.ar) +# All Rights Reserved. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +############################################################################## +{ + "name": "Base Import Mobile UX", + "version": "19.0.1.0.0", + "category": "Base", + "author": "ADHOC SA", + "website": "https://www.adhoc.com.ar", + "license": "AGPL-3", + "summary": "Show the Import action in the list view gear menu on small screens", + "depends": [ + "base_import", + ], + "data": [], + "assets": { + "web.assets_backend": [ + "base_import_mobile_ux/static/src/import_records.js", + ], + }, + "installable": True, + "auto_install": False, + "application": False, +} diff --git a/base_import_mobile_ux/static/src/import_records.js b/base_import_mobile_ux/static/src/import_records.js new file mode 100644 index 00000000..4eb054b9 --- /dev/null +++ b/base_import_mobile_ux/static/src/import_records.js @@ -0,0 +1,31 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { importRecordsItem } from "@base_import/import_records/import_records"; + +const cogMenuRegistry = registry.category("cogMenu"); + +/** + * Core hides the "Import records" gear menu item on small screens + * (isDisplayed checks `!isSmall`, see base_import/import_records.js). + * That's intentional for the default import wizard, but some clients + * still want the entry available on mobile. Re-register the same item, + * only forcing `isSmall` to false so the rest of the original condition + * (view type, `import`/`create` arch attributes, action type) still applies. + * + * Owl envs are built with `Object.create` (prototypal, frozen) — `config` + * lives on a prototype, not as an own property. A plain `{...env}` spread + * only copies own properties and drops `config`, so `Object.create` is used + * here to shadow `isSmall` while keeping the rest of the chain intact. + */ +cogMenuRegistry.add( + "import-menu", + { + ...importRecordsItem, + isDisplayed: (env) => + importRecordsItem.isDisplayed( + Object.create(env, { isSmall: { value: false, enumerable: true } }) + ), + }, + { force: true, sequence: 1 } +); diff --git a/base_import_ux/README.rst b/base_import_ux/README.rst index 02ce03c7..f82474cd 100644 --- a/base_import_ux/README.rst +++ b/base_import_ux/README.rst @@ -17,6 +17,12 @@ First Steps * Adds a "First Steps" section as the first block of General Settings, linking to a single panel that gathers the onboarding initial imports. * Shows each shortcut depending on the installed modules: Import Products (with ``product``), Import Customers / Import Vendors (with ``account_balance_import``, classified through the rank), Import Contacts (otherwise), and the Accounting setup guide (with ``account_balance_import``). * Recommends always using a fresh, clean template downloaded from the system to avoid errors carried by reused spreadsheets. + * Makes import errors less disruptive: a single badly-formatted number or date no longer cancels the whole import. Instead of raising on the first bad cell (which hides every other error and the offending row number), the value is deferred to the per-record ORM converter, which reports it **with its row number and expected-format hint**, accumulated together with every other field/record error in one pass. + +Technical notes +=============== + + * ``base_import.import._parse_float_from_data`` / ``_parse_date_from_data`` are overridden to stop aborting the import on the first unparseable value (they used to ``raise ImportValidationError``). Only genuinely unexpected (non-``ValueError``) date failures are still raised. Installation ============ diff --git a/base_import_ux/__init__.py b/base_import_ux/__init__.py index e69de29b..0650744f 100644 --- a/base_import_ux/__init__.py +++ b/base_import_ux/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/base_import_ux/__manifest__.py b/base_import_ux/__manifest__.py index f5319fbd..2bd795af 100644 --- a/base_import_ux/__manifest__.py +++ b/base_import_ux/__manifest__.py @@ -19,12 +19,12 @@ ############################################################################## { "name": "First Steps", - "version": "19.0.1.0.0", + "version": "19.0.1.2.0", "category": "Tools", "sequence": 14, "summary": "Centralizes the onboarding initial imports in a single panel " "available from General Settings, showing each shortcut depending on the " - "installed modules", + "installed modules; and makes import error messages friendlier", "author": "ADHOC SA", "website": "www.adhoc.com.ar", "license": "AGPL-3", diff --git a/base_import_ux/i18n/base_import_ux.pot b/base_import_ux/i18n/base_import_ux.pot index 17a17840..2c020f1a 100644 --- a/base_import_ux/i18n/base_import_ux.pot +++ b/base_import_ux/i18n/base_import_ux.pot @@ -6,8 +6,8 @@ msgid "" msgstr "" "Project-Id-Version: Odoo Server 19.0+e\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-13 15:09+0000\n" -"PO-Revision-Date: 2026-07-13 15:09+0000\n" +"POT-Creation-Date: 2026-08-21 19:56+0000\n" +"PO-Revision-Date: 2026-08-21 19:56+0000\n" "Last-Translator: \n" "Language-Team: \n" "MIME-Version: 1.0\n" @@ -21,6 +21,16 @@ msgstr "" msgid "Accounting setup" msgstr "" +#. module: base_import_ux +#: model:ir.model,name:base_import_ux.model_base_import_import +msgid "Base Import" +msgstr "" + +#. module: base_import_ux +#: model:ir.model.fields,field_description:base_import_ux.field_base_import_import__display_name +msgid "Display Name" +msgstr "" + #. module: base_import_ux #. odoo-javascript #: code:addons/base_import_ux/static/src/js/first_steps_guide.js:0 @@ -29,6 +39,11 @@ msgstr "" msgid "First Steps" msgstr "" +#. module: base_import_ux +#: model:ir.model.fields,field_description:base_import_ux.field_base_import_import__id +msgid "ID" +msgstr "" + #. module: base_import_ux #. odoo-javascript #: code:addons/base_import_ux/static/src/xml/first_steps_guide.xml:0 diff --git a/base_import_ux/i18n/es.po b/base_import_ux/i18n/es.po index 991b8265..5b0d5f7e 100644 --- a/base_import_ux/i18n/es.po +++ b/base_import_ux/i18n/es.po @@ -6,10 +6,10 @@ msgid "" msgstr "" "Project-Id-Version: Odoo Server 19.0+e\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-13 15:09+0000\n" -"PO-Revision-Date: 2026-07-13 15:09+0000\n" -"Last-Translator: Automatically generated\n" -"Language-Team: none\n" +"POT-Creation-Date: 2026-08-21 19:56+0000\n" +"PO-Revision-Date: 2026-07-21 12:00+0000\n" +"Last-Translator: Luciano Esperlazza \n" +"Language-Team: Spanish\n" "Language: es\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -20,6 +20,16 @@ msgstr "" #. odoo-javascript #: code:addons/base_import_ux/static/src/xml/first_steps_guide.xml:0 msgid "Accounting setup" +msgstr "Configuración contable" + +#. module: base_import_ux +#: model:ir.model,name:base_import_ux.model_base_import_import +msgid "Base Import" +msgstr "" + +#. module: base_import_ux +#: model:ir.model.fields,field_description:base_import_ux.field_base_import_import__display_name +msgid "Display Name" msgstr "" #. module: base_import_ux @@ -28,6 +38,11 @@ msgstr "" #: model:ir.actions.client,name:base_import_ux.action_first_steps_guide #: model_terms:ir.ui.view,arch_db:base_import_ux.res_config_settings_view_form msgid "First Steps" +msgstr "Primeros Pasos" + +#. module: base_import_ux +#: model:ir.model.fields,field_description:base_import_ux.field_base_import_import__id +msgid "ID" msgstr "" #. module: base_import_ux @@ -35,28 +50,28 @@ msgstr "" #: code:addons/base_import_ux/static/src/xml/first_steps_guide.xml:0 #: model:ir.actions.client,name:base_import_ux.action_first_steps_import_partner msgid "Import Contacts" -msgstr "" +msgstr "Importar Contactos" #. module: base_import_ux #. odoo-javascript #: code:addons/base_import_ux/static/src/xml/first_steps_guide.xml:0 #: model:ir.actions.client,name:base_import_ux.action_first_steps_import_customer msgid "Import Customers" -msgstr "" +msgstr "Importar Clientes" #. module: base_import_ux #. odoo-javascript #: code:addons/base_import_ux/static/src/xml/first_steps_guide.xml:0 #: model:ir.actions.client,name:base_import_ux.action_first_steps_import_product msgid "Import Products" -msgstr "" +msgstr "Importar Productos" #. module: base_import_ux #. odoo-javascript #: code:addons/base_import_ux/static/src/xml/first_steps_guide.xml:0 #: model:ir.actions.client,name:base_import_ux.action_first_steps_import_supplier msgid "Import Vendors" -msgstr "" +msgstr "Importar Proveedores" #. module: base_import_ux #. odoo-javascript @@ -64,22 +79,24 @@ msgstr "" msgid "" "Import customers and vendors along with their\n" " contacts." -msgstr "" +msgstr "Importá clientes y proveedores junto con sus contactos." #. module: base_import_ux #. odoo-javascript #: code:addons/base_import_ux/static/src/xml/first_steps_guide.xml:0 msgid "" "Import your customers. They get\n" -" classified and show up in the Customers\n" +" classified and show up in the " +"Customers\n" " list." msgstr "" +"Importá tus clientes. Quedan clasificados y aparecen en la lista de Clientes." #. module: base_import_ux #. odoo-javascript #: code:addons/base_import_ux/static/src/xml/first_steps_guide.xml:0 msgid "Import your product catalog." -msgstr "" +msgstr "Importá tu catálogo de productos." #. module: base_import_ux #. odoo-javascript @@ -88,70 +105,81 @@ msgid "" "Import your vendors. They get classified\n" " and show up in the Vendors list." msgstr "" +"Importá tus proveedores. Quedan clasificados y aparecen en la lista de " +"Proveedores." #. module: base_import_ux #. odoo-javascript #: code:addons/base_import_ux/static/src/xml/first_steps_guide.xml:0 #: model_terms:ir.ui.view,arch_db:base_import_ux.res_config_settings_view_form msgid "Initial imports" -msgstr "" +msgstr "Importaciones iniciales" #. module: base_import_ux #: model_terms:ir.ui.view,arch_db:base_import_ux.res_config_settings_view_form msgid "Open First Steps" -msgstr "" +msgstr "Abrir Primeros Pasos" #. module: base_import_ux #. odoo-javascript #: code:addons/base_import_ux/static/src/xml/first_steps_guide.xml:0 msgid "Open accounting guide" -msgstr "" +msgstr "Abrir guía de contabilidad" #. module: base_import_ux #. odoo-javascript #: code:addons/base_import_ux/static/src/xml/first_steps_guide.xml:0 msgid "Recommendation:" -msgstr "" +msgstr "Recomendación:" #. module: base_import_ux #: model_terms:ir.ui.view,arch_db:base_import_ux.res_config_settings_view_form msgid "Run here the imports you need to get your system up and running." msgstr "" +"Realizá acá las importaciones que necesitás para poner en marcha tu sistema." #. module: base_import_ux #. odoo-javascript #: code:addons/base_import_ux/static/src/xml/first_steps_guide.xml:0 msgid "" "Set up your initial accounting: chart of\n" -" accounts, periods, partner balances and\n" +" accounts, periods, partner balances " +"and\n" " document import." msgstr "" +"Configurá tu contabilidad inicial: plan de cuentas, períodos, saldos de " +"partners e importación de comprobantes." #. module: base_import_ux #. odoo-javascript #: code:addons/base_import_ux/static/src/xml/first_steps_guide.xml:0 msgid "" "The initial physical stock is loaded\n" -" within the same product spreadsheet." +" within the same product " +"spreadsheet." msgstr "" +"El stock físico inicial se carga dentro de la misma planilla de productos." #. module: base_import_ux #. odoo-javascript #: code:addons/base_import_ux/static/src/xml/first_steps_guide.xml:0 msgid "always use a" -msgstr "" +msgstr "usá siempre una" #. module: base_import_ux #. odoo-javascript #: code:addons/base_import_ux/static/src/xml/first_steps_guide.xml:0 msgid "" "downloaded from the\n" -" system itself. Reusing old spreadsheets often carries internal\n" +" system itself. Reusing old spreadsheets often carries " +"internal\n" " errors that block the import." msgstr "" +"descargada del propio sistema. Reutilizar planillas viejas suele arrastrar " +"errores internos que bloquean la importación." #. module: base_import_ux #. odoo-javascript #: code:addons/base_import_ux/static/src/xml/first_steps_guide.xml:0 msgid "fresh, clean template" -msgstr "" +msgstr "plantilla nueva y limpia" diff --git a/base_import_ux/models/__init__.py b/base_import_ux/models/__init__.py new file mode 100644 index 00000000..e8b2d540 --- /dev/null +++ b/base_import_ux/models/__init__.py @@ -0,0 +1 @@ +from . import base_import diff --git a/base_import_ux/models/base_import.py b/base_import_ux/models/base_import.py new file mode 100644 index 00000000..490fa70a --- /dev/null +++ b/base_import_ux/models/base_import.py @@ -0,0 +1,79 @@ +import datetime + +from odoo import api, fields, models +from odoo.tools import DEFAULT_SERVER_DATE_FORMAT, DEFAULT_SERVER_DATETIME_FORMAT + + +class BaseImport(models.TransientModel): + _inherit = "base_import.import" + + @api.model + def _parse_float_from_data(self, data, index, name, options): + """Do not abort the whole import on the first badly-formatted number. + + The standard method (see base_import/models/base_import.py) raises an + ``ImportValidationError`` as soon as one cell of a float/monetary column + cannot be parsed, which cancels the entire import and hides every other + error (and the offending row number). Here, when a value cannot be + parsed, we leave the original value untouched so the per-record ORM + converter reports it with its row number, together with every other + field/record error, in a single pass. Well-formed values are still + pre-cleaned (currency symbols, thousands separators) exactly as before. + """ + for line in data: + raw = line[index] = line[index].strip() + if not line[index]: + continue + thousand_separator, decimal_separator = self._infer_separators(line[index], options) + + if "E" in line[index] or "e" in line[index]: + tmp_value = line[index].replace(thousand_separator, ".") + try: + tmp_value = f"{float(tmp_value):f}" + line[index] = tmp_value + thousand_separator = " " + except Exception: # noqa: BLE001 + pass + + line[index] = line[index].replace(thousand_separator, "").replace(decimal_separator, ".") + cleaned = self._remove_currency_symbol(line[index]) + # Defer to the ORM converter instead of raising (which would cut the + # whole import): restore the raw value so the error is reported per + # row and accumulated with the rest. + line[index] = cleaned if cleaned is not False else raw + + @api.model + def _parse_date_from_data(self, data, index, name, field_type, options): + """Same rationale as ``_parse_float_from_data`` for date/datetime columns. + + A badly-formatted date no longer cancels the whole import: the raw value + is kept and the per-record ORM converter reports it (with its row number + and the expected format hint) alongside every other error. Genuinely + unexpected (non-``ValueError``) failures are still raised, as they signal + a real problem rather than user data. + """ + dt = datetime.datetime + fmt = fields.Date.to_string if field_type == "date" else fields.Datetime.to_string + d_fmt = options.get("date_format") or DEFAULT_SERVER_DATE_FORMAT + dt_fmt = options.get("datetime_format") or DEFAULT_SERVER_DATETIME_FORMAT + for line in data: + if not line[index] or isinstance(line[index], datetime.date): + continue + + raw = line[index] + v = line[index].strip() + try: + # first try parsing as a datetime if it's one + if dt_fmt and field_type == "datetime": + try: + line[index] = fmt(dt.strptime(v, dt_fmt)) + continue + except ValueError: + pass + # otherwise try parsing as a date whether it's a date + # or datetime + line[index] = fmt(dt.strptime(v, d_fmt)) + except ValueError: + # Bad user-supplied date: keep the raw value and let the ORM + # converter report it per row instead of cutting the import. + line[index] = raw diff --git a/base_import_ux/tests/__init__.py b/base_import_ux/tests/__init__.py new file mode 100644 index 00000000..0e5ae9c5 --- /dev/null +++ b/base_import_ux/tests/__init__.py @@ -0,0 +1 @@ +from . import test_import_prevalidation diff --git a/base_import_ux/tests/test_import_prevalidation.py b/base_import_ux/tests/test_import_prevalidation.py new file mode 100644 index 00000000..70039ddb --- /dev/null +++ b/base_import_ux/tests/test_import_prevalidation.py @@ -0,0 +1,26 @@ +from odoo.tests.common import TransactionCase + + +class TestImportPreValidation(TransactionCase): + """A single badly-formatted number/date must NOT abort the whole import: + the raw value is kept so the per-record ORM converter reports it (with its + row) alongside every other error, instead of raising on the first bad cell. + """ + + def test_bad_float_does_not_abort_and_keeps_raw(self): + imp = self.env["base_import.import"] + data = [["100"], ["abc"], ["1.234,56"]] + # Must not raise (standard behaviour raised ImportValidationError here). + imp._parse_float_from_data(data, 0, "list_price", {}) + self.assertEqual(data[0][0], "100") # good value cleaned + self.assertEqual(data[1][0], "abc") # bad value deferred to the ORM + # good value with thousands/decimal separators still normalised + self.assertEqual(data[2][0], "1234.56") + + def test_bad_date_does_not_abort_and_keeps_raw(self): + imp = self.env["base_import.import"] + data = [["2024-01-15"], ["not-a-date"], ["2024-02-20"]] + imp._parse_date_from_data(data, 0, "date", "date", {}) + self.assertEqual(data[0][0], "2024-01-15") + self.assertEqual(data[1][0], "not-a-date") # bad value deferred, not cut + self.assertEqual(data[2][0], "2024-02-20") diff --git a/base_ux/README.rst b/base_ux/README.rst index 77476d68..fa0b92c2 100644 --- a/base_ux/README.rst +++ b/base_ux/README.rst @@ -21,7 +21,7 @@ Several Improvements: * Make parent field on res.company invisible as it is useless now * Keep the activity's description when changing the activity type, regardless of the activity type's description, and change the activity user only if the activity type has a default user. * Make company_registry field on res.company invisible as it is useless now - * Show or hide the activity badge widget depending on the number of activity types defined in the system (configurable threshold, default to 5). + * On the "Schedule Activity" dialog, show only the first N activity types (ordered by sequence) as quick badges, plus a dropdown to search among all the remaining ones. It applies both when scheduling a new activity and when editing an existing one; on an existing activity its current type is always kept among the badges. N is set through the system parameter base_ux.activity_quick_badges (default to 5). Installation diff --git a/base_ux/__manifest__.py b/base_ux/__manifest__.py index 61117327..00dcac03 100644 --- a/base_ux/__manifest__.py +++ b/base_ux/__manifest__.py @@ -19,7 +19,7 @@ ############################################################################## { "name": "Base UX", - "version": "19.0.1.7.0", + "version": "19.0.1.8.0", "category": "Base", "sequence": 14, "summary": "", @@ -38,6 +38,7 @@ "views/res_company_view.xml", "views/base_partner_merge_view.xml", "views/mail_activity_schedule_view.xml", + "views/mail_activity_view.xml", "views/res_partner_views.xml", ], "assets": { diff --git a/base_ux/i18n/base_ux.pot b/base_ux/i18n/base_ux.pot index bbdcef40..ff12a2a2 100644 --- a/base_ux/i18n/base_ux.pot +++ b/base_ux/i18n/base_ux.pot @@ -6,8 +6,8 @@ msgid "" msgstr "" "Project-Id-Version: Odoo Server 19.0+e\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-06-29 18:29+0000\n" -"PO-Revision-Date: 2026-06-29 18:29+0000\n" +"POT-Creation-Date: 2026-07-31 18:53+0000\n" +"PO-Revision-Date: 2026-07-31 18:53+0000\n" "Last-Translator: \n" "Language-Team: \n" "MIME-Version: 1.0\n" @@ -17,6 +17,7 @@ msgstr "" #. module: base_ux #: model_terms:ir.ui.view,arch_db:base_ux.mail_activity_schedule_view_form_inherit +#: model_terms:ir.ui.view,arch_db:base_ux.mail_activity_view_form_popup_inherit msgid "¿No encontrás el tipo que buscas?" msgstr "" @@ -39,6 +40,11 @@ msgstr "" msgid "Activity" msgstr "" +#. module: base_ux +#: model:ir.model,name:base_ux.model_mail_activity_type +msgid "Activity Type" +msgstr "" + #. module: base_ux #: model:ir.model,name:base_ux.model_mail_activity_schedule msgid "Activity schedule plan Wizard" @@ -57,6 +63,7 @@ msgstr "" #. module: base_ux #: model_terms:ir.ui.view,arch_db:base_ux.mail_activity_schedule_view_form_inherit +#: model_terms:ir.ui.view,arch_db:base_ux.mail_activity_view_form_popup_inherit msgid "Buscar otro tipo de actividad..." msgstr "" @@ -81,6 +88,7 @@ msgstr "" #: model:ir.model.fields,field_description:base_ux.field_ir_actions_server__display_name #: model:ir.model.fields,field_description:base_ux.field_mail_activity__display_name #: model:ir.model.fields,field_description:base_ux.field_mail_activity_schedule__display_name +#: model:ir.model.fields,field_description:base_ux.field_mail_activity_type__display_name #: model:ir.model.fields,field_description:base_ux.field_mail_template__display_name #: model:ir.model.fields,field_description:base_ux.field_res_company__display_name #: model:ir.model.fields,field_description:base_ux.field_res_country_state__display_name @@ -104,6 +112,7 @@ msgstr "" #: model:ir.model.fields,field_description:base_ux.field_ir_actions_server__id #: model:ir.model.fields,field_description:base_ux.field_mail_activity__id #: model:ir.model.fields,field_description:base_ux.field_mail_activity_schedule__id +#: model:ir.model.fields,field_description:base_ux.field_mail_activity_type__id #: model:ir.model.fields,field_description:base_ux.field_mail_template__id #: model:ir.model.fields,field_description:base_ux.field_res_company__id #: model:ir.model.fields,field_description:base_ux.field_res_country_state__id @@ -112,6 +121,7 @@ msgid "ID" msgstr "" #. module: base_ux +#: model:ir.model.fields,help:base_ux.field_mail_activity__quick_activity_ids #: model:ir.model.fields,help:base_ux.field_mail_activity_schedule__quick_activity_ids msgid "IDs de los tipos de actividad más frecuentes para mostrar como badges." msgstr "" @@ -128,6 +138,7 @@ msgid "Merge Partner Wizard" msgstr "" #. module: base_ux +#: model:ir.model.fields,field_description:base_ux.field_mail_activity__quick_activity_ids #: model:ir.model.fields,field_description:base_ux.field_mail_activity_schedule__quick_activity_ids msgid "Quick Activity" msgstr "" diff --git a/base_ux/i18n/es.po b/base_ux/i18n/es.po index e5e3be38..dd4d5700 100644 --- a/base_ux/i18n/es.po +++ b/base_ux/i18n/es.po @@ -9,7 +9,7 @@ msgid "" msgstr "" "Project-Id-Version: Odoo Server 19.0+e\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-06-29 18:29+0000\n" +"POT-Creation-Date: 2026-07-31 18:53+0000\n" "PO-Revision-Date: 2026-06-30 15:01+0000\n" "Last-Translator: BAF \n" "Language-Team: Spanish ¿No encontrás el tipo que buscas?" msgstr "¿No encontrás el tipo que buscas?" @@ -46,6 +47,11 @@ msgstr "Activo" msgid "Activity" msgstr "Actividad" +#. module: base_ux +#: model:ir.model,name:base_ux.model_mail_activity_type +msgid "Activity Type" +msgstr "" + #. module: base_ux #: model:ir.model,name:base_ux.model_mail_activity_schedule msgid "Activity schedule plan Wizard" @@ -64,6 +70,7 @@ msgstr "Archivado" #. module: base_ux #: model_terms:ir.ui.view,arch_db:base_ux.mail_activity_schedule_view_form_inherit +#: model_terms:ir.ui.view,arch_db:base_ux.mail_activity_view_form_popup_inherit msgid "Buscar otro tipo de actividad..." msgstr "Buscar otro tipo de actividad..." @@ -88,6 +95,7 @@ msgstr "Provincia del país" #: model:ir.model.fields,field_description:base_ux.field_ir_actions_server__display_name #: model:ir.model.fields,field_description:base_ux.field_mail_activity__display_name #: model:ir.model.fields,field_description:base_ux.field_mail_activity_schedule__display_name +#: model:ir.model.fields,field_description:base_ux.field_mail_activity_type__display_name #: model:ir.model.fields,field_description:base_ux.field_mail_template__display_name #: model:ir.model.fields,field_description:base_ux.field_res_company__display_name #: model:ir.model.fields,field_description:base_ux.field_res_country_state__display_name @@ -111,6 +119,7 @@ msgstr "Plantillas de correo electrónico" #: model:ir.model.fields,field_description:base_ux.field_ir_actions_server__id #: model:ir.model.fields,field_description:base_ux.field_mail_activity__id #: model:ir.model.fields,field_description:base_ux.field_mail_activity_schedule__id +#: model:ir.model.fields,field_description:base_ux.field_mail_activity_type__id #: model:ir.model.fields,field_description:base_ux.field_mail_template__id #: model:ir.model.fields,field_description:base_ux.field_res_company__id #: model:ir.model.fields,field_description:base_ux.field_res_country_state__id @@ -119,6 +128,7 @@ msgid "ID" msgstr "ID" #. module: base_ux +#: model:ir.model.fields,help:base_ux.field_mail_activity__quick_activity_ids #: model:ir.model.fields,help:base_ux.field_mail_activity_schedule__quick_activity_ids msgid "IDs de los tipos de actividad más frecuentes para mostrar como badges." msgstr "IDs de los tipos de actividad más frecuentes para mostrar como badges." @@ -135,6 +145,7 @@ msgid "Merge Partner Wizard" msgstr "Asistente para fusionar contactos" #. module: base_ux +#: model:ir.model.fields,field_description:base_ux.field_mail_activity__quick_activity_ids #: model:ir.model.fields,field_description:base_ux.field_mail_activity_schedule__quick_activity_ids msgid "Quick Activity" msgstr "Actividad rápida" diff --git a/base_ux/models/__init__.py b/base_ux/models/__init__.py index 4299d701..5d1f3372 100644 --- a/base_ux/models/__init__.py +++ b/base_ux/models/__init__.py @@ -10,4 +10,5 @@ from . import mail_template from . import ir_actions_server from . import mail_activity +from . import mail_activity_type from . import mail_activity_schedule diff --git a/base_ux/models/mail_activity.py b/base_ux/models/mail_activity.py index 2ae4cb4b..2fa84777 100644 --- a/base_ux/models/mail_activity.py +++ b/base_ux/models/mail_activity.py @@ -2,12 +2,29 @@ # For copyright and license notices, see __manifest__.py file in module root # directory ############################################################################## -from odoo import api, models +from odoo import api, fields, models class MailActivity(models.Model): _inherit = "mail.activity" + quick_activity_ids = fields.Many2many( + "mail.activity.type", + compute="_compute_quick_activity_ids", + help="IDs de los tipos de actividad más frecuentes para mostrar como badges.", + ) + + @api.depends("activity_type_id") + @api.depends_context("uid") + def _compute_quick_activity_ids(self): + """Las primeras N actividades por sequence, más el tipo ya seteado en la actividad. + Incluir el tipo actual es necesario al editar una actividad existente: si su tipo no + está entre los N sugeridos, el badge de la selección vigente no se renderizaría y + visualmente se perdería el valor guardado.""" + quick_types = self.env["mail.activity.type"]._get_quick_activity_types() + for record in self: + record.quick_activity_ids = quick_types | record.activity_type_id + @api.onchange("activity_type_id") def _onchange_activity_type_id(self): """overrides original method: keep the activity description when diff --git a/base_ux/models/mail_activity_schedule.py b/base_ux/models/mail_activity_schedule.py index 81e8bca1..432e83cc 100644 --- a/base_ux/models/mail_activity_schedule.py +++ b/base_ux/models/mail_activity_schedule.py @@ -17,9 +17,6 @@ class MailActivitySchedule(models.TransientModel): @api.depends_context("uid") def _compute_quick_activity_ids(self): """Obtiene las primeras N actividades ordenadas por sequence.""" - # Obtener el límite desde parámetros del sistema (por defecto 5) - limit_param = self.env["ir.config_parameter"].sudo().get_param("base_ux.activity_quick_badges", "5") - limit = int(limit_param) - quick_types = self.env["mail.activity.type"].search([], order="sequence, id", limit=limit) + quick_types = self.env["mail.activity.type"]._get_quick_activity_types() for record in self: record.quick_activity_ids = quick_types diff --git a/base_ux/models/mail_activity_type.py b/base_ux/models/mail_activity_type.py new file mode 100644 index 00000000..64b15381 --- /dev/null +++ b/base_ux/models/mail_activity_type.py @@ -0,0 +1,17 @@ +############################################################################## +# For copyright and license notices, see __manifest__.py file in module root +# directory +############################################################################## +from odoo import api, models + + +class MailActivityType(models.Model): + _inherit = "mail.activity.type" + + @api.model + def _get_quick_activity_types(self): + """Devuelve las primeras N actividades ordenadas por sequence, para mostrar + como badges de acceso rápido. N sale del parámetro del sistema + base_ux.activity_quick_badges (por defecto 5).""" + limit_param = self.env["ir.config_parameter"].sudo().get_param("base_ux.activity_quick_badges", "5") + return self.search([], order="sequence, id", limit=int(limit_param)) diff --git a/base_ux/tests/__init__.py b/base_ux/tests/__init__.py index a6970e38..9ef3b835 100644 --- a/base_ux/tests/__init__.py +++ b/base_ux/tests/__init__.py @@ -1 +1,2 @@ from . import test_base_partner_merge +from . import test_mail_activity_quick_badges diff --git a/base_ux/tests/test_mail_activity_quick_badges.py b/base_ux/tests/test_mail_activity_quick_badges.py new file mode 100644 index 00000000..1781c208 --- /dev/null +++ b/base_ux/tests/test_mail_activity_quick_badges.py @@ -0,0 +1,47 @@ +############################################################################## +# For copyright and license notices, see __manifest__.py file in module root +# directory +############################################################################## +from odoo.tests import TransactionCase + + +class TestMailActivityQuickBadges(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env["ir.config_parameter"].set_param("base_ux.activity_quick_badges", "2") + cls.quick_types = cls.env["mail.activity.type"].create( + [ + {"name": "Test quick 1", "sequence": -20}, + {"name": "Test quick 2", "sequence": -19}, + ] + ) + cls.other_type = cls.env["mail.activity.type"].create({"name": "Test not quick", "sequence": 999}) + cls.partner_model_id = cls.env["ir.model"]._get_id("res.partner") + cls.partner = cls.env["res.partner"].create({"name": "Test activity quick badges"}) + + def test_quick_types_limited_by_parameter(self): + self.assertEqual(self.env["mail.activity.type"]._get_quick_activity_types(), self.quick_types) + + def test_schedule_wizard_shows_only_quick_types(self): + wizard = self.env["mail.activity.schedule"].create( + { + "res_model_id": self.partner_model_id, + "res_model": "res.partner", + "res_ids": str(self.partner.ids), + } + ) + self.assertEqual(wizard.quick_activity_ids, self.quick_types) + + def test_existing_activity_keeps_its_own_type(self): + """When editing an activity whose type is not among the quick ones, that type must + still be offered as a badge, otherwise the stored value is not rendered at all.""" + activity = self.env["mail.activity"].create( + { + "res_model_id": self.partner_model_id, + "res_id": self.partner.id, + "activity_type_id": self.other_type.id, + "summary": "test", + } + ) + self.assertEqual(activity.quick_activity_ids, self.quick_types | self.other_type) diff --git a/base_ux/views/mail_activity_view.xml b/base_ux/views/mail_activity_view.xml new file mode 100644 index 00000000..66a6f3e0 --- /dev/null +++ b/base_ux/views/mail_activity_view.xml @@ -0,0 +1,42 @@ + + + + + + mail.activity.view.form.popup.inherit + mail.activity + + + + + + + + + + [('id', 'in', quick_activity_ids)] + + + + +
+ ¿No encontrás el tipo que buscas? + +
+
+
+
+ +
diff --git a/portal_backend/__manifest__.py b/portal_backend/__manifest__.py index ee62c560..f3570560 100644 --- a/portal_backend/__manifest__.py +++ b/portal_backend/__manifest__.py @@ -19,7 +19,7 @@ ############################################################################## { "name": "Portal Backend", - "version": "19.0.1.2.0", + "version": "19.0.1.3.0", "category": "Base", "sequence": 14, "summary": "", diff --git a/portal_backend/models/ir_ui_menu.py b/portal_backend/models/ir_ui_menu.py index 2ef6ecb1..80fd78fd 100644 --- a/portal_backend/models/ir_ui_menu.py +++ b/portal_backend/models/ir_ui_menu.py @@ -1,18 +1,22 @@ -from odoo import Command, api, models, tools +from odoo import Command, models class IrUiMenu(models.Model): _inherit = "ir.ui.menu" - @api.model - @tools.ormcache("self.env.uid", "debug", "self.env.lang") - def load_menus(self, debug): - """Assert all parent menus has internal group.""" - # NOTE: - # It is important to do it here to capture the case when portal_backend is already installed and the user - # installs another module with a parent menu without internal group. + def _register_hook(self): + """Assert all parent menus have internal group. + + NOTE: + It is done on registry load to capture the case when portal_backend is already installed and the user + installs another module with a parent menu without internal group. + It can not be done while loading the menus: that route is readonly, so writing there breaks its cursor + with "cannot execute INSERT in a read-only transaction" (the request is retried with a read/write one, + but the failed query is already logged as an error). + """ + super()._register_hook() parent_menus_wo_group = self.sudo().search([("parent_id", "=", False), ("group_ids", "=", False)]) - parent_menus_wo_group.with_context(from_config=True).write( - {"group_ids": [Command.link(self.env.ref("base.group_user").id)]} - ) - return super().load_menus(debug=debug) + if parent_menus_wo_group: + parent_menus_wo_group.with_context(from_config=True).write( + {"group_ids": [Command.link(self.env.ref("base.group_user").id)]} + ) diff --git a/portal_backend/models/res_users.py b/portal_backend/models/res_users.py index d12d7eec..2eba5342 100644 --- a/portal_backend/models/res_users.py +++ b/portal_backend/models/res_users.py @@ -85,21 +85,27 @@ def _inverse_access_type(self): user.group_ids = self._groups_for_access_type(user.group_ids, user.access_type) def _view_group_hierarchy_without_advanced(self): - """Full group hierarchy minus the Advanced Portal category (for the native widget).""" + """Full group hierarchy minus the Advanced Portal category and its subcategories (for the native widget).""" hierarchy = copy.deepcopy(self.env["res.groups"]._get_view_group_hierarchy()) category = self._portal_advanced_category() if category: - hierarchy["categories"] = [c for c in hierarchy["categories"] if c["id"] != category.id] + category_ids = self.env["ir.module.category"].search([("id", "child_of", category.id)]).ids + hierarchy["categories"] = [c for c in hierarchy["categories"] if c["id"] not in category_ids] return hierarchy def _portal_advanced_view_group_hierarchy(self): - """Only the Advanced Portal category (for the portal_advanced_group_ids widget).""" + """The Advanced Portal category and its subcategories (for the portal_advanced_group_ids widget). + + Modules like academic nest their own category under Advanced Portal (parent_id) to keep + their subtitle in the widget; child_of pulls those in too, not just the exact category. + """ empty = {"groups": {}, "privileges": {}, "categories": []} category = self._portal_advanced_category() if not category: return empty + category_ids = self.env["ir.module.category"].search([("id", "child_of", category.id)]).ids full = copy.deepcopy(self.env["res.groups"]._get_view_group_hierarchy()) - categories = [c for c in full["categories"] if c["id"] == category.id] + categories = [c for c in full["categories"] if c["id"] in category_ids] if not categories: return empty privilege_ids = {pid for c in categories for pid in c["privilege_ids"]} @@ -231,8 +237,8 @@ def _portal_advanced_category(self): return self.env.ref("portal_backend.category_portal_advanced", raise_if_not_found=False) def _portal_advanced_groups(self): - """Groups belonging to the Advanced Portal category (timesheets, holidays, etc.).""" + """Groups belonging to the Advanced Portal category or one of its subcategories (timesheets, holidays, etc.).""" category = self._portal_advanced_category() if not category: return self.env["res.groups"] - return self.env["res.groups"].sudo().search([("privilege_id.category_id", "=", category.id)]) + return self.env["res.groups"].sudo().search([("privilege_id.category_id", "child_of", category.id)]) diff --git a/portal_backend/static/src/portal_advanced_group_ids/portal_advanced_group_ids_field.js b/portal_backend/static/src/portal_advanced_group_ids/portal_advanced_group_ids_field.js index 9a3dba09..122d8c71 100644 --- a/portal_backend/static/src/portal_advanced_group_ids/portal_advanced_group_ids_field.js +++ b/portal_backend/static/src/portal_advanced_group_ids/portal_advanced_group_ids_field.js @@ -16,16 +16,22 @@ const nativeDef = fieldsRegistry.get("res_user_group_ids"); class PortalAdvancedGroupIdsField extends nativeDef.component { setup() { const record = this.props.record; - const dataProxy = new Proxy(record.data, { - get: (data, key) => - key === "view_group_hierarchy" - ? data.portal_advanced_view_group_hierarchy - : data[key], - }); const recordProxy = new Proxy(record, { get: (target, key) => { if (key === "data") { - return dataProxy; + // Wrap `target.data` fresh on every access instead of caching a single Proxy + // bound to the `record.data` reference seen at setup() time: on a cold load, + // the record can still be hydrating when setup() runs, and Odoo later swaps in + // the fully-loaded `data` object rather than mutating the placeholder in place. + // A one-time-captured proxy keeps pointing at that stale placeholder forever, + // so the widget renders the group selected before the real data arrived (fixed + // only by a second reload, once the swap already happened on the previous load). + return new Proxy(target.data, { + get: (data, dataKey) => + dataKey === "view_group_hierarchy" + ? data.portal_advanced_view_group_hierarchy + : data[dataKey], + }); } const value = target[key]; return typeof value === "function" ? value.bind(target) : value; diff --git a/portal_holidays/i18n/es.po b/portal_holidays/i18n/es.po index d647eef8..5906c1f6 100644 --- a/portal_holidays/i18n/es.po +++ b/portal_holidays/i18n/es.po @@ -9,7 +9,7 @@ msgid "" msgstr "" "Project-Id-Version: Odoo Server 19.0+e\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-14 19:21+0000\n" +"POT-Creation-Date: 2026-08-13 22:26+0000\n" "PO-Revision-Date: 2025-12-11 18:10+0000\n" "Last-Translator: BAF \n" "Language-Team: Spanish `_. In case of trouble, please +check there if your issue has already been reported. If you spotted it first, +help us smashing it by providing a detailed and welcomed feedback. + +Credits +======= + +Images +------ + +* |company| |icon| + +Contributors +------------ + +Maintainer +---------- + +|company_logo| + +This module is maintained by the |company|. + +To contribute to this module, please visit https://www.adhoc.com.ar. diff --git a/portal_ux/__init__.py b/portal_ux/__init__.py new file mode 100644 index 00000000..c72130af --- /dev/null +++ b/portal_ux/__init__.py @@ -0,0 +1,5 @@ +############################################################################## +# For copyright and license notices, see __manifest__.py file in module root +# directory +############################################################################## +from . import wizards diff --git a/portal_ux/__manifest__.py b/portal_ux/__manifest__.py new file mode 100644 index 00000000..e8605a92 --- /dev/null +++ b/portal_ux/__manifest__.py @@ -0,0 +1,41 @@ +############################################################################## +# +# Copyright (C) 2026 ADHOC SA (http://www.adhoc.com.ar) +# All Rights Reserved. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +############################################################################## +{ + "name": "Portal UX", + "version": "19.0.1.0.0", + "category": "Tools", + "sequence": 14, + "summary": "", + "author": "ADHOC SA", + "website": "www.adhoc.com.ar", + "license": "AGPL-3", + "images": [], + "depends": [ + "portal", + ], + "data": [ + "wizards/portal_wizard_views.xml", + ], + "demo": [], + "test": [], + "installable": True, + "auto_install": False, + "application": False, +} diff --git a/portal_ux/i18n/portal_ux.pot b/portal_ux/i18n/portal_ux.pot new file mode 100644 index 00000000..bf5918be --- /dev/null +++ b/portal_ux/i18n/portal_ux.pot @@ -0,0 +1,42 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * portal_ux +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 19.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-22 13:48+0000\n" +"PO-Revision-Date: 2026-07-22 13:48+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: portal_ux +#: model:ir.model.fields,field_description:portal_ux.field_portal_wizard__display_name +msgid "Display Name" +msgstr "" + +#. module: portal_ux +#: model_terms:ir.ui.view,arch_db:portal_ux.wizard_view +msgid "Grant Access to All" +msgstr "" + +#. module: portal_ux +#: model:ir.model,name:portal_ux.model_portal_wizard +msgid "Grant Portal Access" +msgstr "" + +#. module: portal_ux +#: model:ir.model.fields,field_description:portal_ux.field_portal_wizard__id +msgid "ID" +msgstr "" + +#. module: portal_ux +#: model_terms:ir.ui.view,arch_db:portal_ux.wizard_view +msgid "" +"This button will grant access to all contacts with a valid email address." +msgstr "" diff --git a/portal_ux/tests/__init__.py b/portal_ux/tests/__init__.py new file mode 100644 index 00000000..7c663517 --- /dev/null +++ b/portal_ux/tests/__init__.py @@ -0,0 +1,5 @@ +############################################################################## +# For copyright and license notices, see __manifest__.py file in module root +# directory +############################################################################## +from . import test_portal_wizard diff --git a/portal_ux/tests/test_portal_wizard.py b/portal_ux/tests/test_portal_wizard.py new file mode 100644 index 00000000..8d9f825c --- /dev/null +++ b/portal_ux/tests/test_portal_wizard.py @@ -0,0 +1,46 @@ +############################################################################## +# For copyright and license notices, see __manifest__.py file in module root +# directory +############################################################################## +from odoo.tests import TransactionCase, tagged + + +@tagged("-at_install", "post_install") +class TestPortalWizard(TransactionCase): + def test_action_grant_access_all(self): + partner_model = self.env["res.partner"].sudo() + valid_partner_1 = partner_model.create({"name": "Valid Contact 1", "email": "valid1@test.example.com"}) + valid_partner_2 = partner_model.create({"name": "Valid Contact 2", "email": "valid2@test.example.com"}) + invalid_partner = partner_model.create({"name": "Invalid Contact", "email": "not-an-email"}) + + partners = valid_partner_1 + valid_partner_2 + invalid_partner + wizard = self.env["portal.wizard"].with_context(active_ids=partners.ids).create({}) + self.assertEqual(len(wizard.user_ids), 3) + + wizard.action_grant_access_all() + + group_portal = self.env.ref("base.group_portal") + for partner in (valid_partner_1, valid_partner_2): + self.assertTrue(partner.user_ids, "A portal user should have been created") + self.assertIn(group_portal, partner.user_ids.group_ids) + + self.assertFalse( + invalid_partner.user_ids, + "No user should be created for a contact with an invalid email", + ) + + def test_action_grant_access_all_skips_existing_portal_user(self): + partner_model = self.env["res.partner"].sudo() + already_portal_partner = partner_model.create( + {"name": "Already Portal Contact", "email": "already@test.example.com"} + ) + wizard = self.env["portal.wizard"].with_context(active_ids=already_portal_partner.ids).create({}) + wizard.user_ids.action_grant_access() + user = already_portal_partner.user_ids + self.assertTrue(user, "Precondition: partner should already have a portal user") + + # Re-open the wizard: the existing portal user must be skipped, not re-processed. + wizard_2 = self.env["portal.wizard"].with_context(active_ids=already_portal_partner.ids).create({}) + wizard_2.action_grant_access_all() + + self.assertEqual(already_portal_partner.user_ids, user) diff --git a/portal_ux/wizards/__init__.py b/portal_ux/wizards/__init__.py new file mode 100644 index 00000000..bad0004e --- /dev/null +++ b/portal_ux/wizards/__init__.py @@ -0,0 +1,5 @@ +############################################################################## +# For copyright and license notices, see __manifest__.py file in module root +# directory +############################################################################## +from . import portal_wizard diff --git a/portal_ux/wizards/portal_wizard.py b/portal_ux/wizards/portal_wizard.py new file mode 100644 index 00000000..2d9a2c3c --- /dev/null +++ b/portal_ux/wizards/portal_wizard.py @@ -0,0 +1,15 @@ +############################################################################## +# For copyright and license notices, see __manifest__.py file in module root +# directory +############################################################################## +from odoo import models + + +class PortalWizard(models.TransientModel): + _inherit = "portal.wizard" + + def action_grant_access_all(self): + for wizard_user in self.user_ids: + if not wizard_user.is_portal and not wizard_user.is_internal and wizard_user.email_state == "ok": + wizard_user.action_grant_access() + return self._action_open_modal() diff --git a/portal_ux/wizards/portal_wizard_views.xml b/portal_ux/wizards/portal_wizard_views.xml new file mode 100644 index 00000000..c4d6d9a8 --- /dev/null +++ b/portal_ux/wizards/portal_wizard_views.xml @@ -0,0 +1,14 @@ + + + portal.wizard.form (in portal_ux) + portal.wizard + + + +