Repository navigation
[WIP] 18.0 support release - #84
sebastienbeau wants to merge 8 commits into
Conversation
Design of the release tracking tied to the tickets: two modules (one per side), the migration contract, the push queue, the stage rules and the verification results. Validated with the user before coding.
Track the releases of a customer project and link the tickets they ship, with one module per side. support_release (customer side) only records what the release script hands it and relays it: a single entry point _register_deployment(version, tickets) called from the migration the pipeline generates, a cron pushing the oldest unsent release, and a button to flush the queue. No git logic and no network call in the migration; the entry point never raises, because a failing migration breaks the module update and therefore the deployment. There is nothing to configure either: the environment comes from running_env, so only preprod and prod make the module speak. support_server_release (our side) adds a POST route on the existing cross_connect endpoint, creates one release per version, links the tickets it ships and moves them to the stage configured for the deployed environment. It deliberately depends on cross_connect_server and project only: ticket ids arrive in the payload and the partner is taken from the project of the tickets, so the module carries no GitLab logic and no gitlab_integration dependency. The key of a release is its version; before the first numbered version the key is 'next', which the first numbered version absorbs and removes. Both models stay minimal: version, tickets and is_sent on the customer side, version, client, partner, tasks and the two deployment dates on ours. Assisted-by: Hermes Agent (Nous Research)
There was a problem hiding this comment.
Résumé : 11 points, dont deux bugs de données sur l'absorption de la next (tickets non déplacés, horodatages perdus), un trou d'isolation des tickets par client, et huit points de convention, de robustesse et de test.
Deux points sont arbitrés par le plan et ne sont donc pas commentés : is_sent jamais remis à faux (contrainte 7 et §16), et le silence hors preprod/prod.
Détail
Vérifié en exécution sur un banc jetable (odoo 18.0 + postgres 16, le dépôt de la PR monté tel quel avec --test-enable) : les 28 tests des deux modules passent. Trois tests jetables ont servi à prouver les deux bugs de données (absorption après application des étapes, horodatages perdus à l'absorption) et à vérifier que le chemin « étape absente de project.type_ids » fonctionne (il est correct, il n'est juste pas testé).
Non vérifié, à savoir : le job CI test with OCB est rouge pour une raison préexistante (odoo-addon-gitlab_integration==18.0.* absent de PyPI, tiré par project_customer_access, fichier non touché par la PR). La CI ne prouve donc rien sur ces deux modules, seul le banc le fait.
Les commentaires suivent l'ordre des fichiers.
| ): | ||
| continue | ||
| with self.env.cr.savepoint(): | ||
| if stage not in task.project_id.type_ids: |
There was a problem hiding this comment.
Dette : le rattachement de l'étape au projet réimplémente un helper du core.
_set_stage_on_project_from_task() fait exactement ce bloc, et la copie figera une règle qui peut évoluer en amont.
Correctif : task.sudo()._set_stage_on_project_from_task() puis le write de stage_id.
Détail
addons/project/models/project_task.py:1074 : le helper écrit project.write({'type_ids': [Command.link(stage_id)]}) pour chaque étape courante absente du projet, soit exactement la ligne 105 de ce module.
Vérifié sur le banc : le code actuel fonctionne (étape rattachée, tâche déplacée), ce n'est pas un bug, seulement de la dette.
There was a problem hiding this comment.
Non appliqué, avec la raison : _set_stage_on_project_from_task() rattache au projet l'étape courante du ticket, pas l'étape cible. Appelé avant l'écriture, il rattache l'étape de départ, et le calcul de stage_id peut ensuite remplacer la cible. Le module rattache la cible avec Command.link, exactement le même geste pour la bonne étape ; test_stage_missing_from_project_is_attached couvre le cas.
| stage = self._get_stage(environment) | ||
| if stage: | ||
| release._apply_stages(stage) | ||
| if version != NEXT_VERSION: |
There was a problem hiding this comment.
Bug de données : l'absorption est faite après _apply_stages(), les tickets absorbés ne reçoivent jamais l'étape de l'environnement.
Un ticket arrivé par next reste à l'étape préprod dans une release déployée en prod.
Correctif : appeler _absorb_next_release() juste après release.write(vals).
Détail
Les tickets de next sont ajoutés à task_ids après la boucle d'étapes, donc hors de son périmètre. Reproduit sur le banc : next avec le ticket A reçu en préprod, puis 14.0.1.0 avec le ticket B reçu en prod ; après traitement, B est à l'étape prod et A reste à l'étape préprod, tous les deux dans la même release.
Le flux nominal le masque (le client renvoie l'union des tickets), mais pas une base restaurée, une next locale déjà absorbée, ou un payload écrit à la main.
|
|
||
| class ReleasePayload(BaseModel): | ||
| environment: Literal["preprod", "prod"] | ||
| version: str |
There was a problem hiding this comment.
Robustesse : version: str accepte la chaîne vide.
{"version": ""} crée une release sans nom et absorbe la next au passage.
Correctif : version: str = Field(min_length=1).
Détail
Le client ne peut pas l'envoyer (la garde de _register_deployment), mais la route répond à toute clé valide, et l'unicité est par client : un émetteur qui envoie une version vide verrouille la clé ("", client) et absorbe la next sans rien y mettre.
| @@ -0,0 +1,2 @@ | |||
| id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink | |||
| access_support_server_release_user,support.server.release user,model_support_server_release,base.group_user,1,1,1,1 | |||
There was a problem hiding this comment.
Sécurité : base.group_user a create et unlink sur la release.
N'importe quel utilisateur interne peut supprimer l'historique de déploiement et les liens aux tickets.
Correctif : 1,1,0,0.
Détail
Le plan §9 Sécurité prévoit lecture/écriture. Le create est de toute manière inutile (seul _process crée, en sudo()), et unlink ne devrait pas être ouvert : supprimer une release efface preprod_deployed_at et prod_deployed_at, qui sont les seules traces du déploiement côté central.
| ) | ||
| self.assertEqual(release.task_ids, task) | ||
| self.assertEqual(release.partner_id, self.partner) | ||
| self.assertEqual(foreign.stage_id, self.stage_preprod) |
There was a problem hiding this comment.
Test : cette assertion ne peut pas échouer.
foreign est créé sur stage_preprod, qui est l'étape cible du payload : elle n'a pas bougé parce qu'elle était déjà là, donc le test passe même si la tâche est traitée comme les autres.
Correctif : créer foreign sur une étape en amont et asserter qu'elle y est restée.
Détail
Seule la ligne 145 (release.task_ids == task) prouve réellement l'exclusion.
À noter aussi : le plan §9 Tests liste « étape absente de project.type_ids : rattachée puis appliquée », et ce test n'existe pas. Le chemin fonctionne (vérifié sur le banc : étape rattachée, tâche déplacée), il mérite son test.
| data-key="support_server_release" | ||
| groups="base.group_system" | ||
| > | ||
| <div |
There was a problem hiding this comment.
Convention : le bloc de réglages garde la structure 16.0.
En 17/18 c'est le template du noeud <app> qui rend le div.app_settings_block, le div ajouté ici le duplique.
Correctif : supprimer le div et écrire deux <setting> dans un <block title="Ticket stages">.
Détail
web/static/src/webclient/settings_form_view/settings/settings_app.xml:4 rend ce div.app_settings_block pour chaque app, donc le data-key se retrouve en double dans le formulaire, alors que le filtre de recherche des réglages travaille sur l'app. Les div.o_setting_box / div.o_setting_right_pane écrits à la main sont remplacés en 18 par des <setting id="..." help="...">, comme dans le core et account_usability.
When a payload says the version reached production, send the mail template 'Support Release: release in production' to the users of the client: the portal users the cross connection created, filtered on an existing email address. Nothing is configured and no field is added: an empty list simply sends nothing. The mail lists the title of every ticket of the release, each title linking to the ticket form through the action, and folds the description of the ticket under a details/summary element. Apple Mail and Thunderbird fold it, Gmail and Outlook drop the element and show the description unfolded, which is the only degradation available in an email. The mail is queued (force_send=False) so a broken mail server never fails the reception of the release, and no Odoo notification layout is applied: both provided layouts add an unfollow link without a token and the Odoo branding, which make no sense in a customer notification. The template is editable in the interface; the module only renders it. Assisted-by: Hermes Agent (Nous Research)
| def _normalize_version(self, version): | ||
| version = (version or "").strip() | ||
| if version.lower() == NEXT_VERSION: | ||
| return NEXT_VERSION | ||
| return version |
There was a problem hiding this comment.
Tu peux simplifier avec un simple version.lower().strip(), pas besoin d'une fonction pour ça
| def _normalize_tickets(self, tickets): | ||
| normalized = set() | ||
| for ticket in tickets or (): | ||
| try: | ||
| normalized.add(int(ticket)) | ||
| except (TypeError, ValueError): | ||
| _logger.warning("Ignoring non integer ticket reference %r", ticket) | ||
| return sorted(normalized) |
There was a problem hiding this comment.
pas besoin d'autant de complexité, tu peux juste raise une erreur si ce que l'on te passe n'est pas une liste d'ID
| if release: | ||
| merged = sorted(set(release._get_ticket_ids()) | set(ticket_ids)) | ||
| if merged != release._get_ticket_ids(): | ||
| release.tickets = json.dumps(merged) |
There was a problem hiding this comment.
simplify fait simplement un release.tickets = ticket_ids.
A chaque update on renvoie la totalité des tickets
| return { | ||
| "environment": self._get_environment(), | ||
| "version": self.version, | ||
| "tickets": self._get_ticket_ids(), |
There was a problem hiding this comment.
un simple self.tickets est suffisant
| def _get_partner(self, tasks): | ||
| """Partner of the projects of the tickets, when there is one.""" | ||
| partners = tasks.project_id.partner_id.filtered(lambda partner: partner) | ||
| if len(partners) > 1: | ||
| _logger.warning( | ||
| "Tickets belong to several partners %s, keeping %s", | ||
| partners.ids, | ||
| partners[0].id, | ||
| ) |
There was a problem hiding this comment.
jamais de choses silencieuse, le mieux c'est de mettre un message dans la release coté server
There was a problem hiding this comment.
Même traitement que pour les tickets inconnus : les tickets écartés sont listés dans le champ message de la release, plus aucun avertissement uniquement dans les logs.
| current_stage.id, | ||
| ): | ||
| continue | ||
| with self.env.cr.savepoint(): |
There was a problem hiding this comment.
Réfléchie avant de mettre un savepoint, c'est très rare d'en avoir besoin, ici, ça ne sert à rien
| current_stage = task.stage_id | ||
| if current_stage and (stage.sequence, stage.id) <= ( | ||
| current_stage.sequence, | ||
| current_stage.id, |
There was a problem hiding this comment.
pas besoin de se prendre la tete avec ce "never back" modifie le plan, on applique la release et on met a jours l'état sans condition
There was a problem hiding this comment.
Appliqué : la comparaison de séquence disparaît, l'étape de l'environnement s'écrit sur chaque ticket sans condition. Plan mis à jour (D9), et le test vérifie maintenant qu'un ticket déjà à l'étape prod revient à l'étape préprod.
| if not next_release: | ||
| return | ||
| if next_release.task_ids: | ||
| self.write( |
There was a problem hiding this comment.
trop de complexité pour rien ici, on s'en moque de la next, on la supprime direct pas besoin
self.search(
[
("cross_connect_client_id", "=", self.cross_connect_client_id.id),
("version", "=", NEXT_VERSION),
]
).unlink()
Apply the review of the pull request, the comments of sebastienbeau first when they contradict the others. Customer side: drop the two normalisation helpers, the version and the ticket list are checked inline and raise on a value that cannot be used; a deployment replaces the tickets of the release instead of merging them, since each payload carries the complete list; drop the savepoint and the try/except of the cron, a failing push must show up in the log and the release is retried; drop the manual push button and its view; the tickets field becomes a Json, so the payload reads it directly. Central side: order on id desc, a Char version compared as text puts 14.0.2610.9.0 above 14.0.2610.26.0; apply the stage of the environment without comparing it with the current one, the payload describes the state after the deployment; drop the savepoint and the per-ticket try/except; delete the pending next release instead of merging its tickets; split _process into small helpers, it had grown to 45 lines. Nothing is silent anymore: a new message field on the release carries the tickets that are unknown, of another partner (a release links no ticket when its project has no partner), the missing stage and the missing recipient, instead of a log line nobody reads. Also from the review: the customer menu was under the Technical menu, so only visible in debug mode; version is now min_length=1 in the schema; neither model is creatable nor deletable from the interface; the settings block uses the 18.0 setting tag; and two tests that could not fail are fixed, with the case of a stage missing from the project. Assisted-by: Hermes Agent (Nous Research)
|
Revue traitée en local (commit Côté client (
Côté central (
Deux points non appliqués
Vérifications : 32 tests verts sur le banc odoo 18 (14 client, 14 central, 4 router) et |
rob-beau
left a comment
There was a problem hiding this comment.
Résumé : 3 points sur les correctifs (les tickets ne sont jamais remplacés côté central, le mail de prod repart à chaque payload, et les destinataires de ce mail restent à trancher) ; les 11 points de la revue précédente sont traités, 8 corrigés et 3 arbitrés.
Vérifié en exécution sur le banc, pas seulement relu : 32 tests verts sur ce head, plus trois tests jetables pour les trois points ci-dessous.
Détail
Le banc rejoue la suite du dépôt de la PR (odoo 18.0 + postgres 16, --test-enable) : 32 tests, 0 échec, ce qui confirme le plan §18. Les trois tests jetables de cette passe prouvent les points 1 et 2 (la liste de tickets ne rétrécit pas, deux mail.mail pour deux payloads identiques) et mesurent l'étendue des destinataires du point 3.
Les 11 conversations de la revue précédente sont fermées : corrigées pour le menu, le tri, l'isolation sans partenaire, min_length, l'ACL, le test qui ne pouvait pas échouer et le bloc de réglages ; arbitrées pour le savepoint, le helper du core (à juste titre, il rattache l'étape courante et non la cible) et l'absorption de next, remplacée par une suppression avec un payload complet. Je ne rouvre pas les décisions documentées en D3, D5, D9 et D15.
Les commentaires suivent l'ordre des fichiers.
| "message": "\n".join(messages) or False, | ||
| } | ||
| if tasks: | ||
| vals["task_ids"] = [Command.link(task.id) for task in tasks] |
There was a problem hiding this comment.
Bug de données : le central ajoute les tickets, il ne les remplace pas.
Le client remplace sa liste à chaque payload (D3) et next est supprimée au motif que « le payload porte déjà ses tickets », mais Command.link ne fait qu'ajouter : un ticket retiré d'un payload rejoué reste lié, remonté à l'étape de l'environnement, et annoncé dans le mail de mise en prod.
Correctif : vals["task_ids"] = [Command.set(tasks.ids)], et vider le partenaire quand la liste est vide.
Détail
Prouvé sur le banc : payload preprod 14.0.1.0 avec [A, B], puis la même version avec [A] ; la release garde [A, B]. test_second_payload_is_idempotent ne le voit pas, il renvoie la même liste les deux fois.
L'impact est concret : mail_template_release_in_prod boucle sur object.task_ids, donc le client reçoit l'annonce d'un ticket qui n'est pas dans la version, et ce ticket a été déplacé vers l'étape de l'environnement au passage.
Si l'ajout est le comportement voulu côté central, c'est D3 et le commentaire de _remove_next_release() (« the payload already carries its tickets ») qu'il faut aligner : aujourd'hui les deux côtés ne racontent pas la même chose.
There was a problem hiding this comment.
effectivement il faut écraser les task mettre la commande qui fait un [(6, 0, [])]
There was a problem hiding this comment.
Corrigé : vals["task_ids"] = [Command.set(tasks.ids)]. Un ticket absent du payload est détaché de la release, donc il n'est plus déplacé ni annoncé dans le mail. test_payload_replaces_tickets couvre le cas (payload qui rétrécit de [A, B] à [A]).
There was a problem hiding this comment.
Appliqué, Command.set : le payload fait foi, la liste peut aussi se vider.
|
|
||
| def _get_notification_emails(self): | ||
| """Emails of the users of the client, the recipients of the release mail.""" | ||
| users = self.cross_connect_client_id.user_ids.filtered( |
There was a problem hiding this comment.
Question : le mail part à tous les utilisateurs du client, alors que la consigne était un opt-in.
_get_notification_emails() notifie chaque utilisateur actif du client porteur d'un email ; la remarque du 31/10 sur cette méthode demandait une option sur l'utilisateur, ou un groupe, pour ne prévenir que les volontaires.
Correctif : un booléen support_release_notify sur res.users (ou un groupe dédié) filtré ici, ou confirmer que « tous les utilisateurs du client » est la décision et retirer la consigne.
Détail
Prouvé sur le banc : deux utilisateurs sur le client, deux adresses dans email_to.
Le plan §17 documente aujourd'hui les user_ids du cross.connect.client, donc code et plan sont d'accord entre eux, mais pas avec le commentaire de la revue. À trancher : un projet avec dix utilisateurs envoie dix fois le mail à chaque mise en production, et rien ne permet à un utilisateur de s'y soustraire.
There was a problem hiding this comment.
Laissé en l'état pour le moment, l'arbitrage est en cours : les user_ids du client restent les destinataires, et c'est ce que documente le plan §17.
There was a problem hiding this comment.
alors ce que l'on va faire c'est que sur le partner qui est sur la release on va mettre un champs m2m, qui listera les personnes a notifier.
There was a problem hiding this comment.
Fait : support_release_notification_user_ids, Many2many vers res.users sur res.partner, éditable dans l'onglet Notes internes de la fiche client. _get_notification_emails() lit cette liste sur le partenaire de la release, filtre les comptes actifs avec un email et dédoublonne ; sans utilisateur, aucun mail et une ligne dans message.
| _name = "support.release" | ||
| _description = "Support Release" | ||
| _rec_name = "version" | ||
| _order = "create_date, id" |
There was a problem hiding this comment.
Trie sur l'id est suffisant
| _order = "create_date, id" | |
| _order = "id desc" |
There was a problem hiding this comment.
Appliqué : _order = "id desc". _next_release_to_send() garde un order="id" explicite, sinon la file enverrait la plus récente d'abord.
| "message": "\n".join(messages) or False, | ||
| } | ||
| if tasks: | ||
| vals["task_ids"] = [Command.link(task.id) for task in tasks] |
There was a problem hiding this comment.
effectivement il faut écraser les task mettre la commande qui fait un [(6, 0, [])]
| """Move the tickets of the release to the stage of the environment.""" | ||
| for task in self.task_ids: | ||
| if stage not in task.project_id.type_ids: | ||
| task.project_id.sudo().write({"type_ids": [Command.link(stage.id)]}) |
There was a problem hiding this comment.
pas besoin d'ajouter le stage au projet
There was a problem hiding this comment.
Appliqué : le bloc qui rattachait l'étape au projet est parti, _apply_stages devient self.task_ids.sudo().stage_id = stage. Le README précise que les deux étapes configurées doivent être rattachées aux projets du client pour apparaître dans leur kanban.
| if environment == "preprod" and release.prod_deployed_at: | ||
| return release | ||
| tasks, messages = self._filter_tickets(release, payload.get("tickets") or []) | ||
| release.write(self._release_vals(environment, tasks, messages)) |
There was a problem hiding this comment.
la convention dans odoo c'est de nommer ce genre de method "_prepare_release_vals" au lieu de _release_vals
Central side, the tickets of a payload now replace the previous ones: a ticket dropped from the release is unlinked, so the production mail never announces it. The partner of the release comes from the projects of the client (project.project.cross_connect_client_id) instead of the projects of the tickets, and several partners on those projects raise: the client is one customer, an inconsistent configuration has to be seen rather than arbitrated silently in a log. A ticket is linked only when its project belongs to the client sending the payload, which closes the hole left by the previous filter on the partner alone. A release already deployed in production ignores any further payload, whatever the environment: a payload replayed after a timeout cannot send the production mail twice. The stage is written on the tickets in a single assignment and is no longer added to the projects; the two configured stages have to be linked to the projects of the client, which is the normal setup. The method computing the values of the release is renamed _prepare_release_vals, following the Odoo convention. The manifest depends on project_customer_access, the owner of cross_connect_client_id on projects, so the client can be resolved to its projects. That reverses the earlier decision to keep the module free of that dependency, which is consistent: filtering by project of the client replaces filtering by partner. Customer side, the default order is id desc; the queue keeps an explicit order on id so the oldest pending release is still sent first. The icon of both modules becomes the Akretion logo, the repository being an Akretion one rather than an OCA one. Assisted-by: Hermes Agent (Nous Research)
Same icon as support_server_release: the repository is an Akretion one, the OCA icon was a mistake. Assisted-by: Hermes Agent (Nous Research)
|
Deuxième passe traitée en local (commit Les trois points de la revue précédente :
Deux changements de fond, documentés dans le plan (D7, D8 amendés) : la release prend son partenaire sur les projets du client et lève si ces projets portent plusieurs partenaires, et un ticket n'est lié que si son projet porte le |
| """Push the oldest pending release, one per cron run.""" | ||
| if not self._get_environment(): | ||
| return | ||
| release = self._next_release_to_send() |
There was a problem hiding this comment.
au final la fonction ne fait qu'un ligne donc autant faire l'appel direct ici
et pas besoin de mettre le order car c'est déjà celui par défaut
| release = self._next_release_to_send() | |
| release = self.search([("is_sent", "=", False)], limit=1) |
There was a problem hiding this comment.
Appliqué : la méthode disparaît, la cron fait self.search([("is_sent", "=", False)], limit=1). À noter, conséquence directe : l'ordre par défaut du modèle étant id desc, la file envoie désormais la release la plus récente d'abord et la plus ancienne en dernier. Si tu voulais garder la plus ancienne en premier, il suffit de remettre un order="id".
| if unknown: | ||
| messages.append(_("Unknown tickets: %s", unknown)) | ||
| kept = tasks.filtered( | ||
| lambda task: task.project_id.cross_connect_client_id == client |
There was a problem hiding this comment.
attention, corner case. On peut avoir un cas ou
- le projet n'a pas de cross_connect_client_id
- mais le projet a bien un partner_id de défini (qui est le meme que sur les autres projets)
Et dans ce cas la tache doit bien etre affecté a la release mais pas notifier par mail au client.
Ce sont des taches internes de maintenance non visible par le client (donc un projet sans cross_connect_id)
Donc l'idée c'est de trouver le partenaire a partir des projets ayant le cross connect et ensuite de filtrer les taches par rapport a ce partenaire qui doit se retrouver sur tout les projets lié au tache.
There was a problem hiding this comment.
Appliqué : le filtrage porte maintenant sur le partenaire, plus sur le projet du client. _filter_tickets(partner, ...) garde les tickets dont le projet porte ce partenaire, donc un projet interne de maintenance (sans cross_connect_client_id, même partenaire) voit bien ses tickets liés à la release et annoncés dans le mail, alors qu'ils restent invisibles côté portail. Un ticket d'un autre partenaire n'est pas lié et son id est écrit dans message. Cas couvert par test_ticket_of_internal_project_is_linked.
|
|
||
| def _get_notification_emails(self): | ||
| """Emails of the users of the client, the recipients of the release mail.""" | ||
| users = self.cross_connect_client_id.user_ids.filtered( |
There was a problem hiding this comment.
alors ce que l'on va faire c'est que sur le partner qui est sur la release on va mettre un champs m2m, qui listera les personnes a notifier.
Customer side, _next_release_to_send() only did a search, so the cron calls it directly and drops the explicit order: the queue follows the default order of the model, which means the most recently registered pending release goes first. Central side, the tickets are filtered on the partner of the client instead of the cross connect client of their project. An internal project has no cross connect client but carries the partner of the customer, and its tickets are part of the release, they simply are not visible in the portal. A ticket of another partner is left untouched and listed in the message field. The partner is resolved once in _process and passed down to the filter and to the values, instead of being computed twice. The recipients of the production mail move to the customer: a new many2many of users, support_release_notification_user_ids, on res.partner, editable in the Internal Notes tab of the partner form. The mail is addressed to the users flagged on the partner of the release, so the list is maintained once per customer instead of following the portal accounts created by the cross connection. Assisted-by: Hermes Agent (Nous Research)
|
Troisième passe traitée en local (commit
|
| <field name="model_id" ref="model_support_server_release" /> | ||
| <field | ||
| name="subject" | ||
| >{{ object.cross_connect_client_id.name }}: version {{ object.version }} in production</field> |
There was a problem hiding this comment.
tu peux rajouter la traduction du mail ?
There was a problem hiding this comment.
Fait : le template porte maintenant <field name="lang">{{ object.partner_id.lang }}</field>, le champ que Odoo utilise pour rendre un mail par langue. Sans lui le mail part toujours en anglais, même avec un contexte fr_FR, puisqu'il n'y a pas de langue à choisir.
i18n/fr.po contient le nom du template, le sujet et le corps en français (Bonjour, la version X de Y est désormais déployée en production, Cordialement).
Test ajouté : test_prod_mail_is_sent_in_the_language_of_the_customer, qui active le français, charge les termes du module et vérifie le sujet et le corps du mail généré. 35 tests verts sur le banc.
…r language The template now carries the lang placeholder of the partner of the release, which is the field Odoo uses to render a mail per language: without it the mail always goes out in English, whatever the language of the customer. The French translation of the name, the subject and the body is shipped in i18n/fr.po, and a test activates the language to check the subject and the body of the generated mail. Assisted-by: Hermes Agent (Nous Research)
No description provided.