diff --git a/NEWS.rst b/NEWS.rst
index f323fa8e06696c8bbf554a8a09f517334b86059f_TkVXUy5yc3Q=..21c89ac3dc80de3443c256e85de54000d91edc89_TkVXUy5yc3Q= 100644
--- a/NEWS.rst
+++ b/NEWS.rst
@@ -1,6 +1,20 @@
 Changelog
 =========
 
+17.0.1.1.0
+----------
+
+Features
+~~~~~~~~
+
+- In job log view, only display link to job definition to group system/configuration.
+
+Fixes
+~~~~~
+
+- fix readonly condition on job_definition_id in job log form view.
+- fix duplicating jobs.
+
 17.0.1.0.1
 ----------
 
diff --git a/__manifest__.py b/__manifest__.py
index f323fa8e06696c8bbf554a8a09f517334b86059f_X19tYW5pZmVzdF9fLnB5..21c89ac3dc80de3443c256e85de54000d91edc89_X19tYW5pZmVzdF9fLnB5 100644
--- a/__manifest__.py
+++ b/__manifest__.py
@@ -20,7 +20,7 @@
 ##############################################################################
 {
     "name": "External Jobs",
-    "version": "17.0.1.0.1",
+    "version": "17.0.1.1.0",
     "license": "AGPL-3",
     "author": "XCG Consulting",
     "category": "Tools",
diff --git a/doc/autotodo.py b/doc/autotodo.py
index f323fa8e06696c8bbf554a8a09f517334b86059f_ZG9jL2F1dG90b2RvLnB5..21c89ac3dc80de3443c256e85de54000d91edc89_ZG9jL2F1dG90b2RvLnB5 100755
--- a/doc/autotodo.py
+++ b/doc/autotodo.py
@@ -22,7 +22,7 @@
 import os
 import os.path
 import sys
-from collections.abc import Mapping
+from collections.abc import MutableMapping
 
 
 def main():
@@ -31,13 +31,13 @@
         sys.exit(1)
 
     folder = sys.argv[1]
-    exts = sys.argv[2].split(",")
-    tags = sys.argv[3].split(",")
-    todolist = {tag: [] for tag in tags}
-    path_file_length: Mapping[str, int] = {}
+    exts: list[str] = sys.argv[2].split(",")
+    tags: list[str] = sys.argv[3].split(",")
+    todolist: dict[str, list[tuple[str, int, str]]] = {tag: [] for tag in tags}
+    path_file_length: MutableMapping[str, int] = {}
 
     for root, _dirs, files in os.walk(folder):
         scan_folder((exts, tags, todolist, path_file_length), root, files)
     create_autotodo(folder, todolist, path_file_length)
 
 
@@ -38,10 +38,10 @@
 
     for root, _dirs, files in os.walk(folder):
         scan_folder((exts, tags, todolist, path_file_length), root, files)
     create_autotodo(folder, todolist, path_file_length)
 
 
-def write_info(f, infos, folder, path_file_length: Mapping[str, int]):
+def write_info(f, infos, folder, path_file_length: MutableMapping[str, int]):
     # Check sphinx version for lineno-start support
 
     import sphinx
@@ -55,7 +55,7 @@
         path = i[0]
         line = i[1]
         lines = (line - 3, min(line + 4, path_file_length[path]))
-        class_name = ":class:`%s`" % os.path.basename(os.path.splitext(path)[0])
+        class_name = f":class:`{os.path.basename(os.path.splitext(path)[0])}`"
         f.write(
             "{}\n"
             "{}\n\n"
@@ -74,7 +74,7 @@
             )
         )
         if lineno_start:
-            f.write("\t\t:lineno-start: %s\n" % lines[0])
+            f.write(f"\t\t:lineno-start: {lines[0]}\n")
         f.write("\n")
 
 
@@ -78,10 +78,10 @@
         f.write("\n")
 
 
-def create_autotodo(folder, todolist, path_file_length: Mapping[str, int]):
+def create_autotodo(folder, todolist, path_file_length: MutableMapping[str, int]):
     with open("autotodo", "w+") as f:
         for tag, info in list(todolist.items()):
             f.write("{}\n{}\n\n".format(tag, "=" * len(tag)))
             write_info(f, info, folder, path_file_length)
 
 
@@ -82,10 +82,19 @@
     with open("autotodo", "w+") as f:
         for tag, info in list(todolist.items()):
             f.write("{}\n{}\n\n".format(tag, "=" * len(tag)))
             write_info(f, info, folder, path_file_length)
 
 
-def scan_folder(data_tuple, dirname, names):
+def scan_folder(
+    data_tuple: tuple[
+        list[str],
+        list[str],
+        dict[str, list[tuple[str, int, str]]],
+        MutableMapping[str, int],
+    ],
+    dirname: str,
+    names: list[str],
+):
     (exts, tags, res, path_file_length) = data_tuple
     for name in names:
         (root, ext) = os.path.splitext(name)
@@ -98,7 +107,9 @@
                     res[tag].extend(info)
 
 
-def scan_file(filename, tags) -> tuple[dict[str, list[tuple[str, int, str]]], int]:
+def scan_file(
+    filename: str, tags: list[str]
+) -> tuple[dict[str, list[tuple[str, int, str]]], int]:
     res: dict[str, list[tuple[str, int, str]]] = {tag: [] for tag in tags}
     line_num: int = 0
     with open(filename) as f:
diff --git a/doc/conf.py b/doc/conf.py
index f323fa8e06696c8bbf554a8a09f517334b86059f_ZG9jL2NvbmYucHk=..21c89ac3dc80de3443c256e85de54000d91edc89_ZG9jL2NvbmYucHk= 100644
--- a/doc/conf.py
+++ b/doc/conf.py
@@ -14,7 +14,7 @@
 from importlib.metadata import PackageNotFoundError
 from importlib.metadata import version as import_version
 
-import odoo
+import odoo  # type: ignore[import-untyped]
 from odoo_scripts.config import Configuration
 
 # If extensions (or modules to document with autodoc) are in another directory,
@@ -106,7 +106,7 @@
 html_static_path = ["_static"]
 
 # Output file base name for HTML help builder.
-htmlhelp_basename = "%sdoc" % module_nospace
+htmlhelp_basename = f"{module_nospace}doc"
 
 # -- Options for LaTeX output ---------------------------------------------
 
@@ -110,11 +110,9 @@
 
 # -- Options for LaTeX output ---------------------------------------------
 
-latex_elements = {}
-
 # Grouping the document tree into LaTeX files. List of tuples
 # (source start file, target name, title,
 #  author, documentclass [howto, manual, or own class]).
 latex_documents = [
     (
         master_doc,
@@ -115,11 +113,11 @@
 # Grouping the document tree into LaTeX files. List of tuples
 # (source start file, target name, title,
 #  author, documentclass [howto, manual, or own class]).
 latex_documents = [
     (
         master_doc,
-        "%s.tex" % module_nospace,
-        "%s Documentation" % project,
+        f"{module_nospace}.tex",
+        f"{project} Documentation",
         author,
         "manual",
     )
@@ -129,7 +127,7 @@
 
 # One entry per manual page. List of tuples
 # (source start file, name, description, authors, manual section).
-man_pages = [(master_doc, module_lowercase, "%s Documentation" % project, [author], 1)]
+man_pages = [(master_doc, module_lowercase, f"{project} Documentation", [author], 1)]
 
 # -- Options for Texinfo output -------------------------------------------
 
@@ -140,7 +138,7 @@
     (
         master_doc,
         module_nospace,
-        "%s Documentation" % project,
+        f"{project} Documentation",
         author,
         module_nospace,
         module_description,
diff --git a/doc/index.rst b/doc/index.rst
index f323fa8e06696c8bbf554a8a09f517334b86059f_ZG9jL2luZGV4LnJzdA==..21c89ac3dc80de3443c256e85de54000d91edc89_ZG9jL2luZGV4LnJzdA== 100644
--- a/doc/index.rst
+++ b/doc/index.rst
@@ -1,4 +1,4 @@
 .. |coverage| image:: .badges/coverage.svg
-    :target: https://orus.io/xcg/odoo-modules/external_job/-/pipelines?ref=branch/15.0
+    :target: https://orus.io/xcg/odoo-modules/external_job/-/pipelines?ref=branch/17.0
     :alt: Coverage report
 .. |pylint| image:: .badges/pylint.svg
@@ -3,6 +3,6 @@
     :alt: Coverage report
 .. |pylint| image:: .badges/pylint.svg
-    :target: https://orus.io/xcg/odoo-modules/external_job/-/pipelines?ref=branch/15.0
+    :target: https://orus.io/xcg/odoo-modules/external_job/-/pipelines?ref=branch/17.0
     :alt: pylint score
 
 |coverage| |pylint|
diff --git a/doc/modules.rst b/doc/modules.rst
index f323fa8e06696c8bbf554a8a09f517334b86059f_ZG9jL21vZHVsZXMucnN0..21c89ac3dc80de3443c256e85de54000d91edc89_ZG9jL21vZHVsZXMucnN0 100644
--- a/doc/modules.rst
+++ b/doc/modules.rst
@@ -1,5 +1,5 @@
-external_job
-============
+odoo.addons.external_job
+========================
 
 .. toctree::
    :maxdepth: 4
diff --git a/models/default_value.py b/models/default_value.py
index f323fa8e06696c8bbf554a8a09f517334b86059f_bW9kZWxzL2RlZmF1bHRfdmFsdWUucHk=..21c89ac3dc80de3443c256e85de54000d91edc89_bW9kZWxzL2RlZmF1bHRfdmFsdWUucHk= 100644
--- a/models/default_value.py
+++ b/models/default_value.py
@@ -1,7 +1,7 @@
 ###############################################################################
 #
 #    External Jobs, for Odoo
-#    Copyright © 2019, 2022 XCG Consulting (http://www.xcg-consulting.fr/)
+#    Copyright © 2019, 2022, 2024 XCG Consulting (http://www.xcg-consulting.fr/)
 #
 #    This program is free software: you can redistribute it and/or modify
 #    it under the terms of the GNU Affero General Public License as
@@ -18,7 +18,7 @@
 #
 ###############################################################################
 
-from odoo import fields, models
+from odoo import fields, models  # type: ignore[untyped-import]
 
 
 class DefaultValue(models.Model):
diff --git a/models/env_var.py b/models/env_var.py
index f323fa8e06696c8bbf554a8a09f517334b86059f_bW9kZWxzL2Vudl92YXIucHk=..21c89ac3dc80de3443c256e85de54000d91edc89_bW9kZWxzL2Vudl92YXIucHk= 100644
--- a/models/env_var.py
+++ b/models/env_var.py
@@ -1,7 +1,7 @@
 ##############################################################################
 #
 #    External Jobs, for Odoo
-#    Copyright © 2013, 2022 XCG Consulting (http://odoo.consulting)
+#    Copyright © 2013, 2022, 2024 XCG Consulting (http://odoo.consulting)
 #
 #    This program is free software: you can redistribute it and/or modify
 #    it under the terms of the GNU Affero General Public License as
@@ -18,7 +18,7 @@
 #
 ##############################################################################
 
-from odoo import fields, models
+from odoo import fields, models  # type: ignore[untyped-import]
 
 
 class EnvVar(models.Model):
diff --git a/models/extrunner_server.py b/models/extrunner_server.py
index f323fa8e06696c8bbf554a8a09f517334b86059f_bW9kZWxzL2V4dHJ1bm5lcl9zZXJ2ZXIucHk=..21c89ac3dc80de3443c256e85de54000d91edc89_bW9kZWxzL2V4dHJ1bm5lcl9zZXJ2ZXIucHk= 100644
--- a/models/extrunner_server.py
+++ b/models/extrunner_server.py
@@ -1,7 +1,7 @@
 ##############################################################################
 #
 #    External Jobs, for Odoo
-#    Copyright © 2019, 2023 XCG Consulting (https://xcg-consulting.fr/)
+#    Copyright © 2019, 2023, 2024 XCG Consulting (https://xcg-consulting.fr/)
 #
 #    This program is free software: you can redistribute it and/or modify
 #    it under the terms of the GNU Affero General Public License as
@@ -17,7 +17,7 @@
 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
 #
 ##############################################################################
-from odoo import fields, models
+from odoo import fields, models  # type: ignore[untyped-import]
 
 
 class ExtrunnerServer(models.Model):
diff --git a/models/job_definition.py b/models/job_definition.py
index f323fa8e06696c8bbf554a8a09f517334b86059f_bW9kZWxzL2pvYl9kZWZpbml0aW9uLnB5..21c89ac3dc80de3443c256e85de54000d91edc89_bW9kZWxzL2pvYl9kZWZpbml0aW9uLnB5 100644
--- a/models/job_definition.py
+++ b/models/job_definition.py
@@ -25,5 +25,6 @@
 import sys
 import tempfile
 from base64 import b64encode
+from typing import Any
 
 import requests
@@ -28,7 +29,7 @@
 
 import requests
-from odoo import _, api, exceptions, fields, models
-from odoo.tools import config
+from odoo import _, api, exceptions, fields, models  # type: ignore[untyped-import]
+from odoo.tools import config  # type: ignore[untyped-import]
 
 from .job_log import State
 
@@ -213,7 +214,7 @@
 
     def run_job(
         self, job_args: dict | None = None, in_file_name: str | None = None
-    ) -> models.Model:
+    ) -> models.BaseModel:
         """Run jobs
 
         :param job_args: arguments for the job, added as a section of the dict
@@ -223,6 +224,5 @@
         :return: external_job.job_log record list, to get a view change, use
             return_joblog_act_window, or use action_run_job directly
         """
-        joblogs = self.env["external_job.job_log"]
         # User do not have the rights to see most of the fields, or in the case of
         # extrunner, the models used, so use sudo to bypass the restrictions
@@ -227,10 +227,10 @@
         # User do not have the rights to see most of the fields, or in the case of
         # extrunner, the models used, so use sudo to bypass the restrictions
-        for job in self.sudo():
-            # Pylint does not understand that this is ourselves
-            # pylint: disable=protected-access
-            if job.job_type == "extrunner":
-                joblogs += job._call_extrunner()
-            if job.job_type == "cmd":
-                joblogs += job._call_cmd(job_args, in_file_name)
+        joblogs = (
+            self.sudo()
+            .env["external_job.job_log"]
+            .create(self.sudo()._vals_list(in_file_name))
+        )
+        self.sudo()._run(joblogs, job_args)
+        return joblogs.sudo(False)
 
@@ -236,4 +236,28 @@
 
-        return joblogs
+    def _vals_list(self, in_file_name: str | None = None) -> list[dict[str, Any]]:
+        vals_list = []
+        extrunner_jobs = self.filtered(lambda job: job.job_type == "extrunner")
+        # Pylint does not understand that this is ourselves
+        # pylint: disable=protected-access
+        if extrunner_jobs:
+            vals_list.extend(extrunner_jobs._vals_list_extrunner())
+        cmd_jobs = self.filtered(lambda job: job.job_type == "cmd")
+        if cmd_jobs:
+            vals_list.extend(cmd_jobs._vals_list_cmd(in_file_name))
+        return vals_list
+
+    def _run(self, joblogs: models.BaseModel, job_args: dict | None = None) -> None:
+        extrunner_job_logs = joblogs.filtered(
+            lambda joblog: joblog.job_definition_id.job_type == "extrunner"
+        )
+        # Pylint does not understand that this is ourselves
+        # pylint: disable=protected-access
+        if extrunner_job_logs:
+            self._call_extrunner(joblogs)
+        cmd_job_logs = joblogs.filtered(
+            lambda joblog: joblog.job_definition_id.job_type == "cmd"
+        )
+        if cmd_job_logs:
+            self._call_cmd(joblogs, job_args)
 
     @staticmethod
@@ -238,8 +262,8 @@
 
     @staticmethod
-    def return_joblog_act_window(joblogs: models.Model):
+    def return_joblog_act_window(joblogs: models.BaseModel):
         """Return a view change from a list of job log ids"""
         if len(joblogs) == 1:
             return JobDefinition._return_joblog_act_window(joblogs, default_view="form")
         return JobDefinition._return_joblog_act_window(joblogs, view_mode="tree")
 
@@ -241,12 +265,8 @@
         """Return a view change from a list of job log ids"""
         if len(joblogs) == 1:
             return JobDefinition._return_joblog_act_window(joblogs, default_view="form")
         return JobDefinition._return_joblog_act_window(joblogs, view_mode="tree")
 
-    def _call_cmd(
-        self, job_args: dict | None = None, in_file_name: str | None = None
-    ) -> models.Model:
-        cr, ctx = self.env.cr, self.env.context
-
-        job_logs = self.env["external_job.job_log"]
+    def _vals_list_cmd(self, in_file_name: str | None = None) -> list[dict[str, Any]]:
+        vals_list = []
         for job in self:
@@ -252,5 +272,5 @@
         for job in self:
-            command = job.command_line
+            # create
             job_log_vals = {"job_definition_id": job.id}
 
             if job.output_filename_base:
@@ -270,8 +290,18 @@
             if in_file_name_:
                 job_log_vals["in_file_name"] = in_file_name_
 
-            joblog = job_logs.create(job_log_vals)
-            job_logs += joblog
+            vals_list.append(job_log_vals)
+        return vals_list
+
+    @api.model
+    def _call_cmd(
+        self, job_logs: models.BaseModel, job_args: dict | None = None
+    ) -> None:
+        cr, ctx = self.env.cr, self.env.context
+
+        for joblog in job_logs:
+            job = joblog.job_definition_id
+            command = job.command_line
 
             arg_dict = ArgumentDict(
                 job,
@@ -285,9 +315,9 @@
             if job.input_content:
                 # TODO handle exceptions
                 # in_file_name_ is set before to a value
-                with open(in_file_name_, "w+") as file:  # types: arg-type
+                with open(joblog.in_file_name, "w+") as file:  # types: arg-type
                     data = arg_dict[job.input_content]
                     if isinstance(data, str):
                         file.write(data)
                     else:
                         for value in arg_dict[job.input_content]:
@@ -289,9 +319,9 @@
                     data = arg_dict[job.input_content]
                     if isinstance(data, str):
                         file.write(data)
                     else:
                         for value in arg_dict[job.input_content]:
-                            file.write("%s\n" % value)
+                            file.write(f"{value}\n")
 
             # Make sure a commit of our transaction before giving the newly
             # created id to our external process, or it won't be able to find it
@@ -306,7 +336,7 @@
                 run = sh.Command(command)
             except sh.CommandNotFound:
                 JobDefinition._handle_error(joblog, _("Command %s not found", command))
-                return job_logs
+                return
 
             if job.arguments_base:
                 out = run((job.arguments_base % arg_dict).split())
@@ -314,8 +344,8 @@
                 out = run()
 
             # cleanup temporary files
-            if in_file_name:
-                os.remove(in_file_name)
+            if joblog.in_file_name:
+                os.remove(joblog.in_file_name)
 
             joblog_dict = {
                 "end_date": fields.Datetime.now(),
@@ -323,7 +353,7 @@
                 "state": State.DONE.value,
             }
             if job.output_filename_base:
-                with open(out_file_name, "rb") as outfile:
+                with open(joblog.out_file_name, "rb") as outfile:
                     outfile_data = outfile.read()
 
                 if not outfile_data:
@@ -337,7 +367,7 @@
                     }
                 )
 
-                os.remove(out_file_name)
+                os.remove(joblog.out_file_name)
 
             joblog.write(joblog_dict)
 
@@ -341,7 +371,5 @@
 
             joblog.write(joblog_dict)
 
-        return job_logs
-
     @staticmethod
     def _return_joblog_act_window(
@@ -346,6 +374,6 @@
     @staticmethod
     def _return_joblog_act_window(
-        job_logs: models.Model, view_mode="form", default_view="tree"
+        job_logs: models.BaseModel, view_mode="form", default_view="tree"
     ):
         """return a dictionary that represent external job log action
         Note: default_view : if default view is form, specifies the
@@ -365,7 +393,6 @@
         act["domain"] = [("id", "in", job_logs.ids)]
         return act
 
-    def _call_extrunner(self) -> models.Model:
-        """Call extrunner to launch an external job."""
-        job_logs = self.env["external_job.job_log"]
+    def _vals_list_extrunner(self) -> list[dict[str, Any]]:
+        return [{"job_definition_id": rec.id} for rec in self]
 
@@ -371,8 +398,9 @@
 
-        for rec in self:
-            job_log_vals = {"job_definition_id": rec.id}
-            joblog = job_logs.create(job_log_vals)
-            job_logs += joblog
+    @api.model
+    def _call_extrunner(self, job_logs: models.BaseModel) -> None:
+        """Call extrunner to launch an external job."""
+        for joblog in job_logs:
+            rec = joblog.job_definition_id
 
             # Dynamic arguments: Similar to what we do in the "run_job" method;
             # not worth factoring out.
@@ -389,7 +417,7 @@
             headers = {"Content-Type": "application/json"}
             server = rec.extrunner_server_id
             post = requests.post(
-                "%s/auth/login" % server.base_url,
+                f"{server.base_url}/auth/login",
                 json={
                     "login": server.user_login,
                     "password": server.user_password,
@@ -407,5 +435,5 @@
                         text=post.text,
                     ),
                 )
-                return job_logs
+                return
 
@@ -411,7 +439,5 @@
 
-            headers["Authorization"] = "Bearer %s" % post.json().get(
-                "id_token", "failed"
-            )
+            headers["Authorization"] = f"Bearer {post.json().get('id_token', 'failed')}"
 
             # Make sure a commit of our transaction before giving the newly
             # created id to our external process, or it won't be able to find it
@@ -479,6 +505,4 @@
 
             joblog.write(values)
 
-        return job_logs
-
     @staticmethod
@@ -484,5 +508,5 @@
     @staticmethod
-    def _handle_error(joblog, msg: str):
+    def _handle_error(joblog: models.BaseModel, msg: str):
         joblog.write(
             {
                 "end_date": fields.Datetime.now(),
diff --git a/models/job_log.py b/models/job_log.py
index f323fa8e06696c8bbf554a8a09f517334b86059f_bW9kZWxzL2pvYl9sb2cucHk=..21c89ac3dc80de3443c256e85de54000d91edc89_bW9kZWxzL2pvYl9sb2cucHk= 100644
--- a/models/job_log.py
+++ b/models/job_log.py
@@ -21,7 +21,7 @@
 import logging
 from enum import Enum
 
-from odoo import _, api, fields, models
+from odoo import _, api, fields, models  # type: ignore[untyped-import]
 
 _logger = logging.getLogger(__name__)
 
@@ -56,7 +56,7 @@
     )
 
     start_date = fields.Datetime(
-        string="Start Date", readonly=True, default=fields.Datetime.now
+        string="Start Date", readonly=True, default=fields.Datetime.now, copy=False
     )
 
     # TODO rename to end_datetime?
@@ -60,5 +60,5 @@
     )
 
     # TODO rename to end_datetime?
-    end_date = fields.Datetime(string="End Date", readonly=True)
+    end_date = fields.Datetime(string="End Date", readonly=True, copy=False)
 
@@ -64,7 +64,5 @@
 
-    in_file_name = fields.Char(string="Temporary In File Name", size=512, readonly=True)
-
-    out_file_name = fields.Char(
-        string="Temporary Out File Name", size=512, readonly=True
+    in_file_name = fields.Char(
+        string="Temporary In File Name", size=512, readonly=True, copy=False
     )
 
@@ -69,4 +67,6 @@
     )
 
-    out_file = fields.Binary(string="Output File", readonly=True)
+    out_file_name = fields.Char(
+        string="Temporary Out File Name", size=512, readonly=True, copy=False
+    )
 
@@ -72,3 +72,3 @@
 
-    filename = fields.Char(string="Filename")
+    out_file = fields.Binary(string="Output File", readonly=True, copy=False)
 
@@ -74,3 +74,3 @@
 
-    stdout = fields.Text(string="StdOut", readonly=True)
+    filename = fields.Char(string="Filename", copy=False)
 
@@ -76,5 +76,7 @@
 
-    stderr = fields.Text(string="StdErr", readonly=True)
+    stdout = fields.Text(string="StdOut", readonly=True, copy=False)
+
+    stderr = fields.Text(string="StdErr", readonly=True, copy=False)
 
     state = fields.Selection(
         selection=[(state.value, state.translate()) for state in State],
@@ -105,3 +107,13 @@
             job_dtime = datetime.datetime.strftime(job_dtime, lang_format)
 
             jlog.display_name = _("%s’s Job", job_dtime)
+
+    @api.returns("self", lambda value: value.id)
+    def copy(self, default=None):
+        vals = {}
+        if default is not None:
+            vals.update(default)
+        vals.update(self.job_definition_id._vals_list()[0])
+        created = super().copy(vals)
+        created.job_definition_id._run(created)
+        return created
diff --git a/tests/test_job_definition.py b/tests/test_job_definition.py
index f323fa8e06696c8bbf554a8a09f517334b86059f_dGVzdHMvdGVzdF9qb2JfZGVmaW5pdGlvbi5weQ==..21c89ac3dc80de3443c256e85de54000d91edc89_dGVzdHMvdGVzdF9qb2JfZGVmaW5pdGlvbi5weQ== 100644
--- a/tests/test_job_definition.py
+++ b/tests/test_job_definition.py
@@ -1,7 +1,7 @@
 ###############################################################################
 #
 #    External Job, for Odoo
-#    Copyright © 2018, 2022, 2023 XCG Consulting (https://www.xcg-consulting.fr/)
+#    Copyright © 2018, 2022, 2023, 2024 XCG Consulting (https://www.xcg-consulting.fr/)
 #
 #    This program is free software: you can redistribute it and/or modify
 #    it under the terms of the GNU Affero General Public License as
@@ -23,8 +23,8 @@
 
 import requests  # Odoo req
 from freezegun import freeze_time
-from odoo.exceptions import AccessError
-from odoo.tests import TransactionCase
+from odoo.exceptions import AccessError  # type: ignore[untyped-import]
+from odoo.tests import TransactionCase  # type: ignore[untyped-import]
 
 
 class Test(TransactionCase):
@@ -63,6 +63,13 @@
 
         self.assertEqual(job_def.job_count, 1)
         self.assertEqual(job_log.state, "done")
+        # copy and do the same checks
+        with freeze_time("2023-02-01 10:31:00"):
+            copy = job_log.copy()
+        self.assertEqual("%d\n" % copy[0].id, copy.stdout)
+        self.assertEqual(job_def.job_count, 2)
+        self.assertEqual(copy.state, "done")
+        # gc tests
         job_def.unlink_delay = 1
         self.env["external_job.job_definition"]._gc_job_log()
         self.assertEqual(
@@ -174,6 +181,22 @@
         self.assertEqual(job.out_file, base64.b64encode(b"ATTACHMENT-CONTENTS"))
         self.assertTrue(job.filename)
 
+        # copy tests
+        copy = job.copy()
+        requests_post_mock.assert_any_call(
+            "http://BASE-URL-1/auth/login",
+            json=mock.ANY,
+            headers=mock.ANY,
+            timeout=mock.ANY,
+        )
+        requests_post_mock.assert_any_call(
+            "http://BASE-URL-1/run/0", json=mock.ANY, headers=mock.ANY, timeout=mock.ANY
+        )
+        self.assertTrue(copy.end_date)
+        self.assertEqual(copy.state, "done")
+        self.assertEqual(copy.out_file, base64.b64encode(b"ATTACHMENT-CONTENTS"))
+        self.assertTrue(copy.filename)
+
     def test_run_job_create_empty_file(self):
         """Test for the creation of an empty file"""
         job_def = self.browse_ref("external_job.demo_job_definition_touch")
diff --git a/tests/test_job_log.py b/tests/test_job_log.py
index f323fa8e06696c8bbf554a8a09f517334b86059f_dGVzdHMvdGVzdF9qb2JfbG9nLnB5..21c89ac3dc80de3443c256e85de54000d91edc89_dGVzdHMvdGVzdF9qb2JfbG9nLnB5 100644
--- a/tests/test_job_log.py
+++ b/tests/test_job_log.py
@@ -1,7 +1,7 @@
 ###############################################################################
 #
 #    External Jobs, for Odoo
-#    Copyright © 2022, 2023 XCG Consulting (https://www.xcg-consulting.fr/)
+#    Copyright © 2022, 2023, 2024 XCG Consulting (https://www.xcg-consulting.fr/)
 #
 #    This program is free software: you can redistribute it and/or modify
 #    it under the terms of the GNU Affero General Public License as
@@ -17,8 +17,8 @@
 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
 #
 ###############################################################################
-from odoo.exceptions import AccessError
-from odoo.tests import TransactionCase
+from odoo.exceptions import AccessError  # type: ignore[untyped-import]
+from odoo.tests import TransactionCase  # type: ignore[untyped-import]
 
 
 class Test(TransactionCase):
diff --git a/tests/test_jobrunner.py b/tests/test_jobrunner.py
index f323fa8e06696c8bbf554a8a09f517334b86059f_dGVzdHMvdGVzdF9qb2JydW5uZXIucHk=..21c89ac3dc80de3443c256e85de54000d91edc89_dGVzdHMvdGVzdF9qb2JydW5uZXIucHk= 100644
--- a/tests/test_jobrunner.py
+++ b/tests/test_jobrunner.py
@@ -1,7 +1,7 @@
 ###############################################################################
 #
 #    External Jobs, for Odoo
-#    Copyright © 2022 XCG Consulting (https://www.xcg-consulting.fr/)
+#    Copyright © 2022, 2024 XCG Consulting (https://www.xcg-consulting.fr/)
 #
 #    This program is free software: you can redistribute it and/or modify
 #    it under the terms of the GNU Affero General Public License as
@@ -17,7 +17,7 @@
 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
 #
 ###############################################################################
-from odoo.tests import TransactionCase
+from odoo.tests import TransactionCase  # type: ignore[untyped-import]
 
 
 class Test(TransactionCase):
diff --git a/views/job_definition.xml b/views/job_definition.xml
index f323fa8e06696c8bbf554a8a09f517334b86059f_dmlld3Mvam9iX2RlZmluaXRpb24ueG1s..21c89ac3dc80de3443c256e85de54000d91edc89_dmlld3Mvam9iX2RlZmluaXRpb24ueG1s 100644
--- a/views/job_definition.xml
+++ b/views/job_definition.xml
@@ -69,71 +69,63 @@
                         <field name="raise_error" />
                         <field name="output_filename_base" />
                     </group>
-                    <group
-                        col="4"
-                        colspan="4"
-                        name="help"
-                        string="Legend for arguments and environment variable’s value"
-                    >
-                        <dl>
-                            <dt>
-                                <code>%%(job_definition.&lt;field&gt;)s</code>
-                            </dt>
-                            <dd>job definition field.</dd>
-                            <dt>
-                                <code>%%(job_log.id)s</code>
-                            </dt>
-                            <dd>job ID.</dd>
-                            <dt invisible="job_type != 'cmd'">
-                                <code>%%(job_log.in_file_name)s</code>
-                            </dt>
-                            <dd invisible="job_type != 'cmd'">
-                                job in temporary file.
-                            </dd>
-                            <dt invisible="job_type != 'cmd'">
-                                <code>%%(job_log.out_file_name)s</code>
-                            </dt>
-                            <dd invisible="job_type != 'cmd'">
-                                job out temporary file name (where the program is
-                                supposed to write).
-                            </dd>
-                            <dt>
-                                <code>%%(config.options.???)s</code>
-                            </dt>
-                            <dd>
-                                Arguments to extract from the current configuration,
-                                placed after the static arguments.
-                            </dd>
-                            <dd>
-                                Please note that the db_name configuration key does NOT
-                                correspond to the active database, which is determined
-                                from the cursor and always passed to the job as first
-                                argument.
-                            </dd>
-                            <dt>
-                                <code>%%(cr.dbname)s</code>
-                            </dt>
-                            <dd>database in use.</dd>
-                            <dt>
-                                <code>%%(context.&lt;value&gt;)s</code>
-                            </dt>
-                            <dd>context values (from run)</dd>
-                            <dt invisible="job_type != 'cmd'">
-                                <code>%%(run.&lt;value&gt;)s</code>
-                            </dt>
-                            <dd invisible="job_type != 'cmd'">
-                                values from run args argument
-                            </dd>
-                            <dt>
-                                <code>%%(datetime.now)s</code>
-                            </dt>
-                            <dd>iso formated now date</dd>
-                            <dt>
-                                <code>%%(datetime.today)s</code>
-                            </dt>
-                            <dd>iso formated today date</dd>
-                        </dl>
-                    </group>
+                    <h3>Legend for arguments and environment variable’s value</h3>
+                    <dl>
+                        <dt>
+                            <code>%%(job_definition.&lt;field&gt;)s</code>
+                        </dt>
+                        <dd>job definition field.</dd>
+                        <dt>
+                            <code>%%(job_log.id)s</code>
+                        </dt>
+                        <dd>job ID.</dd>
+                        <dt invisible="job_type != 'cmd'">
+                            <code>%%(job_log.in_file_name)s</code>
+                        </dt>
+                        <dd invisible="job_type != 'cmd'">job in temporary file.</dd>
+                        <dt invisible="job_type != 'cmd'">
+                            <code>%%(job_log.out_file_name)s</code>
+                        </dt>
+                        <dd invisible="job_type != 'cmd'">
+                            job out temporary file name (where the program is
+                            supposed to write).
+                        </dd>
+                        <dt>
+                            <code>%%(config.options.???)s</code>
+                        </dt>
+                        <dd>
+                            Arguments to extract from the current configuration,
+                            placed after the static arguments.
+                        </dd>
+                        <dd>
+                            Please note that the db_name configuration key does NOT
+                            correspond to the active database, which is determined
+                            from the cursor and always passed to the job as first
+                            argument.
+                        </dd>
+                        <dt>
+                            <code>%%(cr.dbname)s</code>
+                        </dt>
+                        <dd>database in use.</dd>
+                        <dt>
+                            <code>%%(context.&lt;value&gt;)s</code>
+                        </dt>
+                        <dd>context values (from run)</dd>
+                        <dt invisible="job_type != 'cmd'">
+                            <code>%%(run.&lt;value&gt;)s</code>
+                        </dt>
+                        <dd invisible="job_type != 'cmd'">
+                            values from run args argument
+                        </dd>
+                        <dt>
+                            <code>%%(datetime.now)s</code>
+                        </dt>
+                        <dd>iso formated now date</dd>
+                        <dt>
+                            <code>%%(datetime.today)s</code>
+                        </dt>
+                        <dd>iso formated today date</dd>
+                    </dl>
                 </sheet>
             </form>
         </field>
diff --git a/views/job_log.xml b/views/job_log.xml
index f323fa8e06696c8bbf554a8a09f517334b86059f_dmlld3Mvam9iX2xvZy54bWw=..21c89ac3dc80de3443c256e85de54000d91edc89_dmlld3Mvam9iX2xvZy54bWw= 100644
--- a/views/job_log.xml
+++ b/views/job_log.xml
@@ -71,7 +71,14 @@
                     <group>
                         <field
                             name="job_definition_id"
-                            readonly="[('id', '!=', False)]"
+                            readonly="id"
+                            widget="selection"
+                            groups="!base.group_system"
+                        />
+                        <field
+                            name="job_definition_id"
+                            readonly="id"
+                            groups="base.group_system"
                         />
                     </group>