From d01954092d8258a81dfe68c74ddc0f68a8a3ee29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20BEAU?= Date: Sat, 31 Oct 2026 10:35:30 +0000 Subject: [PATCH 01/23] [DOC] plan the ticket release feature 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. --- PLAN-support-release.md | 453 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 453 insertions(+) create mode 100644 PLAN-support-release.md diff --git a/PLAN-support-release.md b/PLAN-support-release.md new file mode 100644 index 0000000..858da39 --- /dev/null +++ b/PLAN-support-release.md @@ -0,0 +1,453 @@ +# Plan, système de release lié aux tickets + +Statut : plan validé, aucun code écrit, en attente du feu vert pour l'exécution. Version +8 du 2026-10-31. + +Cadrage : + +- la **version est la clé** de la release, `version="next"` quand aucun numéro n'est + défini ; +- l'appel depuis la migration ne transporte que `version` et `tickets` ; +- le client envoie **une release par tick**, la plus ancienne non envoyée, et ne garde + qu'un booléen `is_sent` ; +- l'environnement se lit dans **`running_env`** (`server_environment`), sans paramètre + dédié ; +- côté serveur, **aucune logique GitLab**, une release **sans projet** (client + + partenaire, tickets multi-projets), et deux **étapes paramétrables** appliquées à la + réception ; +- modèles réduits au strict nécessaire, noms différents de chaque côté : + `support.release` (client) et `support.server.release` (serveur) ; +- pas de backport, pilote sur un projet 18.0. + +Le script qui produit l'information de release (détection du bump, génération à la volée +d'une migration dans `custom_all`, choix de `next` ou de la version numérotée) est +**hors périmètre** : ce plan ne définit que l'interface par laquelle il alimente les +modules, et la logique des modules. + +--- + +## 1. Objectif + +Savoir, dans notre ERP central (erp-fr), quelle version de code d'un projet client est +déployée où, et quels tickets cette version embarque, et faire avancer ces tickets dans +le workflow au fur et à mesure des déploiements. + +Deux modules : + +- `support_release` (base du client) : enregistre les releases déposées par une + migration générée dans `custom_all` et les pousse une par une vers le central ; +- `support_server_release` (erp-fr) : reçoit ce push, crée la release, lie les + `project.task` et applique l'étape correspondant à l'environnement déployé. + +--- + +## 2. Faits vérifiés + +| Fait | Preuve | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Les projets migrent par version de module, convention `@openupgrade.migrate(use_env=True)` et `def migrate(env, version)`. `bin/bump` déplace `migrations/0.0.0/` vers la version et bumpe le manifeste. | `abilis/odoo/local-src/custom_all/migrations/14.0.2304.18.0/post-configure_product.py` ; `abilis/bump:26-46` | +| `"model.name" in env` teste la présence d'un modèle : une migration peut ne rien faire sur une base où le module n'est pas installé. | `odoo/src/odoo/api.py:608-610` | +| `running_env` vient de `server_environment` (valeur `RUNNING_ENV`, repli `ODOO_STAGE`, sinon `test`) et est lisible par `tools.config.get("running_env")`, comme le font déjà les modules `l10n_fr_*` du dépôt. | `server-env/server_environment/server_env.py:47-65` ; `server-env/server_environment/tests/test_environment_variable.py:14-22` ; `l10n-france/l10n_fr_siret/models/res_partner.py:216` | +| En CI, tous les conteneurs tournent avec `RUNNING_ENV=preprod` (dev `dev`, prod `prod`). | `abilis/ci.docker-compose.yml:18` ; `abilis/prod.docker-compose.yml:20` ; `abilis/docker-compose.yml:31` | +| Les versions en usage ont cinq segments (`14.0.2610.26.0`) : aucune validation `x.y.z`. | `abilis/odoo/local-src/custom_all/__manifest__.py:9` | +| L'endpoint `https://erp-fr.akretion.com/api-support` est un `fastapi.endpoint` `app="cross_connect"` ; l'auth par header `api-key` est résolue en `cross.connect.client`. | `server-auth/cross_connect_server/models/fastapi_endpoint.py:24-105` ; `support/support_akretion/data/cross_connect_server.xml` | +| Côté client, `cross.connect.server` expose `_request(method, path, data)` : préfixe `/cross_connect/`, header `api-key`, lève sur non-2xx. Le record support est `support.support_server`. | `raw.githubusercontent.com/OCA/server-auth/18.0/cross_connect_client/models/cross_connect_server.py` ; `support/support/models/cross_connect_server.py:19` | +| `project.project` porte `partner_id` (champ « Customer » du core) et `project.task.project_id` le relie : le partenaire d'une release se déduit des tickets, sans configuration. | `project/models/project_project.py:89` | +| Les étapes sont partageables entre projets (`project.task.type.project_ids`), mais `project.task.stage_id` est calculé stocké et son calcul remplace l'étape si elle n'est pas rattachée au projet ; `_set_stage_on_project_from_task()` sert à la rattacher. | `project/models/project_task_type.py:29-33` ; `project/models/project_task.py:154-157,667-675,1074-1081` | +| akretion/support a des branches 16.0, 18.0, 19.0 ; le clone est sur la branche agrégée `merged` avec une modification locale dans `support_server/models/project_task.py`. | `git ls-remote --heads origin` ; `git status -sb` | +| Aucun modèle ni champ "release" n'existe aujourd'hui. | `grep -ri release` sur `odoo/local-src` et `odoo/external-src/support` | + +--- + +## 3. Contraintes + +1. **La version est la clé.** Rejouer un appel avec la même version complète la release + (union des tickets) sans la dupliquer : une migration rejouée ne crée rien en double. +2. **`next`** est la valeur de version quand aucun numéro n'existe : une seule par + client, absorbée par la première version numérotée. +3. **Aucune logique GitLab côté serveur** : les ids de tickets arrivent dans le payload. +4. **Une release n'appartient pas à un projet** : elle appartient au client + (`cross.connect.client`) et peut contenir des tickets de plusieurs projets ; + `partner_id` est celui du projet des tickets liés. +5. **Une migration ne doit jamais casser un déploiement** : elle tourne aussi sur les + bases de test, et aucun appel réseau ne s'y fait. +6. **Le déplacement d'étape ne régresse jamais un ticket** (comparaison de séquence) et + ne casse jamais la réception (try/except par ticket). +7. **Une seule registration par version, avant le déploiement.** `is_sent` n'est jamais + invalidé : une version déjà envoyée n'est pas renvoyée, même si une migration + ultérieure y ajoutait des tickets. +8. **Pas de backport** : cible 18.0, pilote sur un projet 18.0. + +--- + +## 4. Architecture + +``` + pipeline du projet client (script de l'utilisateur, hors périmètre) + merge ou bump -> génère custom_all/migrations//post-release.py + | + | _register_deployment(version="next" | "", tickets=[...]) + v + Odoo client (support_release) Odoo central (support_server_release) + +-------------------------------------+ +---------------------------------------+ + | support.release | cron 10' | POST /cross_connect/release | + | version (clé, dont "next") |--- api-key ->| support.server.release | + | tickets, is_sent | 1/tick | client (clé) + partner (projet) | + | push la plus ancienne non envoyée | | -> project.task liés (multi-projets) | + +-------------------------------------+ | -> étape préprod OU étape prod | + | (paramétrées, jamais en arrière) | + +---------------------------------------+ +``` + +| Moment | Appel de la migration | Côté client | Côté central | +| -------------------------- | ------------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------ | +| merge, déploiement préprod | `version="next"`, `tickets=[...]` | release `next` créée ou complétée | `next` reçue, tickets liés, étape préprod | +| bump, déploiement préprod | `version="14.0.2610.26.0"`, `tickets=[...]` | release `` créée, tickets de `next` absorbés, `next` supprimée | `` reçue, tickets liés, étape préprod, `next` absorbée puis supprimée | +| déploiement prod | même appel que la préprod | même release, renvoyée | `` reçue, tickets à l'étape prod, `prod_deployed_at` posé | + +Trois règles : + +- une release qui a déjà un `prod_deployed_at` ignore tout payload venant de la + **préprod** ; +- la cron n'envoie **qu'une release par tick** (`is_sent = False`, la plus ancienne par + `create_date`) ; +- un envoi en échec laisse `is_sent` à faux : la release reste en tête de file et sera + réessayée. + +--- + +## 5. Décisions + +| # | Décision | Raison | +| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| D1 | Deux modules dans akretion/support : `support_release` (client) et `support_server_release` (serveur), avec des modèles aux noms distincts (`support.release` / `support.server.release`). | Réponse utilisateur ; aucun risque de fusion de modèle. | +| D2 | L'information entre par une migration générée qui appelle `env["support.release"]._register_deployment(version=..., tickets=[...])`, sous `if "support.release" not in env`. | Seul mécanisme disponible au moment de la mise à jour, sans git ni réseau ; la garde évite de casser une base où le module manque. | +| D3 | La version est la clé d'idempotence, `next` quand il n'y a pas de numéro ; la première version numérotée absorbe `next` puis la supprime (le serveur fait de même). | Réponse utilisateur ; rejouable, et aucun ticket perdu ou compté deux fois. | +| D4 | Environnement = `tools.config.get("running_env")`, sans paramètre de module ; seuls `preprod` et `prod` déclenchent un envoi. | Réponse utilisateur ; `server_environment` fournit déjà la valeur. | +| D5 | Envoi par cron (10 min) et bouton, une release par tick, la plus ancienne non envoyée ; `is_sent` posé seulement en cas de succès, jamais invalidé. | Réponse utilisateur ; aucun état d'erreur à maintenir. | +| D6 | Transport par `cross.connect.server._request()` sur `/cross_connect/release`, route ajoutée à l'endpoint `api-support` existant. | Infrastructure en place : ni authentification ni endpoint nouveau. | +| D7 | Le serveur ne dépend ni de GitLab ni de `project_customer_access` : manifeste `cross_connect_server` + `project`. | Réponse utilisateur ; le partenaire venant des projets des tickets, plus rien n'a besoin du lien client → projets, et `gitlab_integration` sort de la chaîne de dépendances. | +| D8 | La release n'a pas de `project_id` ; elle porte le `cross_connect_client_id` (issu de la clé d'API) et le `partner_id` **déduit du projet des tickets liés** ; les tickets dont le projet pointe un autre partenaire ne sont pas liés. | Réponse utilisateur : le partenaire vient du projet de la task, pas de la configuration du client. | +| D9 | Deux étapes globales paramétrables (préprod, prod) dans les Réglages, appliquées à chaque réception, jamais en arrière, avec rattachement de l'étape au projet si besoin. | Réponse utilisateur, plus le piège du calcul de `stage_id`. | +| D10 | Préprod ignorée pour une version déjà en prod. | Réponse utilisateur. | +| D11 | Les horodatages de déploiement sont posés par le central à la réception et écrasés à chaque réception. | Le client n'envoie pas de dates, l'heure du central est la seule fiable. | +| D12 | Versions non validées en format ; `next` normalisé en minuscules. | Les versions en usage ont cinq segments. | +| D13 | Pas de backport. | Réponse utilisateur. | + +--- + +## 6. Interface d'appel depuis la migration + +```python +# custom_all/migrations//post-release.py (généré, exemple sans bump) +from openupgradelib import openupgrade + + +@openupgrade.migrate(use_env=True) +def migrate(env, version): + if "support.release" not in env: + return + env["support.release"]._register_deployment(version="next", tickets=[25882, 25890]) +``` + +Avec un bump : `version="14.0.2610.26.0"` et les tickets de la version. + +- `version` vide → avertissement dans les logs, rien n'est créé, aucune exception ; +- `tickets` absents ou vides → liste vide acceptée ; +- si la release existe déjà, ses tickets sont complétés (union dédoublonnée) ; +- si `version` est numérotée et qu'une `next` existe, ses tickets sont absorbés puis la + `next` est supprimée, dans la même transaction. + +--- + +## 7. Contrat HTTP + +`POST https:///api-support/cross_connect/release`, header +`api-key: `. + +```json +{ + "environment": "preprod", + "version": "next", + "tickets": [25901, 25912] +} +``` + +`environment` est `running_env` tel quel ; `preprod` et `prod` sont acceptés, le reste +est refusé (422). Le client vient de la clé d'API ; le partenaire, lui, n'est pas dans +le payload : il se déduit du projet des tickets liés. + +Réponse : `{"ok": true}` (200). 401 si la clé est inconnue, 422 si le payload est +invalide. Aucune autre information n'est retournée : les diagnostics (tickets inconnus, +tickets d'un autre partenaire, étapes déplacées ou ignorées) vont dans les logs du +central. + +--- + +## 8. Module `support_release` (client) + +Dépend de `support`. Un modèle, une cron, une vue liste, une vue formulaire, un bouton. + +### `support.release` (`_rec_name = "version"`) + +| Champ | Type | Rôle | +| --------- | ---------------------------- | --------------------------------------------------------------- | +| `version` | Char, requis, unique, indexé | clé ; `next` pour la future version | +| `tickets` | Char | JSON de la liste d'ids, dédoublonnée et triée | +| `is_sent` | Booléen | vrai après un envoi accepté par le central, jamais remis à faux | + +`create_date`, posé par Odoo, donne l'ordre d'envoi. + +### Méthodes + +- `_register_deployment(version, tickets)` : normalise la version, normalise et + dédoublonne les tickets, crée ou complète la release, absorbe `next` si la version est + numérotée (§6). +- `_get_environment()` : `tools.config.get("running_env")` si la valeur est `preprod` ou + `prod`, sinon `False`. +- `_next_release_to_send()` : + `search([("is_sent", "=", False)], order="create_date, id", limit=1)`. +- `_push_release(release)` : + `ref("support.support_server")._request("POST", "/release", data=payload)` en + `sudo()`, puis `is_sent = True`. Toute exception est journalisée et laisse `is_sent` à + faux. +- `_cron_push_releases()` : sort si `_get_environment()` est faux ; sinon pousse + `_next_release_to_send()`, dans un try/except (la cron ne lève jamais). +- `_push_pending()` : bouton, boucle sur `_next_release_to_send()` jusqu'à épuisement + (garde-fou à 100). + +### Vues + +Réglages > Technique > Releases (`base.group_system`) : liste (version, envoyée) avec +filtre "À envoyer" et bouton "Pousser maintenant", formulaire (tickets en lecture, +bouton d'envoi). + +### Tests + +- première registration : release créée, `is_sent` faux, tickets corrects ; +- seconde registration de la même version : union, une seule release, `is_sent` inchangé + ; +- version numérotée avec une `next` existante : absorption puis suppression de `next` ; +- nouveau merge après absorption : une nouvelle `next` est créée ; +- `version="Next"` : normalisé, même release ; +- version vide : avertissement, rien de créé ; +- `running_env` à `dev` : aucun appel ; à `preprod` : un seul appel, sur la plus + ancienne non envoyée ; +- envoi en échec : `is_sent` reste faux et la release est retentée ; +- clic sur le bouton : toute la file part, une requête par release. + +--- + +## 9. Module `support_server_release` (central) + +Dépend de `cross_connect_server` et `project`. Aucune référence GitLab, et aucune +dépendance à `project_customer_access` : le partenaire se déduit des projets des +tickets. + +### `support.server.release` (`_rec_name = "version"`) + +| Champ | Type | Rôle | +| ------------------------- | ------------------------------ | --------------------------------------- | +| `version` | Char, requis, indexé | unique par client | +| `cross_connect_client_id` | Many2one, requis, indexé | client propriétaire | +| `partner_id` | Many2one `res.partner`, indexé | `partner_id` du projet des tickets liés | +| `task_ids` | Many2many `project.task` | tickets liés, tous projets confondus | +| `preprod_deployed_at` | Datetime | dernière réception préprod | +| `prod_deployed_at` | Datetime | dernière réception prod | + +Contrainte SQL : unicité (`cross_connect_client_id`, `version`). `project.task` : +`release_ids` Many2many `support.server.release` (relation +`support_server_release_project_task_rel`) et bouton intelligent. + +### Router + +`routers/release.py` : `POST /cross_connect/release`, dépendances +`authenticated_cross_connect_client` et `odoo_env`, schéma pydantic à trois champs. +`models/fastapi_endpoint.py` ajoute le router via `_get_fastapi_routers()` quand +`self.app == "cross_connect"`. Le router appelle +`env["support.server.release"].sudo()._process(client, payload)`. + +### Traitement + +`_process(client, payload)` : + +1. release existante = `search` sur (`cross_connect_client_id`, `version`) ; si + `environment == "preprod"` et qu'elle a déjà un `prod_deployed_at`, on s'arrête là + (log) ; +2. `env["project.task"].browse(ticket_ids).exists()` ; les ids inexistants sont + journalisés et ignorés ; +3. `partner_id` = premier `partner_id` non vide des projets de ces tickets ; si un + partenaire est trouvé, les tickets dont le projet pointe un autre partenaire sont + journalisés et ne sont pas liés ; si aucun projet n'a de partenaire, tous les tickets + sont liés et la release reste sans partenaire ; +4. upsert : `task_ids` complétés, horodatage de l'environnement posé à `now()` (écrasé) + ; +5. étapes, voir ci-dessous ; +6. si la version n'est pas `next` et qu'une `next` existe pour ce client : ses tickets + sont ajoutés à la version, puis la `next` est supprimée. + +### Étapes + +`_apply_stages(release, environment)` : + +- cible = `support_server_release.stage_preprod_id` ou `.stage_prod_id` ; +- pour chaque ticket, on n'écrit que si `(sequence, id)` de la cible est strictement + au-delà de l'étape courante ; +- si la cible n'est pas dans `task.project_id.type_ids`, elle y est rattachée + (`_set_stage_on_project_from_task`) pour que la colonne existe dans le kanban ; +- écriture en `sudo()`, une entrée de chatter par ticket (`stage_id` est + `tracking=True`) ; +- chaque ticket dans son propre try/except, les erreurs sont journalisées. + +### Paramétrage + +Réglages > Support, bloc "Releases", deux `Many2one('project.task.type')` persistés en +`ir.config_parameter` (`support_server_release.stage_preprod_id`, `.stage_prod_id`). + +### Vues + +Support > Releases : liste (version, client, partenaire, préprod, prod) avec filtre +"Next", formulaire (tickets en tags). Recherche par client, partenaire, version. + +### Sécurité + +`ir.model.access.csv` : lecture/écriture pour `base.group_user`. Rien pour les +utilisateurs clients du support : `cross_connect_client_id` et `partner_id` sont là pour +poser plus tard une règle d'accès. + +### Tests + +- payload `next` en préprod : release créée, tickets liés, étape préprod appliquée, + `preprod_deployed_at` posé ; +- payload d'une version numérotée alors qu'une `next` existe : tickets absorbés, `next` + supprimée ; +- payload `prod` : tickets à l'étape prod, `prod_deployed_at` posé ; +- payload préprod sur une version déjà en prod : rien de modifié ; +- cible en amont de l'étape courante : ticket non déplacé ; +- étape non configurée : aucun déplacement, aucune erreur ; +- étape absente de `project.type_ids` : rattachée puis appliquée ; +- `partner_id` de la release = celui du projet du ticket ; un ticket d'un projet d'un + autre partenaire n'est pas lié ; +- ticket inexistant : ignoré, la release est créée quand même ; +- second envoi identique : rien ne change ; +- `api-key` inconnue → 401 ; `environment` inconnu → 422. + +--- + +## 10. Par projet client + +1. installer `support_release` dans le `spec.yaml` (série 18.0) ; +2. vérifier que `RUNNING_ENV` vaut `preprod` ou `prod` sur les environnements qui + doivent remonter ; +3. le `cross.connect.server` de support est déjà configuré par `support_akretion`. + +Côté erp-fr : installer `support_server_release`, créer le `cross.connect.client` du +client s'il n'existe pas, configurer les deux étapes. Rien à configurer sur les projets +: le partenaire vient du projet du ticket. + +--- + +## 11. Étapes + +| # | Étape | C'est fait quand | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| 0 | Hygiène du clone : commiter ou mettre de côté la modification locale de `support_server/models/project_task.py`, créer `18.0-support-release` depuis `origin/18.0`. | `git status` propre, `git log` part de `e0dca6d`. | +| 1 | Squelettes des deux modules (manifestes, `readme/`, `pyproject.toml`, description), sans logique. | `pre-commit run -a` vert, installation sans erreur sur une base de test. | +| 2 | `support_release` : modèle, `_register_deployment`, absorption de `next`, sécurité. | Tests de registration verts. | +| 3 | `support_release` : `running_env`, payload, file d'envoi, push, cron, bouton. | Tests d'envoi verts (mock), silence en `dev`. | +| 4 | `support_server_release` : modèle, partenaire, tickets, absorption de `next`, horodatages, garde préprod. | Tests de traitement verts. | +| 5 | `support_server_release` : réglages des étapes et application (séquence, rattachement, try/except). | Tests d'étapes verts. | +| 6 | `support_server_release` : router fastapi, schéma, `_get_fastapi_routers`. | `POST /cross_connect/release` répond 200 en base de test avec une clé valide, 401 sans, 422 avec un environnement inconnu. | +| 7 | Vues, menus, bouton intelligent, filtres. | Un `base.group_user` voit la liste et le formulaire côté central ; un utilisateur client ne les voit pas. | +| 8 | Migration de référence jouée dans une vraie migration locale. | La migration crée `next`, une seconde avec une version numérotée l'absorbe. | +| 9 | Bout en bout local (base erp-fr de test + base projet de test). | Un tick crée la release, lie les tickets, applique l'étape préprod ; le tick suivant est muet. | +| 10 | README des deux modules et note d'exploitation. | `pre-commit run -a` vert, README corrects. | +| 11 | Pilote réel sur un projet 18.0, avec le script. | Un merge apparaît en `next` avec ses tickets à l'étape préprod, le bump absorbe `next`, la prod déplace les tickets à l'étape prod. | + +--- + +## 12. Risques + +| Risque | Impact | Traitement | +| -------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| Une release en échec reste en tête de file et bloque les suivantes. | Retard d'envoi. | Assumé : `is_sent` non posé, erreur journalisée à chaque tick ; on corrige la cause, ou on marque la release envoyée à la main. | +| Environnements de review en `RUNNING_ENV=preprod`. | Aucune pollution : la migration n'existe que sur la branche principale, donc ils n'ont aucune release à envoyer. | Rien à faire. | +| Tickets de projets rattachés à des partenaires différents dans une même release. | Les minoritaires ne sont pas liés. | Partenaire = celui du premier projet rencontré, les autres tickets sont journalisés ; cas anormal par construction. | +| Migration générée sur une base où le module client n'est pas installé. | Échec de mise à jour. | Garde `if "support.release" not in env`. | +| Versions en `next` pendant longtemps. | La release `next` grossit. | Comportement voulu. | +| Étape configurée absente de `project.type_ids`, ou séquences mal ordonnées. | Ticket non déplacé ou invisible. | Rattachement automatique, logs par ticket ; à vérifier une fois en pilote. | +| Tickets non résolus non conservés. | Pas de post-mortem en base. | Logs du central uniquement, assumé. | + +--- + +## 13. Plan B + +- `_register_deployment` peut être appelée hors migration (bouton, `odoo shell`) : rien + n'en dépend. +- `_push_release()` peut être appelée depuis un `odoo shell` si la cron ne suffit pas. +- Les deux étapes peuvent être vidées : le module continue de recevoir releases et + tickets sans y toucher. + +--- + +## 14. Hors périmètre + +Script de génération de release ; toute logique GitLab ; règles d'accès par client ; +multi-dépôts par base ; notes de release publiées ; facturation et temps ; +synchronisation inverse ; backport 16.0. + +--- + +## 15. Ce qui reste à faire + +Aucune question de conception ouverte. Deux points opérationnels : nommer le projet +pilote 18.0 (étape 11) et vérifier sur sa base que les deux étapes sont rattachées aux +projets et correctement séquencées. + +--- + +## 16. Vérifications exécutées + +Tout ce qui suit a été exécuté sur un banc jetable (`odoo:18.0` + postgres 16, +dépendances Python minimales), pas seulement relu. + +**Tests unitaires** : 28 tests, 0 échec (`support_release` 16, `support_server_release` +12), plus `pre-commit run` vert sur les deux modules. + +**Étape 8, migration réelle** : un module `bench_release_migration` dépendant de +`support_release` est installé en 18.0.1.0.0, puis mis à jour avec une migration générée +dans `migrations/18.0.2.0.0/post-release.py` : + +``` +next|[11, 12]|f +``` + +puis en 18.0.3.0.0 avec `version="18.0.2620.1.0"` : + +``` +18.0.2620.1.0|[11, 12, 13]|f +``` + +La ligne `next` a disparu, les tickets des deux migrations sont réunis. Le snippet du +README est donc jouable tel quel, `openupgradelib` compris. + +**Étape 9, bout en bout** : deux bases Odoo distinctes (`bench_central`, `bench_client`) +plus une troisième pour la prod (`bench_prod`), le central servant réellement +`/api-support` sur HTTP. + +| Envoi | Effet constaté sur le central | +| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| préprod depuis `bench_client` (`next`, tickets 1 et 2) | release `next` créée, `partner_id` = client du projet, `preprod_deployed_at` posé, les 2 tickets déplacés de `New` à `To release`, 2 tickets liés, `is_sent` à vrai côté client | +| prod depuis `bench_prod` (même version, `RUNNING_ENV=prod`) | `prod_deployed_at` posé, les 2 tickets déplacés à `Closed` | +| préprod tardif renvoyé après la prod | ignoré : ni horodatage préprod modifié, ni ticket déplacé | + +Deux points d'exploitation découverts à cette occasion, à retenir pour le pilote : + +- un `fastapi.endpoint` créé hors interface (`odoo shell`, données de démo) n'est pas + routé tant que `_handle_registry_sync()` n'a pas été appelé : les routes vivent dans + la table `endpoint_route`, pas dans le code. Créer l'endpoint par l'interface (ou + lancer la synchro) avant le premier envoi. +- la base de prod doit être sa propre base : si elle est restaurée depuis un dump de + préprod, `is_sent` arrive à vrai et la prod ne pousse rien. C'est le comportement + voulu (`is_sent` n'est jamais remis à faux), mais il faut le savoir avant un + rafraîchissement de base. From ac3f1bee0fc862a4f414d951f538589bb37ce70a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20BEAU?= Date: Sat, 31 Oct 2026 10:35:30 +0000 Subject: [PATCH 02/23] [ADD] support_release, support_server_release 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) --- setup/_metapackage/pyproject.toml | 2 + support_release/README.rst | 105 ++++ support_release/__init__.py | 4 + support_release/__manifest__.py | 20 + support_release/data/ir_cron.xml | 14 + support_release/models/__init__.py | 4 + support_release/models/support_release.py | 161 +++++++ support_release/pyproject.toml | 3 + support_release/readme/CONTRIBUTORS.md | 1 + support_release/readme/DESCRIPTION.md | 10 + support_release/readme/USAGE.md | 31 ++ support_release/security/ir.model.access.csv | 2 + support_release/static/description/icon.png | Bin 0 -> 10254 bytes support_release/static/description/index.html | 454 ++++++++++++++++++ support_release/tests/__init__.py | 4 + support_release/tests/test_support_release.py | 143 ++++++ .../views/support_release_views.xml | 67 +++ support_server_release/README.rst | 87 ++++ support_server_release/__init__.py | 5 + support_server_release/__manifest__.py | 23 + support_server_release/models/__init__.py | 7 + .../models/fastapi_endpoint.py | 16 + support_server_release/models/project_task.py | 26 + .../models/res_config_settings.py | 21 + .../models/support_server_release.py | 176 +++++++ support_server_release/pyproject.toml | 3 + support_server_release/readme/CONTRIBUTORS.md | 1 + support_server_release/readme/DESCRIPTION.md | 8 + support_server_release/readme/USAGE.md | 15 + support_server_release/routers/__init__.py | 6 + support_server_release/routers/release.py | 31 ++ support_server_release/schemas.py | 12 + .../security/ir.model.access.csv | 2 + .../static/description/icon.png | Bin 0 -> 10254 bytes .../static/description/index.html | 440 +++++++++++++++++ support_server_release/tests/__init__.py | 4 + .../tests/test_support_server_release.py | 232 +++++++++ .../views/project_task_views.xml | 24 + .../views/res_config_settings_views.xml | 77 +++ .../views/support_server_release_views.xml | 86 ++++ 40 files changed, 2327 insertions(+) create mode 100644 support_release/README.rst create mode 100644 support_release/__init__.py create mode 100644 support_release/__manifest__.py create mode 100644 support_release/data/ir_cron.xml create mode 100644 support_release/models/__init__.py create mode 100644 support_release/models/support_release.py create mode 100644 support_release/pyproject.toml create mode 100644 support_release/readme/CONTRIBUTORS.md create mode 100644 support_release/readme/DESCRIPTION.md create mode 100644 support_release/readme/USAGE.md create mode 100644 support_release/security/ir.model.access.csv create mode 100644 support_release/static/description/icon.png create mode 100644 support_release/static/description/index.html create mode 100644 support_release/tests/__init__.py create mode 100644 support_release/tests/test_support_release.py create mode 100644 support_release/views/support_release_views.xml create mode 100644 support_server_release/README.rst create mode 100644 support_server_release/__init__.py create mode 100644 support_server_release/__manifest__.py create mode 100644 support_server_release/models/__init__.py create mode 100644 support_server_release/models/fastapi_endpoint.py create mode 100644 support_server_release/models/project_task.py create mode 100644 support_server_release/models/res_config_settings.py create mode 100644 support_server_release/models/support_server_release.py create mode 100644 support_server_release/pyproject.toml create mode 100644 support_server_release/readme/CONTRIBUTORS.md create mode 100644 support_server_release/readme/DESCRIPTION.md create mode 100644 support_server_release/readme/USAGE.md create mode 100644 support_server_release/routers/__init__.py create mode 100644 support_server_release/routers/release.py create mode 100644 support_server_release/schemas.py create mode 100644 support_server_release/security/ir.model.access.csv create mode 100644 support_server_release/static/description/icon.png create mode 100644 support_server_release/static/description/index.html create mode 100644 support_server_release/tests/__init__.py create mode 100644 support_server_release/tests/test_support_server_release.py create mode 100644 support_server_release/views/project_task_views.xml create mode 100644 support_server_release/views/res_config_settings_views.xml create mode 100644 support_server_release/views/support_server_release_views.xml diff --git a/setup/_metapackage/pyproject.toml b/setup/_metapackage/pyproject.toml index a53618f..5a02b5a 100644 --- a/setup/_metapackage/pyproject.toml +++ b/setup/_metapackage/pyproject.toml @@ -5,7 +5,9 @@ dependencies = [ "odoo-addon-project_customer_access==18.0.*", "odoo-addon-support==18.0.*", "odoo-addon-support_akretion==18.0.*", + "odoo-addon-support_release==18.0.*", "odoo-addon-support_server==18.0.*", + "odoo-addon-support_server_release==18.0.*", ] classifiers=[ "Programming Language :: Python", diff --git a/support_release/README.rst b/support_release/README.rst new file mode 100644 index 0000000..691d168 --- /dev/null +++ b/support_release/README.rst @@ -0,0 +1,105 @@ +=============== +Support Release +=============== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:0c56dd86e3e17aa7d60d7bcaab1a85dddfe02ff793f56e69da581d851840fa2a + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-Akretion%2Fsupport-lightgray.png?logo=github + :target: https://github.com/Akretion/support/tree/18.0/support_release + :alt: Akretion/support + +|badge1| |badge2| |badge3| + +This module records the releases deployed on a customer project and +pushes them to the Akretion support server, where the tickets shipped by +each release are linked and moved to the stage matching the deployment +environment. + +The information comes from a migration generated in the project by the +release script, so the module never reads git: it only stores what the +migration hands it and relays it. + +A release is identified by its version. When no version is defined yet, +the version is ``next``, and the first numbered version absorbs it. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +The release information is registered by a migration generated in the +project (usually ``custom_all``) at each deployment: + +.. code:: python + + from openupgradelib import openupgrade + + + @openupgrade.migrate(use_env=True) + def migrate(env, version): + if "support.release" not in env: + return + env["support.release"]._register_deployment( + version="next", # or the numbered version, e.g. "14.0.2610.26.0" + tickets=[25882, 25890], + ) + +The guard on the model presence keeps a database where this module is +not installed from failing its module update. + +Releases are then pushed by the cron *Support Release: push pending +releases* (every 10 minutes, one release per run, oldest first), or +immediately with the *Push pending releases* button. + +The push only happens when ``running_env`` is ``preprod`` or ``prod``: +it is set by ``server_environment`` from the ``RUNNING_ENV`` environment +variable, so there is nothing else to configure in the module. + +The generated migration must exist on the **main branch only**: the CI +sets ``RUNNING_ENV=preprod`` on every container, so a review environment +carrying a migration would announce a version that was never deployed. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Akretion + +Contributors +------------ + +- Sébastien Beau + +Maintainers +----------- + +This module is part of the `Akretion/support `_ project on GitHub. + +You are welcome to contribute. diff --git a/support_release/__init__.py b/support_release/__init__.py new file mode 100644 index 0000000..a3b5e91 --- /dev/null +++ b/support_release/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 Akretion (http://www.akretion.com). +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import models diff --git a/support_release/__manifest__.py b/support_release/__manifest__.py new file mode 100644 index 0000000..0234483 --- /dev/null +++ b/support_release/__manifest__.py @@ -0,0 +1,20 @@ +# Copyright 2026 Akretion (http://www.akretion.com). +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Support Release", + "summary": "Record the releases of a project and push them to the support server", + "version": "18.0.1.0.0", + "license": "AGPL-3", + "author": "Akretion", + "website": "https://github.com/akretion/support", + "depends": [ + "support", + ], + "data": [ + "security/ir.model.access.csv", + "data/ir_cron.xml", + "views/support_release_views.xml", + ], + "installable": True, +} diff --git a/support_release/data/ir_cron.xml b/support_release/data/ir_cron.xml new file mode 100644 index 0000000..2bbe2b0 --- /dev/null +++ b/support_release/data/ir_cron.xml @@ -0,0 +1,14 @@ + + + + + Support Release: push pending releases + + code + model._cron_push_releases() + 10 + minutes + + + diff --git a/support_release/models/__init__.py b/support_release/models/__init__.py new file mode 100644 index 0000000..f8d07db --- /dev/null +++ b/support_release/models/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 Akretion (http://www.akretion.com). +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import support_release diff --git a/support_release/models/support_release.py b/support_release/models/support_release.py new file mode 100644 index 0000000..61f96ae --- /dev/null +++ b/support_release/models/support_release.py @@ -0,0 +1,161 @@ +# Copyright 2026 Akretion (http://www.akretion.com). +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import json +import logging + +from odoo import _, api, fields, models, tools +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + +NEXT_VERSION = "next" +SUPPORTED_ENVIRONMENTS = ("preprod", "prod") + + +class SupportRelease(models.Model): + _name = "support.release" + _description = "Support Release" + _rec_name = "version" + _order = "create_date, id" + + version = fields.Char( + required=True, + index=True, + help="Version of the deployment, or 'next' while no version is defined.", + ) + tickets = fields.Char( + help="JSON list of the ticket ids shipped by this release.", + ) + is_sent = fields.Boolean( + default=False, + copy=False, + help="Set once the support server has accepted the release.", + ) + + _sql_constraints = [ + ( + "version_uniq", + "unique(version)", + "A release already exists for this version.", + ), + ] + + @api.model + def _get_environment(self): + """Return the deployment environment, or False when releases stay silent.""" + running_env = tools.config.get("running_env") + return running_env if running_env in SUPPORTED_ENVIRONMENTS else False + + @api.model + def _normalize_version(self, version): + version = (version or "").strip() + if version.lower() == NEXT_VERSION: + return NEXT_VERSION + return version + + @api.model + 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) + + def _get_ticket_ids(self): + self.ensure_one() + return json.loads(self.tickets or "[]") + + @api.model + def _register_deployment(self, version, tickets=None): + """Record a deployment of a release. + + Called from the migration generated by the project pipeline. The version + is the key of the release: registering the same version twice merges the + tickets instead of creating a second release. + """ + version = self._normalize_version(version) + if not version: + _logger.warning("Release registration without version, ignored") + return self.browse() + ticket_ids = self._normalize_tickets(tickets) + release = self.search([("version", "=", version)]) + if release: + merged = sorted(set(release._get_ticket_ids()) | set(ticket_ids)) + if merged != release._get_ticket_ids(): + release.tickets = json.dumps(merged) + else: + release = self.create( + {"version": version, "tickets": json.dumps(ticket_ids)} + ) + if version != NEXT_VERSION: + release._absorb_next_release() + return release + + def _absorb_next_release(self): + """Merge the pending 'next' release into this one, then drop it.""" + self.ensure_one() + if self.version == NEXT_VERSION: + return + next_release = self.search([("version", "=", NEXT_VERSION)]) + if not next_release: + return + merged = sorted( + set(next_release._get_ticket_ids()) | set(self._get_ticket_ids()) + ) + self.tickets = json.dumps(merged) + next_release.unlink() + + @api.model + def _next_release_to_send(self): + return self.search([("is_sent", "=", False)], limit=1) + + def _build_payload(self): + self.ensure_one() + return { + "environment": self._get_environment(), + "version": self.version, + "tickets": self._get_ticket_ids(), + } + + def _push(self): + self.ensure_one() + server = self.env.ref("support.support_server", raise_if_not_found=False) + if not server: + raise UserError(_("The support server is not configured.")) + server.sudo()._request("POST", "/release", data=self._build_payload()) + self.is_sent = True + + @api.model + def _cron_push_releases(self): + """Push the oldest pending release, one per cron run.""" + if not self._get_environment(): + return + release = self._next_release_to_send() + if not release: + return + try: + with self.env.cr.savepoint(): + release._push() + except Exception: + _logger.exception("Unable to push release %s", release.version) + + def action_push_pending(self): + """Push every pending release, one request per release.""" + if not self._get_environment(): + raise UserError( + _("Releases are only pushed from a preprod or prod environment.") + ) + for _counter in range(100): + release = self._next_release_to_send() + if not release: + break + try: + with self.env.cr.savepoint(): + release._push() + except Exception: + _logger.exception("Unable to push release %s", release.version) + break + return True diff --git a/support_release/pyproject.toml b/support_release/pyproject.toml new file mode 100644 index 0000000..4231d0c --- /dev/null +++ b/support_release/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/support_release/readme/CONTRIBUTORS.md b/support_release/readme/CONTRIBUTORS.md new file mode 100644 index 0000000..7bc1803 --- /dev/null +++ b/support_release/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Sébastien Beau \<\> diff --git a/support_release/readme/DESCRIPTION.md b/support_release/readme/DESCRIPTION.md new file mode 100644 index 0000000..0cc8a82 --- /dev/null +++ b/support_release/readme/DESCRIPTION.md @@ -0,0 +1,10 @@ +This module records the releases deployed on a customer project and pushes them +to the Akretion support server, where the tickets shipped by each release are +linked and moved to the stage matching the deployment environment. + +The information comes from a migration generated in the project by the release +script, so the module never reads git: it only stores what the migration hands +it and relays it. + +A release is identified by its version. When no version is defined yet, the +version is `next`, and the first numbered version absorbs it. diff --git a/support_release/readme/USAGE.md b/support_release/readme/USAGE.md new file mode 100644 index 0000000..38b4e53 --- /dev/null +++ b/support_release/readme/USAGE.md @@ -0,0 +1,31 @@ +The release information is registered by a migration generated in the project +(usually `custom_all`) at each deployment: + +```python +from openupgradelib import openupgrade + + +@openupgrade.migrate(use_env=True) +def migrate(env, version): + if "support.release" not in env: + return + env["support.release"]._register_deployment( + version="next", # or the numbered version, e.g. "14.0.2610.26.0" + tickets=[25882, 25890], + ) +``` + +The guard on the model presence keeps a database where this module is not +installed from failing its module update. + +Releases are then pushed by the cron *Support Release: push pending releases* +(every 10 minutes, one release per run, oldest first), or immediately with the +*Push pending releases* button. + +The push only happens when `running_env` is `preprod` or `prod`: it is set by +`server_environment` from the `RUNNING_ENV` environment variable, so there is +nothing else to configure in the module. + +The generated migration must exist on the **main branch only**: the CI sets +`RUNNING_ENV=preprod` on every container, so a review environment carrying a +migration would announce a version that was never deployed. diff --git a/support_release/security/ir.model.access.csv b/support_release/security/ir.model.access.csv new file mode 100644 index 0000000..f3ba00d --- /dev/null +++ b/support_release/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_support_release_system,support.release system,model_support_release,base.group_system,1,1,1,1 diff --git a/support_release/static/description/icon.png b/support_release/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..1dcc49c24f364e9adf0afbc6fc0bac6dbecdeb11 GIT binary patch literal 10254 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 + + + + +Support Release + + + +
+

Support Release

+ + +

Beta License: AGPL-3 Akretion/support

+

This module records the releases deployed on a customer project and +pushes them to the Akretion support server, where the tickets shipped by +each release are linked and moved to the stage matching the deployment +environment.

+

The information comes from a migration generated in the project by the +release script, so the module never reads git: it only stores what the +migration hands it and relays it.

+

A release is identified by its version. When no version is defined yet, +the version is next, and the first numbered version absorbs it.

+

Table of contents

+ +
+

Usage

+

The release information is registered by a migration generated in the +project (usually custom_all) at each deployment:

+
+from openupgradelib import openupgrade
+
+
+@openupgrade.migrate(use_env=True)
+def migrate(env, version):
+    if "support.release" not in env:
+        return
+    env["support.release"]._register_deployment(
+        version="next",  # or the numbered version, e.g. "14.0.2610.26.0"
+        tickets=[25882, 25890],
+    )
+
+

The guard on the model presence keeps a database where this module is +not installed from failing its module update.

+

Releases are then pushed by the cron Support Release: push pending +releases (every 10 minutes, one release per run, oldest first), or +immediately with the Push pending releases button.

+

The push only happens when running_env is preprod or prod: +it is set by server_environment from the RUNNING_ENV environment +variable, so there is nothing else to configure in the module.

+

The generated migration must exist on the main branch only: the CI +sets RUNNING_ENV=preprod on every container, so a review environment +carrying a migration would announce a version that was never deployed.

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Akretion
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is part of the Akretion/support project on GitHub.

+

You are welcome to contribute.

+
+
+
+ + diff --git a/support_release/tests/__init__.py b/support_release/tests/__init__.py new file mode 100644 index 0000000..a7498de --- /dev/null +++ b/support_release/tests/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 Akretion (http://www.akretion.com). +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import test_support_release diff --git a/support_release/tests/test_support_release.py b/support_release/tests/test_support_release.py new file mode 100644 index 0000000..1c8f6ba --- /dev/null +++ b/support_release/tests/test_support_release.py @@ -0,0 +1,143 @@ +# Copyright 2026 Akretion (http://www.akretion.com). +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from unittest.mock import patch + +from odoo import tools +from odoo.exceptions import UserError +from odoo.tests.common import TransactionCase + + +def _set_running_env(environment): + return patch.dict(tools.config.options, {"running_env": environment}) + + +class TestSupportRelease(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.Release = cls.env["support.release"] + cls.Server = cls.env["cross.connect.server"] + + def _patch_request(self): + """Mock the transport, keeping the rest of the push logic under test.""" + return patch.object(type(self.Server), "_request") + + def test_register_next(self): + release = self.Release._register_deployment("next", [42, 12, 42]) + self.assertEqual(release.version, "next") + self.assertFalse(release.is_sent) + self.assertEqual(release._get_ticket_ids(), [12, 42]) + + def test_register_same_version_merges_tickets(self): + self.Release._register_deployment("next", [1]) + release = self.Release._register_deployment("next", [2]) + self.assertEqual(self.Release.search_count([("version", "=", "next")]), 1) + self.assertEqual(release._get_ticket_ids(), [1, 2]) + + def test_register_same_version_keeps_is_sent(self): + release = self.Release._register_deployment("next", [1]) + release.is_sent = True + release = self.Release._register_deployment("next", [2]) + self.assertTrue(release.is_sent) + self.assertEqual(release._get_ticket_ids(), [1, 2]) + + def test_version_absorbs_next(self): + self.Release._register_deployment("next", [1, 2]) + release = self.Release._register_deployment("14.0.1.0", [2, 3]) + self.assertFalse(self.Release.search([("version", "=", "next")])) + self.assertEqual(release.version, "14.0.1.0") + self.assertEqual(release._get_ticket_ids(), [1, 2, 3]) + + def test_next_recreated_after_absorption(self): + self.Release._register_deployment("next", [1]) + self.Release._register_deployment("14.0.1.0", [1]) + release = self.Release._register_deployment("next", [2]) + self.assertEqual(release.version, "next") + self.assertEqual(release._get_ticket_ids(), [2]) + + def test_next_is_case_insensitive(self): + self.Release._register_deployment("Next", [1]) + self.assertEqual(self.Release.search_count([("version", "=", "next")]), 1) + + def test_empty_version_is_ignored(self): + self.assertFalse(self.Release._register_deployment("", [1])) + self.assertEqual(self.Release.search_count([]), 0) + + def test_non_integer_tickets_are_ignored(self): + release = self.Release._register_deployment("next", [1, "abc", None]) + self.assertEqual(release._get_ticket_ids(), [1]) + + def test_environment(self): + for environment in ("dev", "ci", "test"): + with _set_running_env(environment): + self.assertFalse(self.Release._get_environment()) + for environment in ("preprod", "prod"): + with _set_running_env(environment): + self.assertEqual(self.Release._get_environment(), environment) + + def test_payload(self): + release = self.Release._register_deployment("14.0.1.0", [3, 1]) + with _set_running_env("preprod"): + self.assertEqual( + release._build_payload(), + { + "environment": "preprod", + "version": "14.0.1.0", + "tickets": [1, 3], + }, + ) + + def test_next_release_to_send_is_oldest(self): + first = self.Release._register_deployment("14.0.1.0", [1]) + second = self.Release._register_deployment("14.0.2.0", [2]) + self.assertEqual(self.Release._next_release_to_send(), first) + first.is_sent = True + self.assertEqual(self.Release._next_release_to_send(), second) + + def test_cron_pushes_one_release(self): + first = self.Release._register_deployment("14.0.1.0", [1]) + second = self.Release._register_deployment("14.0.2.0", [2]) + with _set_running_env("preprod"), self._patch_request() as request: + self.Release._cron_push_releases() + self.assertEqual(request.call_count, 1) + args, kwargs = request.call_args + self.assertEqual(args, ("POST", "/release")) + self.assertEqual( + kwargs["data"], + { + "environment": "preprod", + "version": "14.0.1.0", + "tickets": [1], + }, + ) + self.assertTrue(first.is_sent) + self.assertFalse(second.is_sent) + + def test_cron_is_silent_without_environment(self): + self.Release._register_deployment("14.0.1.0", [1]) + with _set_running_env("dev"), self._patch_request() as request: + self.Release._cron_push_releases() + request.assert_not_called() + + def test_failed_push_keeps_release_pending(self): + release = self.Release._register_deployment("14.0.1.0", [1]) + with _set_running_env("preprod"), self._patch_request() as request: + request.side_effect = Exception("boom") + self.Release._cron_push_releases() + self.assertFalse(release.is_sent) + self.assertEqual(self.Release._next_release_to_send(), release) + + def test_button_pushes_all_pending(self): + first = self.Release._register_deployment("14.0.1.0", [1]) + second = self.Release._register_deployment("14.0.2.0", [2]) + with _set_running_env("prod"), self._patch_request() as request: + self.Release.action_push_pending() + self.assertEqual(request.call_count, 2) + self.assertTrue(first.is_sent) + self.assertTrue(second.is_sent) + + def test_button_requires_an_environment(self): + with _set_running_env("dev"): + with self.assertRaises(UserError): + self.Release.action_push_pending() diff --git a/support_release/views/support_release_views.xml b/support_release/views/support_release_views.xml new file mode 100644 index 0000000..3c42b90 --- /dev/null +++ b/support_release/views/support_release_views.xml @@ -0,0 +1,67 @@ + + + + + support.release + + + + + + + + + + + + support.release + +
+
+
+ + + + + + + +
+
+
+ + + support.release + + + + + + + + + + Releases + support.release + list,form + {'search_default_to_send': 1} + + + +
diff --git a/support_server_release/README.rst b/support_server_release/README.rst new file mode 100644 index 0000000..b2f4439 --- /dev/null +++ b/support_server_release/README.rst @@ -0,0 +1,87 @@ +====================== +Support Server Release +====================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:a9b5fed9a22af0316cb7b4dd2becfbabca81369f70837d514b05a543b3e9d28e + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-Akretion%2Fsupport-lightgray.png?logo=github + :target: https://github.com/Akretion/support/tree/18.0/support_server_release + :alt: Akretion/support + +|badge1| |badge2| |badge3| + +This module receives the releases pushed by a customer project (see +``support_release``), creates a release per version, links the tickets +it ships and moves them to the stage matching the deployment +environment. + +The core is a single POST route added to the existing ``cross_connect`` +endpoint, authenticated by the same ``api-key`` as the support feature. +There is no git logic here: the ticket ids are given by the payload, and +the partner of the release is the one of the projects of the tickets. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Two stages are configured once for all the projects, in *Settings > +Support Releases*: + +- the preprod stage, applied when a release is received from preprod; +- the prod stage, applied when a release is received from prod. + +A ticket is never moved backwards: the stage is only written when the +target stage is further than the current one, comparing their sequences. + +Once a release has a production deployment date, a payload coming from +preprod for the same version is ignored, so a preprod redeployment +cannot reopen tickets that are already closed. + +The ``next`` release of a client is absorbed by the first numbered +version received for that client, then removed. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Akretion + +Contributors +------------ + +- Sébastien Beau + +Maintainers +----------- + +This module is part of the `Akretion/support `_ project on GitHub. + +You are welcome to contribute. diff --git a/support_server_release/__init__.py b/support_server_release/__init__.py new file mode 100644 index 0000000..22f0739 --- /dev/null +++ b/support_server_release/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2026 Akretion (http://www.akretion.com). +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import models +from . import routers diff --git a/support_server_release/__manifest__.py b/support_server_release/__manifest__.py new file mode 100644 index 0000000..a96a0f4 --- /dev/null +++ b/support_server_release/__manifest__.py @@ -0,0 +1,23 @@ +# Copyright 2026 Akretion (http://www.akretion.com). +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Support Server Release", + "summary": "Receive the releases of a customer project, link the tickets and " + "move them to the stage of the deployed environment", + "version": "18.0.1.0.0", + "license": "AGPL-3", + "author": "Akretion", + "website": "https://github.com/akretion/support", + "depends": [ + "cross_connect_server", + "project", + ], + "data": [ + "security/ir.model.access.csv", + "views/support_server_release_views.xml", + "views/project_task_views.xml", + "views/res_config_settings_views.xml", + ], + "installable": True, +} diff --git a/support_server_release/models/__init__.py b/support_server_release/models/__init__.py new file mode 100644 index 0000000..6200257 --- /dev/null +++ b/support_server_release/models/__init__.py @@ -0,0 +1,7 @@ +# Copyright 2026 Akretion (http://www.akretion.com). +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import fastapi_endpoint +from . import project_task +from . import res_config_settings +from . import support_server_release diff --git a/support_server_release/models/fastapi_endpoint.py b/support_server_release/models/fastapi_endpoint.py new file mode 100644 index 0000000..cc1a1d3 --- /dev/null +++ b/support_server_release/models/fastapi_endpoint.py @@ -0,0 +1,16 @@ +# Copyright 2026 Akretion (http://www.akretion.com). +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import models + +from ..routers import release_router + + +class FastapiEndpoint(models.Model): + _inherit = "fastapi.endpoint" + + def _get_fastapi_routers(self): + routers = super()._get_fastapi_routers() + if self.app == "cross_connect": + routers += [release_router] + return routers diff --git a/support_server_release/models/project_task.py b/support_server_release/models/project_task.py new file mode 100644 index 0000000..f29c825 --- /dev/null +++ b/support_server_release/models/project_task.py @@ -0,0 +1,26 @@ +# Copyright 2026 Akretion (http://www.akretion.com). +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, fields, models + + +class ProjectTask(models.Model): + _inherit = "project.task" + + release_ids = fields.Many2many( + "support.server.release", + relation="support_server_release_project_task_rel", + column1="task_id", + column2="release_id", + string="Releases", + ) + + def action_open_releases(self): + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": _("Releases"), + "res_model": "support.server.release", + "view_mode": "list,form", + "domain": [("id", "in", self.release_ids.ids)], + } diff --git a/support_server_release/models/res_config_settings.py b/support_server_release/models/res_config_settings.py new file mode 100644 index 0000000..61b2488 --- /dev/null +++ b/support_server_release/models/res_config_settings.py @@ -0,0 +1,21 @@ +# Copyright 2026 Akretion (http://www.akretion.com). +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + support_server_release_stage_preprod_id = fields.Many2one( + "project.task.type", + string="Preprod stage", + config_parameter="support_server_release.stage_preprod_id", + help="Stage applied to the tickets when a release is received from preprod.", + ) + support_server_release_stage_prod_id = fields.Many2one( + "project.task.type", + string="Prod stage", + config_parameter="support_server_release.stage_prod_id", + help="Stage applied to the tickets when a release is received from prod.", + ) diff --git a/support_server_release/models/support_server_release.py b/support_server_release/models/support_server_release.py new file mode 100644 index 0000000..0966188 --- /dev/null +++ b/support_server_release/models/support_server_release.py @@ -0,0 +1,176 @@ +# Copyright 2026 Akretion (http://www.akretion.com). +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + +NEXT_VERSION = "next" +STAGE_PARAMETER = "support_server_release.stage_%s_id" +DEPLOYED_FIELDS = { + "preprod": "preprod_deployed_at", + "prod": "prod_deployed_at", +} + + +class SupportServerRelease(models.Model): + _name = "support.server.release" + _description = "Support Release" + _rec_name = "version" + _order = "version desc, id desc" + + version = fields.Char( + required=True, + index=True, + help="Version of the deployment, or 'next' while no version is defined.", + ) + cross_connect_client_id = fields.Many2one( + "cross.connect.client", + required=True, + index=True, + ondelete="cascade", + string="Client", + ) + partner_id = fields.Many2one( + "res.partner", + index=True, + help="Partner of the projects of the tickets of this release.", + ) + task_ids = fields.Many2many( + "project.task", + relation="support_server_release_project_task_rel", + column1="release_id", + column2="task_id", + string="Tickets", + ) + preprod_deployed_at = fields.Datetime() + prod_deployed_at = fields.Datetime() + + _sql_constraints = [ + ( + "client_version_uniq", + "unique(cross_connect_client_id, version)", + "This version is already registered for this client.", + ), + ] + + @api.model + def _get_tickets(self, ticket_ids): + """Resolve the ticket ids of a payload, ignoring the unknown ones.""" + tasks = self.env["project.task"].browse(ticket_ids).exists() + unknown = sorted(set(ticket_ids) - set(tasks.ids)) + if unknown: + _logger.warning("Ignoring unknown ticket ids %s", unknown) + return tasks + + @api.model + 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, + ) + return partners[:1] + + @api.model + def _get_stage(self, environment): + stage_id = ( + self.env["ir.config_parameter"] + .sudo() + .get_param(STAGE_PARAMETER % environment) + ) + if not stage_id: + return self.env["project.task.type"] + return self.env["project.task.type"].browse(int(stage_id)).exists() + + def _apply_stages(self, stage): + """Move the tickets forward to the stage of the environment, never back.""" + self.ensure_one() + moved = 0 + for task in self.task_ids: + try: + current_stage = task.stage_id + if current_stage and (stage.sequence, stage.id) <= ( + current_stage.sequence, + current_stage.id, + ): + continue + with self.env.cr.savepoint(): + if stage not in task.project_id.type_ids: + task.project_id.sudo().write({"type_ids": [(4, stage.id)]}) + task.sudo().write({"stage_id": stage.id}) + moved += 1 + except Exception: + _logger.exception( + "Unable to move ticket %s to stage %s", task.id, stage.name + ) + return moved + + def _absorb_next_release(self): + """Merge the pending 'next' release of the client, then drop it.""" + self.ensure_one() + next_release = self.search( + [ + ("cross_connect_client_id", "=", self.cross_connect_client_id.id), + ("version", "=", NEXT_VERSION), + ] + ) + if not next_release: + return + if next_release.task_ids: + self.write( + {"task_ids": [(4, task_id) for task_id in next_release.task_ids.ids]} + ) + next_release.unlink() + + @api.model + def _process(self, client, payload): + """Handle a release payload received from a client.""" + environment = payload["environment"] + version = payload["version"] + release = self.search( + [ + ("cross_connect_client_id", "=", client.id), + ("version", "=", version), + ] + ) + if environment == "preprod" and release.prod_deployed_at: + _logger.info( + "Release %s of %s is already in prod, preprod payload ignored", + version, + client.name, + ) + return release + tasks = self._get_tickets(payload.get("tickets") or []) + partner = release.partner_id or self._get_partner(tasks) + if partner: + foreign = tasks.filtered(lambda task: task.project_id.partner_id != partner) + if foreign: + _logger.warning( + "Release %s: tickets %s belong to another partner, ignored", + version, + foreign.ids, + ) + tasks -= foreign + if not release: + release = self.create( + {"version": version, "cross_connect_client_id": client.id} + ) + field_name = DEPLOYED_FIELDS[environment] + vals = {field_name: fields.Datetime.now()} + if partner: + vals["partner_id"] = partner.id + if tasks: + vals["task_ids"] = [(4, task_id) for task_id in tasks.ids] + release.write(vals) + stage = self._get_stage(environment) + if stage: + release._apply_stages(stage) + if version != NEXT_VERSION: + release._absorb_next_release() + return release diff --git a/support_server_release/pyproject.toml b/support_server_release/pyproject.toml new file mode 100644 index 0000000..4231d0c --- /dev/null +++ b/support_server_release/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/support_server_release/readme/CONTRIBUTORS.md b/support_server_release/readme/CONTRIBUTORS.md new file mode 100644 index 0000000..7bc1803 --- /dev/null +++ b/support_server_release/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Sébastien Beau \<\> diff --git a/support_server_release/readme/DESCRIPTION.md b/support_server_release/readme/DESCRIPTION.md new file mode 100644 index 0000000..fa8a900 --- /dev/null +++ b/support_server_release/readme/DESCRIPTION.md @@ -0,0 +1,8 @@ +This module receives the releases pushed by a customer project (see +`support_release`), creates a release per version, links the tickets it ships +and moves them to the stage matching the deployment environment. + +The core is a single POST route added to the existing `cross_connect` +endpoint, authenticated by the same `api-key` as the support feature. There is +no git logic here: the ticket ids are given by the payload, and the partner of +the release is the one of the projects of the tickets. diff --git a/support_server_release/readme/USAGE.md b/support_server_release/readme/USAGE.md new file mode 100644 index 0000000..0521bf2 --- /dev/null +++ b/support_server_release/readme/USAGE.md @@ -0,0 +1,15 @@ +Two stages are configured once for all the projects, in *Settings > Support +Releases*: + +* the preprod stage, applied when a release is received from preprod; +* the prod stage, applied when a release is received from prod. + +A ticket is never moved backwards: the stage is only written when the target +stage is further than the current one, comparing their sequences. + +Once a release has a production deployment date, a payload coming from preprod +for the same version is ignored, so a preprod redeployment cannot reopen +tickets that are already closed. + +The `next` release of a client is absorbed by the first numbered version +received for that client, then removed. diff --git a/support_server_release/routers/__init__.py b/support_server_release/routers/__init__.py new file mode 100644 index 0000000..99b08a5 --- /dev/null +++ b/support_server_release/routers/__init__.py @@ -0,0 +1,6 @@ +# Copyright 2026 Akretion (http://www.akretion.com). +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from .release import release_router + +__all__ = ["release_router"] diff --git a/support_server_release/routers/release.py b/support_server_release/routers/release.py new file mode 100644 index 0000000..ebc9159 --- /dev/null +++ b/support_server_release/routers/release.py @@ -0,0 +1,31 @@ +# Copyright 2026 Akretion (http://www.akretion.com). +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from typing import Annotated + +from fastapi import APIRouter, Depends + +from odoo import api + +from odoo.addons.cross_connect_server.dependencies import ( + authenticated_cross_connect_client, +) +from odoo.addons.cross_connect_server.models.cross_connect_client import ( + CrossConnectClient, +) +from odoo.addons.fastapi.dependencies import odoo_env + +from ..schemas import ReleasePayload + +release_router = APIRouter(tags=["Support Release"]) + + +@release_router.post("/cross_connect/release") +def post_release( + env: Annotated[api.Environment, Depends(odoo_env)], + client: Annotated[CrossConnectClient, Depends(authenticated_cross_connect_client)], + payload: ReleasePayload, +) -> dict: + """Receive a release deployed on a customer project.""" + env["support.server.release"].sudo()._process(client, payload.model_dump()) + return {"ok": True} diff --git a/support_server_release/schemas.py b/support_server_release/schemas.py new file mode 100644 index 0000000..0806071 --- /dev/null +++ b/support_server_release/schemas.py @@ -0,0 +1,12 @@ +# Copyright 2026 Akretion (http://www.akretion.com). +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from typing import Literal + +from pydantic import BaseModel, Field + + +class ReleasePayload(BaseModel): + environment: Literal["preprod", "prod"] + version: str + tickets: list[int] = Field(default_factory=list) diff --git a/support_server_release/security/ir.model.access.csv b/support_server_release/security/ir.model.access.csv new file mode 100644 index 0000000..cc3338b --- /dev/null +++ b/support_server_release/security/ir.model.access.csv @@ -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 diff --git a/support_server_release/static/description/icon.png b/support_server_release/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..1dcc49c24f364e9adf0afbc6fc0bac6dbecdeb11 GIT binary patch literal 10254 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 + + + + +Support Server Release + + + +
+

Support Server Release

+ + +

Beta License: AGPL-3 Akretion/support

+

This module receives the releases pushed by a customer project (see +support_release), creates a release per version, links the tickets +it ships and moves them to the stage matching the deployment +environment.

+

The core is a single POST route added to the existing cross_connect +endpoint, authenticated by the same api-key as the support feature. +There is no git logic here: the ticket ids are given by the payload, and +the partner of the release is the one of the projects of the tickets.

+

Table of contents

+ +
+

Usage

+

Two stages are configured once for all the projects, in Settings > +Support Releases:

+
    +
  • the preprod stage, applied when a release is received from preprod;
  • +
  • the prod stage, applied when a release is received from prod.
  • +
+

A ticket is never moved backwards: the stage is only written when the +target stage is further than the current one, comparing their sequences.

+

Once a release has a production deployment date, a payload coming from +preprod for the same version is ignored, so a preprod redeployment +cannot reopen tickets that are already closed.

+

The next release of a client is absorbed by the first numbered +version received for that client, then removed.

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Akretion
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is part of the Akretion/support project on GitHub.

+

You are welcome to contribute.

+
+
+
+ + diff --git a/support_server_release/tests/__init__.py b/support_server_release/tests/__init__.py new file mode 100644 index 0000000..732e87f --- /dev/null +++ b/support_server_release/tests/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 Akretion (http://www.akretion.com). +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import test_support_server_release diff --git a/support_server_release/tests/test_support_server_release.py b/support_server_release/tests/test_support_server_release.py new file mode 100644 index 0000000..700680e --- /dev/null +++ b/support_server_release/tests/test_support_server_release.py @@ -0,0 +1,232 @@ +# Copyright 2026 Akretion (http://www.akretion.com). +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.tests.common import TransactionCase, tagged + +from odoo.addons.extendable_fastapi.tests.common import FastAPITransactionCase + +from ..routers import release_router + + +class ReleaseCaseMixin: + @classmethod + def _setup_release_data(cls): + cls.Release = cls.env["support.server.release"] + cls.partner = cls.env["res.partner"].create({"name": "Customer"}) + cls.other_partner = cls.env["res.partner"].create({"name": "Other customer"}) + cls.project = cls.env["project.project"].create( + {"name": "Ticket project", "partner_id": cls.partner.id} + ) + cls.other_project = cls.env["project.project"].create( + {"name": "Other project", "partner_id": cls.other_partner.id} + ) + projects = (cls.project | cls.other_project).ids + cls.stage_preprod = cls.env["project.task.type"].create( + { + "name": "To release", + "sequence": 10, + "project_ids": [(6, 0, projects)], + } + ) + cls.stage_prod = cls.env["project.task.type"].create( + { + "name": "Closed", + "sequence": 20, + "project_ids": [(6, 0, projects)], + } + ) + cls.env["ir.config_parameter"].sudo().set_param( + "support_server_release.stage_preprod_id", cls.stage_preprod.id + ) + cls.env["ir.config_parameter"].sudo().set_param( + "support_server_release.stage_prod_id", cls.stage_prod.id + ) + + def _task(self, stage=None, project=None): + stage = stage or self.stage_preprod + project = project or self.project + return self.env["project.task"].create( + {"name": "Ticket", "project_id": project.id, "stage_id": stage.id} + ) + + def _payload(self, environment, version, tickets): + return {"environment": environment, "version": version, "tickets": tickets} + + +class TestSupportServerRelease(ReleaseCaseMixin, TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._setup_release_data() + cls.endpoint = cls.env["fastapi.endpoint"].create( + {"name": "Support endpoint", "root_path": "/api", "app": "cross_connect"} + ) + cls.client = cls.env["cross.connect.client"].create( + { + "name": "Test client", + "endpoint_id": cls.endpoint.id, + "api_key": "release-api-key", + } + ) + + def test_preprod_creates_release(self): + task = self._task() + release = self.Release._process( + self.client, self._payload("preprod", "next", [task.id]) + ) + self.assertEqual(release.version, "next") + self.assertEqual(release.cross_connect_client_id, self.client) + self.assertEqual(release.partner_id, self.partner) + self.assertEqual(release.task_ids, task) + self.assertTrue(release.preprod_deployed_at) + self.assertFalse(release.prod_deployed_at) + self.assertEqual(task.stage_id, self.stage_preprod) + self.assertEqual(task.release_ids, release) + + def test_unknown_ticket_is_ignored(self): + release = self.Release._process( + self.client, self._payload("preprod", "next", [99999999]) + ) + self.assertTrue(release) + self.assertFalse(release.task_ids) + + def test_numbered_version_absorbs_next(self): + first = self._task() + second = self._task() + self.Release._process(self.client, self._payload("preprod", "next", [first.id])) + release = self.Release._process( + self.client, self._payload("preprod", "14.0.1.0", [second.id]) + ) + self.assertFalse(self.Release.search([("version", "=", "next")])) + self.assertEqual(release.version, "14.0.1.0") + self.assertEqual(release.task_ids, first | second) + self.assertEqual(self.Release.search_count([]), 1) + + def test_prod_applies_prod_stage(self): + task = self._task() + release = self.Release._process( + self.client, self._payload("prod", "14.0.1.0", [task.id]) + ) + self.assertTrue(release.prod_deployed_at) + self.assertFalse(release.preprod_deployed_at) + self.assertEqual(task.stage_id, self.stage_prod) + + def test_preprod_ignored_once_in_prod(self): + task = self._task() + self.Release._process(self.client, self._payload("prod", "14.0.1.0", [task.id])) + release = self.Release._process( + self.client, self._payload("preprod", "14.0.1.0", [task.id]) + ) + self.assertFalse(release.preprod_deployed_at) + self.assertEqual(task.stage_id, self.stage_prod) + + def test_stage_never_goes_back(self): + task = self._task(stage=self.stage_prod) + self.Release._process(self.client, self._payload("preprod", "next", [task.id])) + self.assertEqual(task.stage_id, self.stage_prod) + + def test_no_stage_configured(self): + self.env["ir.config_parameter"].sudo().set_param( + "support_server_release.stage_preprod_id", False + ) + task = self._task() + release = self.Release._process( + self.client, self._payload("preprod", "next", [task.id]) + ) + self.assertTrue(release) + self.assertEqual(task.stage_id, self.stage_preprod) + + def test_foreign_partner_ticket_is_ignored(self): + task = self._task() + foreign = self._task(project=self.other_project) + release = self.Release._process( + self.client, self._payload("preprod", "next", [task.id, foreign.id]) + ) + self.assertEqual(release.task_ids, task) + self.assertEqual(release.partner_id, self.partner) + self.assertEqual(foreign.stage_id, self.stage_preprod) + + def test_second_payload_is_idempotent(self): + task = self._task() + payload = self._payload("preprod", "next", [task.id]) + release = self.Release._process(self.client, dict(payload)) + deployed_at = release.preprod_deployed_at + release = self.Release._process(self.client, dict(payload)) + self.assertEqual(self.Release.search_count([]), 1) + self.assertEqual(release.task_ids, task) + self.assertEqual(release.partner_id, self.partner) + self.assertGreaterEqual(release.preprod_deployed_at, deployed_at) + + +@tagged("post_install", "-at_install") +class TestSupportServerReleaseRouter(ReleaseCaseMixin, FastAPITransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._setup_release_data() + cls.endpoint = cls.env["fastapi.endpoint"].create( + { + "name": "Support Release Endpoint", + "root_path": "/api", + "app": "cross_connect", + } + ) + cls.client = cls.env["cross.connect.client"].create( + { + "name": "Test Client", + "endpoint_id": cls.endpoint.id, + "api_key": "release-api-key", + } + ) + cls.endpoint_user = cls.env["res.users"].create( + { + "name": "FastAPI Endpoint User", + "login": "support_release_endpoint_user", + "groups_id": [ + ( + 6, + 0, + [cls.env.ref("fastapi.group_fastapi_endpoint_runner").id], + ) + ], + } + ) + cls.endpoint._handle_registry_sync(cls.endpoint.ids) + cls.default_fastapi_running_user = cls.endpoint_user + cls.default_fastapi_router = release_router + cls.default_fastapi_app = cls.endpoint._get_app() + cls.default_fastapi_dependency_overrides = ( + cls.default_fastapi_app.dependency_overrides + ) + + def test_post_release(self): + task = self._task() + with self._create_test_client() as test_client: + response = test_client.post( + "/cross_connect/release", + headers={"api-key": "release-api-key"}, + json=self._payload("preprod", "next", [task.id]), + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"ok": True}) + release = self.env["support.server.release"].search([("version", "=", "next")]) + self.assertEqual(release.task_ids, task) + self.assertEqual(task.stage_id, self.stage_preprod) + + def test_post_release_unauthorized(self): + with self._create_test_client(raise_server_exceptions=False) as test_client: + response = test_client.post( + "/cross_connect/release", + headers={"api-key": "wrong-api-key"}, + json=self._payload("preprod", "next", []), + ) + self.assertEqual(response.status_code, 401) + + def test_post_release_invalid_environment(self): + with self._create_test_client(raise_server_exceptions=False) as test_client: + response = test_client.post( + "/cross_connect/release", + headers={"api-key": "release-api-key"}, + json=self._payload("dev", "next", []), + ) + self.assertEqual(response.status_code, 422) diff --git a/support_server_release/views/project_task_views.xml b/support_server_release/views/project_task_views.xml new file mode 100644 index 0000000..61a13b7 --- /dev/null +++ b/support_server_release/views/project_task_views.xml @@ -0,0 +1,24 @@ + + + + + project.task + + + + + + + + diff --git a/support_server_release/views/res_config_settings_views.xml b/support_server_release/views/res_config_settings_views.xml new file mode 100644 index 0000000..720aace --- /dev/null +++ b/support_server_release/views/res_config_settings_views.xml @@ -0,0 +1,77 @@ + + + + + res.config.settings.view.form.inherit.support.release + res.config.settings + + + + +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/support_server_release/views/support_server_release_views.xml b/support_server_release/views/support_server_release_views.xml new file mode 100644 index 0000000..0467420 --- /dev/null +++ b/support_server_release/views/support_server_release_views.xml @@ -0,0 +1,86 @@ + + + + + support.server.release + + + + + + + + + + + + + support.server.release + +
+ +
+

+ +

+
+ + + + + + + + + + + + + + + +
+
+
+
+ + + support.server.release + + + + + + + + + + + + + + + + + Releases + support.server.release + list,form + + + +
From a55cc7be8832818db583d27c02750f076b9bbb70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20BEAU?= Date: Sat, 31 Oct 2026 12:32:34 +0000 Subject: [PATCH 03/23] [IMP] support_server_release: notify a release in production 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) --- PLAN-support-release.md | 33 ++++++++++++ support_server_release/README.rst | 25 +++++++++ support_server_release/__manifest__.py | 1 + support_server_release/data/mail_template.xml | 50 +++++++++++++++++ .../models/support_server_release.py | 29 ++++++++++ support_server_release/readme/DESCRIPTION.md | 3 ++ support_server_release/readme/USAGE.md | 19 +++++++ .../static/description/index.html | 45 ++++++++++++---- .../tests/test_support_server_release.py | 54 +++++++++++++++++++ 9 files changed, 248 insertions(+), 11 deletions(-) create mode 100644 support_server_release/data/mail_template.xml diff --git a/PLAN-support-release.md b/PLAN-support-release.md index 858da39..40bd152 100644 --- a/PLAN-support-release.md +++ b/PLAN-support-release.md @@ -451,3 +451,36 @@ Deux points d'exploitation découverts à cette occasion, à retenir pour le pil préprod, `is_sent` arrive à vrai et la prod ne pousse rien. C'est le comportement voulu (`is_sent` n'est jamais remis à faux), mais il faut le savoir avant un rafraîchissement de base. + +--- + +## 17. Notification e-mail de mise en production + +Ajout demandé après la première livraison. À la réception d'un payload `prod`, et +seulement après l'absorption de `next` (pour que la liste des tickets soit complète), le +central envoie le template `mail_template_release_in_prod` : + +- destinataires : les `user_ids` du `cross.connect.client`, c'est-à-dire les + utilisateurs portail créés par la connexion croisée, filtrés sur `active` et sur la + présence d'un `email`, dédoublonnés. Aucun champ ajouté au modèle, aucun nouveau + réglage : une liste vide signifie aucun envoi, avec une ligne de log ; +- contenu : les titres des tickets, chacun pointant vers + `/odoo/action-project.action_view_task/` construit depuis `get_base_url()`, et la + description du ticket repliée dans un `
` ; +- envoi : `send_mail(force_send=False)`, donc un `mail.mail` en file, expédié par la + cron de la file de mails. Une panne SMTP ne fait jamais échouer la réception de la + release ; +- le template est un vrai `mail.template` éditable dans l'interface : rien du contenu + n'est en dur dans le code, à part les deux helpers `_get_notification_emails()` et + `_get_task_url()`. + +Pas de layout de notification Odoo : `mail.mail_notification_light` et +`mail_notification_layout` ajoutent un lien _Unfollow_ sans jeton, qui ne veut rien dire +pour un utilisateur portail, et un « Powered by Odoo » inutile chez le client. Le corps +du template est donc envoyé tel quel. + +**Vérifié** : 31 tests verts, dont trois sur le mail (envoi en prod avec destinataire, +aucun envoi en préprod, aucun envoi sans utilisateur). Rendu réel contrôlé sur la base +du banc après `-u` : sujet `E2E client: version next in production`, liens absolus +`https://erp-fr.akretion.com/odoo/action-project.action_view_task/2`, `
` et +description HTML du ticket préservés, aucun lien `mail/unfollow`. diff --git a/support_server_release/README.rst b/support_server_release/README.rst index b2f4439..e90708b 100644 --- a/support_server_release/README.rst +++ b/support_server_release/README.rst @@ -32,6 +32,10 @@ endpoint, authenticated by the same ``api-key`` as the support feature. There is no git logic here: the ticket ids are given by the payload, and the partner of the release is the one of the projects of the tickets. +When a release reaches production, the mail template *Support Release: +release in production* notifies the users of the client with the tickets +it ships. + **Table of contents** .. contents:: @@ -56,6 +60,27 @@ cannot reopen tickets that are already closed. The ``next`` release of a client is absorbed by the first numbered version received for that client, then removed. +Production notification +----------------------- + +When a release reaches production, the users of its client (the portal +users attached to the ``cross.connect.client``, the ones the connection +creates) get an email listing the tickets of the release: every title +links to the ticket, and the description of the ticket is folded under a +*Description* summary. + +The mail is queued like any other Odoo mail and sent by the mail queue, +so a mail server problem never fails the release. A client without a +user having an email address simply gets no mail. + +The template is *Support Release: release in production*: change the +wording, the recipients or the layout there, the module only renders it. + +The folding uses the HTML ``
`` element. Apple Mail, Thunderbird +and the iOS Mail application fold it; Gmail and Outlook drop it and show +the description unfolded. Email has no reliable alternative, so the +message stays readable everywhere. + Bug Tracker =========== diff --git a/support_server_release/__manifest__.py b/support_server_release/__manifest__.py index a96a0f4..0bdb5e0 100644 --- a/support_server_release/__manifest__.py +++ b/support_server_release/__manifest__.py @@ -15,6 +15,7 @@ ], "data": [ "security/ir.model.access.csv", + "data/mail_template.xml", "views/support_server_release_views.xml", "views/project_task_views.xml", "views/res_config_settings_views.xml", diff --git a/support_server_release/data/mail_template.xml b/support_server_release/data/mail_template.xml new file mode 100644 index 0000000..84a9ee1 --- /dev/null +++ b/support_server_release/data/mail_template.xml @@ -0,0 +1,50 @@ + + + + + Support Release: release in production + + {{ object.cross_connect_client_id.name }}: version {{ object.version }} in production + {{ object._get_notification_emails() }} + + +
+

Hello,

+

+ Version + + of + + is now deployed in production. It contains the following + tickets: +

+ +

Regards,

+
+
+
+
diff --git a/support_server_release/models/support_server_release.py b/support_server_release/models/support_server_release.py index 0966188..cacabd6 100644 --- a/support_server_release/models/support_server_release.py +++ b/support_server_release/models/support_server_release.py @@ -9,6 +9,8 @@ NEXT_VERSION = "next" STAGE_PARAMETER = "support_server_release.stage_%s_id" +TASK_ACTION_XMLID = "project.action_view_task" +RELEASE_IN_PROD_TEMPLATE = "support_server_release.mail_template_release_in_prod" DEPLOYED_FIELDS = { "preprod": "preprod_deployed_at", "prod": "prod_deployed_at", @@ -128,6 +130,31 @@ def _absorb_next_release(self): ) next_release.unlink() + def _get_task_url(self, task): + """URL opening the ticket form in the web client of this database.""" + return f"{self.get_base_url()}/odoo/action-{TASK_ACTION_XMLID}/{task.id}" + + 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( + lambda user: user.active and user.email + ) + return ", ".join(sorted(set(users.mapped("email")))) + + def _notify_prod_release(self): + """Tell the users of the client that the release reached production.""" + self.ensure_one() + if not self._get_notification_emails(): + _logger.info( + "No user to notify for release %s of %s", + self.version, + self.cross_connect_client_id.name, + ) + return + template = self.env.ref(RELEASE_IN_PROD_TEMPLATE, raise_if_not_found=False) + if template: + template.send_mail(self.id, force_send=False) + @api.model def _process(self, client, payload): """Handle a release payload received from a client.""" @@ -173,4 +200,6 @@ def _process(self, client, payload): release._apply_stages(stage) if version != NEXT_VERSION: release._absorb_next_release() + if environment == "prod": + release._notify_prod_release() return release diff --git a/support_server_release/readme/DESCRIPTION.md b/support_server_release/readme/DESCRIPTION.md index fa8a900..c8b201f 100644 --- a/support_server_release/readme/DESCRIPTION.md +++ b/support_server_release/readme/DESCRIPTION.md @@ -6,3 +6,6 @@ The core is a single POST route added to the existing `cross_connect` endpoint, authenticated by the same `api-key` as the support feature. There is no git logic here: the ticket ids are given by the payload, and the partner of the release is the one of the projects of the tickets. + +When a release reaches production, the mail template *Support Release: release +in production* notifies the users of the client with the tickets it ships. diff --git a/support_server_release/readme/USAGE.md b/support_server_release/readme/USAGE.md index 0521bf2..af25b21 100644 --- a/support_server_release/readme/USAGE.md +++ b/support_server_release/readme/USAGE.md @@ -13,3 +13,22 @@ tickets that are already closed. The `next` release of a client is absorbed by the first numbered version received for that client, then removed. + +## Production notification + +When a release reaches production, the users of its client (the portal users +attached to the `cross.connect.client`, the ones the connection creates) get an +email listing the tickets of the release: every title links to the ticket, and +the description of the ticket is folded under a *Description* summary. + +The mail is queued like any other Odoo mail and sent by the mail queue, so a +mail server problem never fails the release. A client without a user having an +email address simply gets no mail. + +The template is *Support Release: release in production*: change the wording, +the recipients or the layout there, the module only renders it. + +The folding uses the HTML `
` element. Apple Mail, Thunderbird and the +iOS Mail application fold it; Gmail and Outlook drop it and show the +description unfolded. Email has no reliable alternative, so the message stays +readable everywhere. diff --git a/support_server_release/static/description/index.html b/support_server_release/static/description/index.html index b9ae9ea..b0996aa 100644 --- a/support_server_release/static/description/index.html +++ b/support_server_release/static/description/index.html @@ -378,15 +378,21 @@

Support Server Release

endpoint, authenticated by the same api-key as the support feature. There is no git logic here: the ticket ids are given by the payload, and the partner of the release is the one of the projects of the tickets.

+

When a release reaches production, the mail template Support Release: +release in production notifies the users of the client with the tickets +it ships.

Table of contents

    -
  • Usage
  • -
  • Bug Tracker
  • -
  • Credits @@ -406,9 +412,26 @@

    Usage

    cannot reopen tickets that are already closed.

    The next release of a client is absorbed by the first numbered version received for that client, then removed.

    +
    +

    Production notification

    +

    When a release reaches production, the users of its client (the portal +users attached to the cross.connect.client, the ones the connection +creates) get an email listing the tickets of the release: every title +links to the ticket, and the description of the ticket is folded under a +Description summary.

    +

    The mail is queued like any other Odoo mail and sent by the mail queue, +so a mail server problem never fails the release. A client without a +user having an email address simply gets no mail.

    +

    The template is Support Release: release in production: change the +wording, the recipients or the layout there, the module only renders it.

    +

    The folding uses the HTML <details> element. Apple Mail, Thunderbird +and the iOS Mail application fold it; Gmail and Outlook drop it and show +the description unfolded. Email has no reliable alternative, so the +message stays readable everywhere.

    +
-

Bug Tracker

+

Bug Tracker

Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed @@ -416,21 +439,21 @@

Bug Tracker

Do not contact contributors directly about support or help with technical issues.

-

Credits

+

Credits

-

Authors

+

Authors

  • Akretion
-

Maintainers

+

Maintainers

This module is part of the Akretion/support project on GitHub.

You are welcome to contribute.

diff --git a/support_server_release/tests/test_support_server_release.py b/support_server_release/tests/test_support_server_release.py index 700680e..4ef09d1 100644 --- a/support_server_release/tests/test_support_server_release.py +++ b/support_server_release/tests/test_support_server_release.py @@ -52,6 +52,22 @@ def _task(self, stage=None, project=None): def _payload(self, environment, version, tickets): return {"environment": environment, "version": version, "tickets": tickets} + def _client_user(self, email): + """A portal user of the client, the recipient of the release mail.""" + return ( + self.env["res.users"] + .with_context(no_reset_password=True) + .create( + { + "name": "Customer user", + "login": email, + "email": email, + "cross_connect_client_id": self.client.id, + "groups_id": [(6, 0, [self.env.ref("base.group_portal").id])], + } + ) + ) + class TestSupportServerRelease(ReleaseCaseMixin, TransactionCase): @classmethod @@ -157,6 +173,44 @@ def test_second_payload_is_idempotent(self): self.assertEqual(release.partner_id, self.partner) self.assertGreaterEqual(release.preprod_deployed_at, deployed_at) + def test_prod_notifies_the_client_users(self): + task = self._task() + task.description = "

The content of the ticket

" + self._client_user("customer@example.com") + self.env["mail.mail"].search([]).unlink() + release = self.Release._process( + self.client, self._payload("prod", "14.0.1.0", [task.id]) + ) + mail = self.env["mail.mail"].search( + [("model", "=", "support.server.release"), ("res_id", "=", release.id)] + ) + self.assertEqual(len(mail), 1) + self.assertEqual(mail.email_to, "customer@example.com") + self.assertIn("14.0.1.0", mail.subject) + self.assertIn(task.name, mail.body_html) + self.assertIn("The content of the ticket", mail.body_html) + self.assertIn("
", mail.body_html) + self.assertNotIn("mail/unfollow", mail.body_html) + self.assertIn( + f"/odoo/action-project.action_view_task/{task.id}", mail.body_html + ) + + def test_preprod_sends_no_mail(self): + task = self._task() + self._client_user("customer@example.com") + self.env["mail.mail"].search([]).unlink() + self.Release._process(self.client, self._payload("preprod", "next", [task.id])) + self.assertFalse(self.env["mail.mail"].search([])) + + def test_prod_without_client_user_sends_no_mail(self): + task = self._task() + self.env["mail.mail"].search([]).unlink() + release = self.Release._process( + self.client, self._payload("prod", "14.0.1.0", [task.id]) + ) + self.assertTrue(release.prod_deployed_at) + self.assertFalse(self.env["mail.mail"].search([])) + @tagged("post_install", "-at_install") class TestSupportServerReleaseRouter(ReleaseCaseMixin, FastAPITransactionCase): From aa74df081c6c878f12b7ba7c4232b118744b9bb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20BEAU?= Date: Sat, 31 Oct 2026 13:26:18 +0000 Subject: [PATCH 04/23] [IMP] support_release, support_server_release: apply the review 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) --- PLAN-support-release.md | 262 +++++++++++------- support_release/README.rst | 12 +- support_release/models/support_release.py | 109 ++------ support_release/readme/USAGE.md | 11 +- support_release/security/ir.model.access.csv | 2 +- support_release/static/description/index.html | 10 +- support_release/tests/test_support_release.py | 61 ++-- .../views/support_release_views.xml | 13 +- support_server_release/README.rst | 17 +- .../models/fastapi_endpoint.py | 2 +- .../models/support_server_release.py | 173 +++++------- support_server_release/readme/USAGE.md | 16 +- support_server_release/schemas.py | 2 +- .../security/ir.model.access.csv | 2 +- .../static/description/index.html | 16 +- .../tests/test_support_server_release.py | 58 +++- .../views/res_config_settings_views.xml | 71 ++--- .../views/support_server_release_views.xml | 10 +- 18 files changed, 427 insertions(+), 420 deletions(-) diff --git a/PLAN-support-release.md b/PLAN-support-release.md index 40bd152..2190edc 100644 --- a/PLAN-support-release.md +++ b/PLAN-support-release.md @@ -61,22 +61,24 @@ Deux modules : ## 3. Contraintes -1. **La version est la clé.** Rejouer un appel avec la même version complète la release - (union des tickets) sans la dupliquer : une migration rejouée ne crée rien en double. +1. **La version est la clé.** Rejouer un appel avec la même version remplace ses tickets + sans dupliquer la release : chaque déploiement renvoie la liste complète des tickets. 2. **`next`** est la valeur de version quand aucun numéro n'existe : une seule par - client, absorbée par la première version numérotée. + client, supprimée à l'arrivée de la première version numérotée. 3. **Aucune logique GitLab côté serveur** : les ids de tickets arrivent dans le payload. 4. **Une release n'appartient pas à un projet** : elle appartient au client (`cross.connect.client`) et peut contenir des tickets de plusieurs projets ; `partner_id` est celui du projet des tickets liés. 5. **Une migration ne doit jamais casser un déploiement** : elle tourne aussi sur les bases de test, et aucun appel réseau ne s'y fait. -6. **Le déplacement d'étape ne régresse jamais un ticket** (comparaison de séquence) et - ne casse jamais la réception (try/except par ticket). +6. **L'étape de l'environnement s'applique sans condition** : le payload décrit l'état + après déploiement, donc une réception hors ordre corrige la précédente. 7. **Une seule registration par version, avant le déploiement.** `is_sent` n'est jamais invalidé : une version déjà envoyée n'est pas renvoyée, même si une migration ultérieure y ajoutait des tickets. -8. **Pas de backport** : cible 18.0, pilote sur un projet 18.0. +8. **Rien n'est ignoré en silence.** Ce qui ne peut pas être appliqué à la réception est + écrit dans le champ `message` de la release ; un appel mal formé lève. +9. **Pas de backport** : cible 18.0, pilote sur un projet 18.0. --- @@ -95,15 +97,15 @@ Deux modules : | tickets, is_sent | 1/tick | client (clé) + partner (projet) | | push la plus ancienne non envoyée | | -> project.task liés (multi-projets) | +-------------------------------------+ | -> étape préprod OU étape prod | - | (paramétrées, jamais en arrière) | + | (paramétrées, sans condition) | +---------------------------------------+ ``` -| Moment | Appel de la migration | Côté client | Côté central | -| -------------------------- | ------------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------ | -| merge, déploiement préprod | `version="next"`, `tickets=[...]` | release `next` créée ou complétée | `next` reçue, tickets liés, étape préprod | -| bump, déploiement préprod | `version="14.0.2610.26.0"`, `tickets=[...]` | release `` créée, tickets de `next` absorbés, `next` supprimée | `` reçue, tickets liés, étape préprod, `next` absorbée puis supprimée | -| déploiement prod | même appel que la préprod | même release, renvoyée | `` reçue, tickets à l'étape prod, `prod_deployed_at` posé | +| Moment | Appel de la migration | Côté client | Côté central | +| -------------------------- | ------------------------------------------- | ------------------------------------- | ------------------------------------------------------------ | +| merge, déploiement préprod | `version="next"`, `tickets=[...]` | release `next` créée ou remplacée | `next` reçue, tickets liés, étape préprod | +| bump, déploiement préprod | `version="14.0.2610.26.0"`, `tickets=[...]` | release `` créée, `next` supprimée | `` reçue, tickets liés, étape préprod, `next` supprimée | +| déploiement prod | même appel que la préprod | même release, renvoyée | `` reçue, tickets à l'étape prod, `prod_deployed_at` posé | Trois règles : @@ -118,21 +120,24 @@ Trois règles : ## 5. Décisions -| # | Décision | Raison | -| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| D1 | Deux modules dans akretion/support : `support_release` (client) et `support_server_release` (serveur), avec des modèles aux noms distincts (`support.release` / `support.server.release`). | Réponse utilisateur ; aucun risque de fusion de modèle. | -| D2 | L'information entre par une migration générée qui appelle `env["support.release"]._register_deployment(version=..., tickets=[...])`, sous `if "support.release" not in env`. | Seul mécanisme disponible au moment de la mise à jour, sans git ni réseau ; la garde évite de casser une base où le module manque. | -| D3 | La version est la clé d'idempotence, `next` quand il n'y a pas de numéro ; la première version numérotée absorbe `next` puis la supprime (le serveur fait de même). | Réponse utilisateur ; rejouable, et aucun ticket perdu ou compté deux fois. | -| D4 | Environnement = `tools.config.get("running_env")`, sans paramètre de module ; seuls `preprod` et `prod` déclenchent un envoi. | Réponse utilisateur ; `server_environment` fournit déjà la valeur. | -| D5 | Envoi par cron (10 min) et bouton, une release par tick, la plus ancienne non envoyée ; `is_sent` posé seulement en cas de succès, jamais invalidé. | Réponse utilisateur ; aucun état d'erreur à maintenir. | -| D6 | Transport par `cross.connect.server._request()` sur `/cross_connect/release`, route ajoutée à l'endpoint `api-support` existant. | Infrastructure en place : ni authentification ni endpoint nouveau. | -| D7 | Le serveur ne dépend ni de GitLab ni de `project_customer_access` : manifeste `cross_connect_server` + `project`. | Réponse utilisateur ; le partenaire venant des projets des tickets, plus rien n'a besoin du lien client → projets, et `gitlab_integration` sort de la chaîne de dépendances. | -| D8 | La release n'a pas de `project_id` ; elle porte le `cross_connect_client_id` (issu de la clé d'API) et le `partner_id` **déduit du projet des tickets liés** ; les tickets dont le projet pointe un autre partenaire ne sont pas liés. | Réponse utilisateur : le partenaire vient du projet de la task, pas de la configuration du client. | -| D9 | Deux étapes globales paramétrables (préprod, prod) dans les Réglages, appliquées à chaque réception, jamais en arrière, avec rattachement de l'étape au projet si besoin. | Réponse utilisateur, plus le piège du calcul de `stage_id`. | -| D10 | Préprod ignorée pour une version déjà en prod. | Réponse utilisateur. | -| D11 | Les horodatages de déploiement sont posés par le central à la réception et écrasés à chaque réception. | Le client n'envoie pas de dates, l'heure du central est la seule fiable. | -| D12 | Versions non validées en format ; `next` normalisé en minuscules. | Les versions en usage ont cinq segments. | -| D13 | Pas de backport. | Réponse utilisateur. | +| # | Décision | Raison | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| D1 | Deux modules dans akretion/support : `support_release` (client) et `support_server_release` (serveur), avec des modèles aux noms distincts (`support.release` / `support.server.release`). | Réponse utilisateur ; aucun risque de fusion de modèle. | +| D2 | L'information entre par une migration générée qui appelle `env["support.release"]._register_deployment(version=..., tickets=[...])`, sous `if "support.release" not in env`. | Seul mécanisme disponible au moment de la mise à jour, sans git ni réseau ; la garde évite de casser une base où le module manque. | +| D3 | La version est la clé d'idempotence, `next` quand il n'y a pas de numéro ; chaque payload porte la liste complète des tickets, donc la première version numérotée supprime simplement `next` (le serveur fait de même). | Réponse utilisateur ; rejouable, aucun ticket compté deux fois, aucune fusion à maintenir. | +| D4 | Environnement = `tools.config.get("running_env")`, sans paramètre de module ; seuls `preprod` et `prod` déclenchent un envoi. | Réponse utilisateur ; `server_environment` fournit déjà la valeur. | +| D5 | Envoi par cron (10 min), une release par tick, la plus ancienne non envoyée ; `is_sent` posé seulement en cas de succès, jamais invalidé ; un échec remonte dans la cron au lieu d'être avalé. | Réponse utilisateur ; aucun état d'erreur à maintenir, l'exception reste visible. | +| D6 | Transport par `cross.connect.server._request()` sur `/cross_connect/release`, route ajoutée à l'endpoint `api-support` existant. | Infrastructure en place : ni authentification ni endpoint nouveau. | +| D7 | Le serveur ne dépend ni de GitLab ni de `project_customer_access` : manifeste `cross_connect_server` + `project`. | Réponse utilisateur ; le partenaire venant des projets des tickets, plus rien n'a besoin du lien client → projets, et `gitlab_integration` sort de la chaîne de dépendances. | +| D8 | La release n'a pas de `project_id` ; elle porte le `cross_connect_client_id` (issu de la clé d'API) et le `partner_id` **déduit du projet des tickets liés** ; les tickets dont le projet pointe un autre partenaire ne sont pas liés. | Réponse utilisateur : le partenaire vient du projet de la task, pas de la configuration du client. | +| D9 | Deux étapes globales paramétrables (préprod, prod) dans les Réglages, appliquées à chaque réception sans comparaison avec l'étape courante, avec rattachement de l'étape au projet si besoin. | Réponse utilisateur (« on applique la release et on met à jour l'état sans condition »), plus le piège du calcul de `stage_id`. | +| D10 | Préprod ignorée pour une version déjà en prod. | Réponse utilisateur. | +| D11 | Les horodatages de déploiement sont posés par le central à la réception et écrasés à chaque réception. | Le client n'envoie pas de dates, l'heure du central est la seule fiable. | +| D12 | Versions non validées en format ; `next` normalisé en minuscules. | Les versions en usage ont cinq segments. | +| D13 | Pas de backport. | Réponse utilisateur. | +| D14 | Tri des releases du central sur `id desc` : la version est une `Char`, un tri `version desc` placerait `14.0.2610.9.0` avant `14.0.2610.26.0`. | Revue de la PR 84. | +| D15 | Ce qui ne peut pas être appliqué est écrit dans un champ `message` visible sur la release (tickets inconnus, autre partenaire, aucun partenaire, étape non configurée, aucun destinataire) : aucun avertissement silencieux dans les logs. | Revue de la PR 84 (« jamais de choses silencieuses »). | +| D16 | Droits : lecture/écriture pour les internes côté central, lecture/écriture pour `base.group_system` côté client, jamais de création ni de suppression depuis l'interface. | Revue de la PR 84 : seul le code crée les releases, et supprimer effacerait la seule trace du déploiement. | --- @@ -152,11 +157,13 @@ def migrate(env, version): Avec un bump : `version="14.0.2610.26.0"` et les tickets de la version. -- `version` vide → avertissement dans les logs, rien n'est créé, aucune exception ; -- `tickets` absents ou vides → liste vide acceptée ; -- si la release existe déjà, ses tickets sont complétés (union dédoublonnée) ; -- si `version` est numérotée et qu'une `next` existe, ses tickets sont absorbés puis la - `next` est supprimée, dans la même transaction. +- `version` vide → `ValueError` : une migration générée cassée doit se voir, pas + disparaître dans un log ; +- `tickets` doit être une liste d'ids entiers, sinon `ValueError`, aucun filtrage + silencieux ; +- si la release existe déjà, ses tickets sont remplacés par ceux du payload ; +- si `version` est numérotée et qu'une `next` existe, la `next` est supprimée, dans la + même transaction : ses tickets sont déjà dans le payload de la version. --- @@ -178,63 +185,61 @@ est refusé (422). Le client vient de la clé d'API ; le partenaire, lui, n'est le payload : il se déduit du projet des tickets liés. Réponse : `{"ok": true}` (200). 401 si la clé est inconnue, 422 si le payload est -invalide. Aucune autre information n'est retournée : les diagnostics (tickets inconnus, -tickets d'un autre partenaire, étapes déplacées ou ignorées) vont dans les logs du -central. +invalide (dont une `version` vide). Aucune autre information n'est retournée : les +diagnostics (tickets inconnus, ticket d'un autre partenaire, étape non configurée) sont +écrits dans le champ `message` de la release sur le central. --- ## 8. Module `support_release` (client) -Dépend de `support`. Un modèle, une cron, une vue liste, une vue formulaire, un bouton. +Dépend de `support`. Un modèle, une cron, une vue liste, une vue formulaire. ### `support.release` (`_rec_name = "version"`) | Champ | Type | Rôle | | --------- | ---------------------------- | --------------------------------------------------------------- | | `version` | Char, requis, unique, indexé | clé ; `next` pour la future version | -| `tickets` | Char | JSON de la liste d'ids, dédoublonnée et triée | +| `tickets` | `Json` | liste d'ids telle que reçue du dernier déploiement | | `is_sent` | Booléen | vrai après un envoi accepté par le central, jamais remis à faux | `create_date`, posé par Odoo, donne l'ordre d'envoi. ### Méthodes -- `_register_deployment(version, tickets)` : normalise la version, normalise et - dédoublonne les tickets, crée ou complète la release, absorbe `next` si la version est - numérotée (§6). +- `_register_deployment(version, tickets)` : `strip()` sur la version, `next` replié en + minuscules, `ValueError` si la version est vide ou si `tickets` n'est pas une liste + d'ids entiers ; crée la release ou remplace ses tickets ; supprime `next` quand la + version est numérotée (§6). - `_get_environment()` : `tools.config.get("running_env")` si la valeur est `preprod` ou `prod`, sinon `False`. -- `_next_release_to_send()` : - `search([("is_sent", "=", False)], order="create_date, id", limit=1)`. -- `_push_release(release)` : - `ref("support.support_server")._request("POST", "/release", data=payload)` en - `sudo()`, puis `is_sent = True`. Toute exception est journalisée et laisse `is_sent` à - faux. +- `_next_release_to_send()` : `search([("is_sent", "=", False)], limit=1)` sur l'ordre + `create_date, id`. +- `_push()` : `ref("support.support_server")._request("POST", "/release", data=payload)` + en `sudo()`, puis `is_sent = True`. Une exception laisse `is_sent` à faux et remonte. - `_cron_push_releases()` : sort si `_get_environment()` est faux ; sinon pousse - `_next_release_to_send()`, dans un try/except (la cron ne lève jamais). -- `_push_pending()` : bouton, boucle sur `_next_release_to_send()` jusqu'à épuisement - (garde-fou à 100). + `_next_release_to_send()`, sans try/except : une panne doit apparaître dans le log de + la cron et la release est retentée au tick suivant. ### Vues -Réglages > Technique > Releases (`base.group_system`) : liste (version, envoyée) avec -filtre "À envoyer" et bouton "Pousser maintenant", formulaire (tickets en lecture, -bouton d'envoi). +Réglages > Releases (`base.group_system`, donc visible sans mode développeur) : liste +(version, tickets, envoyée, date) avec filtre "À envoyer", formulaire en lecture seule. +Pas de bouton : la cron est le seul chemin d'envoi. ### Tests - première registration : release créée, `is_sent` faux, tickets corrects ; -- seconde registration de la même version : union, une seule release, `is_sent` inchangé - ; -- version numérotée avec une `next` existante : absorption puis suppression de `next` ; -- nouveau merge après absorption : une nouvelle `next` est créée ; -- `version="Next"` : normalisé, même release ; -- version vide : avertissement, rien de créé ; +- seconde registration de la même version : les tickets sont remplacés, une seule + release, `is_sent` inchangé ; +- version numérotée avec une `next` existante : `next` supprimée, la version garde ses + propres tickets ; +- nouveau merge après suppression : une nouvelle `next` est créée ; +- `version="Next"` : replié, même release ; +- version vide ou tickets qui ne sont pas une liste d'ids : `ValueError`, rien de créé ; - `running_env` à `dev` : aucun appel ; à `preprod` : un seul appel, sur la plus ancienne non envoyée ; -- envoi en échec : `is_sent` reste faux et la release est retentée ; -- clic sur le bouton : toute la file part, une requête par release. +- envoi en échec : l'exception remonte, `is_sent` reste faux et la release est retentée. --- @@ -254,6 +259,9 @@ tickets. | `task_ids` | Many2many `project.task` | tickets liés, tous projets confondus | | `preprod_deployed_at` | Datetime | dernière réception préprod | | `prod_deployed_at` | Datetime | dernière réception prod | +| `message` | Text, lecture seule | ce qui n'a pas pu être appliqué (D15) | + +Ordre par défaut : `id desc`, donc la dernière release reçue en tête (D14). Contrainte SQL : unicité (`cross_connect_client_id`, `version`). `project.task` : `release_ids` Many2many `support.server.release` (relation @@ -269,35 +277,37 @@ Contrainte SQL : unicité (`cross_connect_client_id`, `version`). `project.task` ### Traitement -`_process(client, payload)` : - -1. release existante = `search` sur (`cross_connect_client_id`, `version`) ; si - `environment == "preprod"` et qu'elle a déjà un `prod_deployed_at`, on s'arrête là - (log) ; -2. `env["project.task"].browse(ticket_ids).exists()` ; les ids inexistants sont - journalisés et ignorés ; -3. `partner_id` = premier `partner_id` non vide des projets de ces tickets ; si un - partenaire est trouvé, les tickets dont le projet pointe un autre partenaire sont - journalisés et ne sont pas liés ; si aucun projet n'a de partenaire, tous les tickets - sont liés et la release reste sans partenaire ; -4. upsert : `task_ids` complétés, horodatage de l'environnement posé à `now()` (écrasé) - ; -5. étapes, voir ci-dessous ; -6. si la version n'est pas `next` et qu'une `next` existe pour ce client : ses tickets - sont ajoutés à la version, puis la `next` est supprimée. +`_process(client, payload)` reste courte (une quinzaine de lignes) et délègue : + +1. `_get_release(client, version)` : `search` sur (`cross_connect_client_id`, + `version`), sinon `create` ; +2. si `environment == "preprod"` et que la release a déjà un `prod_deployed_at`, on + s'arrête là : rien n'est modifié, pas même le champ `message` ; +3. `_filter_tickets(release, ticket_ids)` : résout les ids (`browse().exists()`), met + dans `message` les ids inconnus, garde les tickets dont le projet porte le partenaire + de la release (ou le premier partenaire trouvé si la release n'en a pas encore) et + met dans `message` ceux d'un autre partenaire ; si aucun projet n'a de partenaire, + aucun ticket n'est lié et `message` l'indique ; +4. `_release_vals(...)` : horodatage de l'environnement à `now()` (écrasé), `message`, + `task_ids` liés et `partner_id` ; +5. `_apply_stages(stage)` puis, si aucune étape n'est configurée, une ligne dans + `message` ; +6. `_remove_next_release()` si la version n'est pas `next` ; +7. `_notify_prod_release()` en production. ### Étapes -`_apply_stages(release, environment)` : +`_apply_stages(stage)` : - cible = `support_server_release.stage_preprod_id` ou `.stage_prod_id` ; -- pour chaque ticket, on n'écrit que si `(sequence, id)` de la cible est strictement - au-delà de l'étape courante ; +- l'étape est écrite sur **chaque** ticket, sans comparaison avec l'étape courante ; - si la cible n'est pas dans `task.project_id.type_ids`, elle y est rattachée - (`_set_stage_on_project_from_task`) pour que la colonne existe dans le kanban ; + (`Command.link`) pour que la colonne existe dans le kanban et que le calcul de + `stage_id` ne la remplace pas ; - écriture en `sudo()`, une entrée de chatter par ticket (`stage_id` est `tracking=True`) ; -- chaque ticket dans son propre try/except, les erreurs sont journalisées. +- aucune gestion d'erreur par ticket : une écriture impossible fait échouer la + réception, ce qui est visible et rejoué par le client. ### Paramétrage @@ -311,26 +321,32 @@ Support > Releases : liste (version, client, partenaire, préprod, prod) avec fi ### Sécurité -`ir.model.access.csv` : lecture/écriture pour `base.group_user`. Rien pour les -utilisateurs clients du support : `cross_connect_client_id` et `partner_id` sont là pour -poser plus tard une règle d'accès. +`ir.model.access.csv` : lecture/écriture pour `base.group_user`, ni création ni +suppression (D16) : seul le code crée une release, et la supprimer effacerait la seule +trace du déploiement. Rien pour les utilisateurs clients du support : +`cross_connect_client_id` et `partner_id` sont là pour poser plus tard une règle +d'accès. ### Tests - payload `next` en préprod : release créée, tickets liés, étape préprod appliquée, - `preprod_deployed_at` posé ; -- payload d'une version numérotée alors qu'une `next` existe : tickets absorbés, `next` - supprimée ; + `preprod_deployed_at` posé, `message` vide ; +- payload d'une version numérotée alors qu'une `next` existe : `next` supprimée, la + version garde les tickets de son payload ; - payload `prod` : tickets à l'étape prod, `prod_deployed_at` posé ; - payload préprod sur une version déjà en prod : rien de modifié ; -- cible en amont de l'étape courante : ticket non déplacé ; -- étape non configurée : aucun déplacement, aucune erreur ; +- cible en amont de l'étape courante : le ticket est quand même déplacé (D9) ; +- étape non configurée : aucun déplacement, une ligne dans `message` ; - étape absente de `project.type_ids` : rattachée puis appliquée ; - `partner_id` de la release = celui du projet du ticket ; un ticket d'un projet d'un - autre partenaire n'est pas lié ; -- ticket inexistant : ignoré, la release est créée quand même ; + autre partenaire n'est pas lié et reste à son étape, avec une ligne dans `message` ; +- projet sans partenaire : aucun ticket lié, `message` l'indique ; +- ticket inexistant : la release est créée quand même et `message` cite l'id ; - second envoi identique : rien ne change ; -- `api-key` inconnue → 401 ; `environment` inconnu → 422. +- mail de production : un `mail.mail` avec les destinataires du client, les titres, les + liens et le `
` ; aucun mail en préprod ; aucun mail sans destinataire, mais + une ligne dans `message` ; +- `api-key` inconnue → 401 ; `environment` inconnu → 422 ; `version` vide → 422. --- @@ -368,15 +384,15 @@ client s'il n'existe pas, configurer les deux étapes. Rien à configurer sur le ## 12. Risques -| Risque | Impact | Traitement | -| -------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| Une release en échec reste en tête de file et bloque les suivantes. | Retard d'envoi. | Assumé : `is_sent` non posé, erreur journalisée à chaque tick ; on corrige la cause, ou on marque la release envoyée à la main. | -| Environnements de review en `RUNNING_ENV=preprod`. | Aucune pollution : la migration n'existe que sur la branche principale, donc ils n'ont aucune release à envoyer. | Rien à faire. | -| Tickets de projets rattachés à des partenaires différents dans une même release. | Les minoritaires ne sont pas liés. | Partenaire = celui du premier projet rencontré, les autres tickets sont journalisés ; cas anormal par construction. | -| Migration générée sur une base où le module client n'est pas installé. | Échec de mise à jour. | Garde `if "support.release" not in env`. | -| Versions en `next` pendant longtemps. | La release `next` grossit. | Comportement voulu. | -| Étape configurée absente de `project.type_ids`, ou séquences mal ordonnées. | Ticket non déplacé ou invisible. | Rattachement automatique, logs par ticket ; à vérifier une fois en pilote. | -| Tickets non résolus non conservés. | Pas de post-mortem en base. | Logs du central uniquement, assumé. | +| Risque | Impact | Traitement | +| -------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Une release en échec reste en tête de file et bloque les suivantes. | Retard d'envoi. | Assumé : `is_sent` non posé, l'exception remonte dans le log de la cron à chaque tick ; on corrige la cause, ou on marque la release envoyée à la main. | +| Environnements de review en `RUNNING_ENV=preprod`. | Aucune pollution : la migration n'existe que sur la branche principale, donc ils n'ont aucune release à envoyer. | Rien à faire. | +| Tickets de projets rattachés à des partenaires différents dans une même release. | Les minoritaires ne sont pas liés. | Partenaire = celui du premier projet rencontré, les autres tickets ne sont pas liés et apparaissent dans `message`. | +| Migration générée sur une base où le module client n'est pas installé. | Échec de mise à jour. | Garde `if "support.release" not in env`. | +| Versions en `next` pendant longtemps. | La release `next` grossit. | Comportement voulu. | +| Étape configurée absente de `project.type_ids`. | Ticket non déplacé ou invisible. | Rattachement automatique avant l'écriture ; les séquences ne servent plus à rien puisque l'étape s'applique sans condition. | +| Tickets non résolus. | Pas de post-mortem en base. | Conservés dans le champ `message` de la release, avec les autres anomalies. | --- @@ -484,3 +500,47 @@ aucun envoi en préprod, aucun envoi sans utilisateur). Rendu réel contrôlé s du banc après `-u` : sujet `E2E client: version next in production`, liens absolus `https://erp-fr.akretion.com/odoo/action-project.action_view_task/2`, `
` et description HTML du ticket préservés, aucun lien `mail/unfollow`. + +--- + +## 18. Revue de la PR 84 + +Les commentaires de `sebastienbeau` primes sur ceux de `rob-beau` en cas de désaccord ; +les points de `rob-beau` qu'il n'a pas contredits sont appliqués. Le détail : + +| Commentaire | Traitement | +| ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `_normalize_version` inutile | helper supprimé, `strip()` et repli de `next` en minuscules inline | +| `_normalize_tickets` trop complexe | helper supprimé : `ValueError` si ce n'est pas une liste d'ids entiers | +| `release.tickets = ticket_ids` | plus aucune fusion : le payload remplace les tickets (chaque déploiement envoie la liste complète) | +| savepoint dans `_register_deployment` | non appliqué, conformément à la réponse (« on fait un search avant, pas de risque de parallèle ») | +| `self.tickets` suffisant | champ passé en `Json`, `_get_ticket_ids()` supprimé | +| pas de try/except dans la cron | supprimé : l'exception remonte dans le log de la cron, la release reste non envoyée et sera retentée | +| `action_push_pending` à dégager | méthode, bouton de la vue et les deux tests supprimés ; la cron est le seul chemin d'envoi | +| `append` plutôt que `+= [x]` | appliqué | +| tri `id desc` | appliqué (D14) | +| pas de warning silencieux (tickets inconnus) | nouveau champ `message` sur la release | +| pas de warning silencieux (plusieurs partenaires) | idem : les tickets écartés y sont listés | +| savepoint dans `_apply_stages` | supprimé | +| pas de « never back » | comparaison de séquence supprimée : l'étape s'applique sans condition (D9, plan mis à jour) | +| `next` supprimée directement | fusion des tickets supprimée, simple `unlink()` | +| `_process` trop longue | découpée en `_get_release`, `_filter_tickets`, `_release_vals`, `_add_message`, `_apply_stages`, `_remove_next_release` ; une quinzaine de lignes | +| menu de `support_release` sous `base.menu_custom` (debug) | déplacé sous Réglages avec `groups="base.group_system"` | +| isolation : tout lié quand le projet n'a pas de partenaire | plus aucun ticket lié sans partenaire, la raison est dans `message` | +| réutiliser `_set_stage_on_project_from_task()` | non appliqué : ce helper rattache l'étape **courante** du ticket, pas la cible ; le code rattache la cible avec `Command.link`, ce qui est le même geste pour la bonne étape. Un test couvre le cas. | +| `version` vide acceptée par le schéma | `Field(min_length=1)` + test 422 | +| `create`/`unlink` ouverts aux internes | `1,1,0,0` côté central et côté client (D16) | +| test `foreign.stage_id` qui ne peut pas échouer | ticket étranger créé sur une étape en amont, assertion qu'il n'a pas bougé + présence dans `message` | +| bloc de réglages en structure 16.0 | `` dans un ``, plus de `div.app_settings_block` manuel | +| test manquant « étape absente de `project.type_ids` » | ajouté, avec vérification du rattachement | + +Le fonctionnement a changé sur quatre points, tous reportés dans le plan : les tickets +d'un payload remplacent les précédents (D3), l'étape s'applique sans condition (D9), les +anomalies vont dans `message` au lieu des logs (D15), et le bouton d'envoi disparaît +(D5). + +**Vérifié** : 32 tests verts sur le banc (14 client, 14 central, 4 router), dont les +nouveaux cas : remplacement des tickets, `ValueError` sur version vide ou tickets +invalides, étape appliquée en arrière, étape rattachée au projet, `message` rempli pour +un ticket inconnu, un ticket d'un autre partenaire, un projet sans partenaire, une étape +non configurée et l'absence de destinataire, `version` vide en 422. diff --git a/support_release/README.rst b/support_release/README.rst index 691d168..d4406ed 100644 --- a/support_release/README.rst +++ b/support_release/README.rst @@ -63,8 +63,16 @@ The guard on the model presence keeps a database where this module is not installed from failing its module update. Releases are then pushed by the cron *Support Release: push pending -releases* (every 10 minutes, one release per run, oldest first), or -immediately with the *Push pending releases* button. +releases* (every 10 minutes, one release per run, oldest first). + +Every deployment sends the complete list of tickets of the release, so +registering the same version again replaces its tickets instead of +completing them. A call with a version or a ticket list that cannot be +used raises: a broken generated migration must be visible in the log, +not silently ignored. + +A release is only marked as sent once the support server accepted it, so +a failed push is retried by the next run. The push only happens when ``running_env`` is ``preprod`` or ``prod``: it is set by ``server_environment`` from the ``RUNNING_ENV`` environment diff --git a/support_release/models/support_release.py b/support_release/models/support_release.py index 61f96ae..1c0e92a 100644 --- a/support_release/models/support_release.py +++ b/support_release/models/support_release.py @@ -1,14 +1,9 @@ # Copyright 2026 Akretion (http://www.akretion.com). # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -import json -import logging - from odoo import _, api, fields, models, tools from odoo.exceptions import UserError -_logger = logging.getLogger(__name__) - NEXT_VERSION = "next" SUPPORTED_ENVIRONMENTS = ("preprod", "prod") @@ -24,8 +19,8 @@ class SupportRelease(models.Model): index=True, help="Version of the deployment, or 'next' while no version is defined.", ) - tickets = fields.Char( - help="JSON list of the ticket ids shipped by this release.", + tickets = fields.Json( + help="Ids of the tickets shipped by this release.", ) is_sent = fields.Boolean( default=False, @@ -48,66 +43,29 @@ def _get_environment(self): return running_env if running_env in SUPPORTED_ENVIRONMENTS else False @api.model - def _normalize_version(self, version): - version = (version or "").strip() - if version.lower() == NEXT_VERSION: - return NEXT_VERSION - return version - - @api.model - 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) + def _register_deployment(self, version, tickets): + """Record a deployment, called from the migration of the project. - def _get_ticket_ids(self): - self.ensure_one() - return json.loads(self.tickets or "[]") - - @api.model - def _register_deployment(self, version, tickets=None): - """Record a deployment of a release. - - Called from the migration generated by the project pipeline. The version - is the key of the release: registering the same version twice merges the - tickets instead of creating a second release. + Each deployment sends every ticket of the release, so the last payload + replaces the previous one instead of completing it. """ - version = self._normalize_version(version) + version = (version or "").strip() + if version.lower() == NEXT_VERSION: + version = NEXT_VERSION if not version: - _logger.warning("Release registration without version, ignored") - return self.browse() - ticket_ids = self._normalize_tickets(tickets) - release = self.search([("version", "=", version)]) - if release: - merged = sorted(set(release._get_ticket_ids()) | set(ticket_ids)) - if merged != release._get_ticket_ids(): - release.tickets = json.dumps(merged) - else: - release = self.create( - {"version": version, "tickets": json.dumps(ticket_ids)} - ) + raise ValueError("a release needs a version") + if not isinstance(tickets, list | tuple) or not all( + isinstance(ticket, int) for ticket in tickets + ): + raise ValueError("tickets must be a list of ticket ids") + release = self.search([("version", "=", version)]) or self.create( + {"version": version} + ) + release.tickets = list(tickets) if version != NEXT_VERSION: - release._absorb_next_release() + self.search([("version", "=", NEXT_VERSION)]).unlink() return release - def _absorb_next_release(self): - """Merge the pending 'next' release into this one, then drop it.""" - self.ensure_one() - if self.version == NEXT_VERSION: - return - next_release = self.search([("version", "=", NEXT_VERSION)]) - if not next_release: - return - merged = sorted( - set(next_release._get_ticket_ids()) | set(self._get_ticket_ids()) - ) - self.tickets = json.dumps(merged) - next_release.unlink() - @api.model def _next_release_to_send(self): return self.search([("is_sent", "=", False)], limit=1) @@ -117,7 +75,7 @@ def _build_payload(self): return { "environment": self._get_environment(), "version": self.version, - "tickets": self._get_ticket_ids(), + "tickets": self.tickets or [], } def _push(self): @@ -134,28 +92,5 @@ def _cron_push_releases(self): if not self._get_environment(): return release = self._next_release_to_send() - if not release: - return - try: - with self.env.cr.savepoint(): - release._push() - except Exception: - _logger.exception("Unable to push release %s", release.version) - - def action_push_pending(self): - """Push every pending release, one request per release.""" - if not self._get_environment(): - raise UserError( - _("Releases are only pushed from a preprod or prod environment.") - ) - for _counter in range(100): - release = self._next_release_to_send() - if not release: - break - try: - with self.env.cr.savepoint(): - release._push() - except Exception: - _logger.exception("Unable to push release %s", release.version) - break - return True + if release: + release._push() diff --git a/support_release/readme/USAGE.md b/support_release/readme/USAGE.md index 38b4e53..510364e 100644 --- a/support_release/readme/USAGE.md +++ b/support_release/readme/USAGE.md @@ -19,8 +19,15 @@ The guard on the model presence keeps a database where this module is not installed from failing its module update. Releases are then pushed by the cron *Support Release: push pending releases* -(every 10 minutes, one release per run, oldest first), or immediately with the -*Push pending releases* button. +(every 10 minutes, one release per run, oldest first). + +Every deployment sends the complete list of tickets of the release, so +registering the same version again replaces its tickets instead of completing +them. A call with a version or a ticket list that cannot be used raises: a +broken generated migration must be visible in the log, not silently ignored. + +A release is only marked as sent once the support server accepted it, so a +failed push is retried by the next run. The push only happens when `running_env` is `preprod` or `prod`: it is set by `server_environment` from the `RUNNING_ENV` environment variable, so there is diff --git a/support_release/security/ir.model.access.csv b/support_release/security/ir.model.access.csv index f3ba00d..25455a3 100644 --- a/support_release/security/ir.model.access.csv +++ b/support_release/security/ir.model.access.csv @@ -1,2 +1,2 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink -access_support_release_system,support.release system,model_support_release,base.group_system,1,1,1,1 +access_support_release_system,support.release system,model_support_release,base.group_system,1,1,0,0 diff --git a/support_release/static/description/index.html b/support_release/static/description/index.html index 38bc51e..c3cbf99 100644 --- a/support_release/static/description/index.html +++ b/support_release/static/description/index.html @@ -412,8 +412,14 @@

Usage

The guard on the model presence keeps a database where this module is not installed from failing its module update.

Releases are then pushed by the cron Support Release: push pending -releases (every 10 minutes, one release per run, oldest first), or -immediately with the Push pending releases button.

+releases (every 10 minutes, one release per run, oldest first).

+

Every deployment sends the complete list of tickets of the release, so +registering the same version again replaces its tickets instead of +completing them. A call with a version or a ticket list that cannot be +used raises: a broken generated migration must be visible in the log, +not silently ignored.

+

A release is only marked as sent once the support server accepted it, so +a failed push is retried by the next run.

The push only happens when running_env is preprod or prod: it is set by server_environment from the RUNNING_ENV environment variable, so there is nothing else to configure in the module.

diff --git a/support_release/tests/test_support_release.py b/support_release/tests/test_support_release.py index 1c8f6ba..436a493 100644 --- a/support_release/tests/test_support_release.py +++ b/support_release/tests/test_support_release.py @@ -3,8 +3,9 @@ from unittest.mock import patch +from requests import exceptions as requests_exceptions + from odoo import tools -from odoo.exceptions import UserError from odoo.tests.common import TransactionCase @@ -24,49 +25,52 @@ def _patch_request(self): return patch.object(type(self.Server), "_request") def test_register_next(self): - release = self.Release._register_deployment("next", [42, 12, 42]) + release = self.Release._register_deployment("next", [42, 12]) self.assertEqual(release.version, "next") self.assertFalse(release.is_sent) - self.assertEqual(release._get_ticket_ids(), [12, 42]) + self.assertEqual(release.tickets, [42, 12]) - def test_register_same_version_merges_tickets(self): + def test_register_same_version_replaces_tickets(self): self.Release._register_deployment("next", [1]) release = self.Release._register_deployment("next", [2]) self.assertEqual(self.Release.search_count([("version", "=", "next")]), 1) - self.assertEqual(release._get_ticket_ids(), [1, 2]) + self.assertEqual(release.tickets, [2]) def test_register_same_version_keeps_is_sent(self): release = self.Release._register_deployment("next", [1]) release.is_sent = True release = self.Release._register_deployment("next", [2]) self.assertTrue(release.is_sent) - self.assertEqual(release._get_ticket_ids(), [1, 2]) + self.assertEqual(release.tickets, [2]) - def test_version_absorbs_next(self): + def test_numbered_version_removes_next(self): self.Release._register_deployment("next", [1, 2]) - release = self.Release._register_deployment("14.0.1.0", [2, 3]) + release = self.Release._register_deployment("14.0.1.0", [3]) self.assertFalse(self.Release.search([("version", "=", "next")])) self.assertEqual(release.version, "14.0.1.0") - self.assertEqual(release._get_ticket_ids(), [1, 2, 3]) + self.assertEqual(release.tickets, [3]) - def test_next_recreated_after_absorption(self): + def test_next_recreated_after_removal(self): self.Release._register_deployment("next", [1]) self.Release._register_deployment("14.0.1.0", [1]) release = self.Release._register_deployment("next", [2]) self.assertEqual(release.version, "next") - self.assertEqual(release._get_ticket_ids(), [2]) + self.assertEqual(release.tickets, [2]) def test_next_is_case_insensitive(self): self.Release._register_deployment("Next", [1]) self.assertEqual(self.Release.search_count([("version", "=", "next")]), 1) - def test_empty_version_is_ignored(self): - self.assertFalse(self.Release._register_deployment("", [1])) - self.assertEqual(self.Release.search_count([]), 0) + def test_empty_version_raises(self): + with self.assertRaises(ValueError): + self.Release._register_deployment("", [1]) + self.assertFalse(self.Release.search_count([])) - def test_non_integer_tickets_are_ignored(self): - release = self.Release._register_deployment("next", [1, "abc", None]) - self.assertEqual(release._get_ticket_ids(), [1]) + def test_tickets_must_be_a_list_of_ids(self): + for tickets in ("1, 2", [1, "2"], None): + with self.assertRaises(ValueError): + self.Release._register_deployment("next", tickets) + self.assertFalse(self.Release.search_count([])) def test_environment(self): for environment in ("dev", "ci", "test"): @@ -84,7 +88,7 @@ def test_payload(self): { "environment": "preprod", "version": "14.0.1.0", - "tickets": [1, 3], + "tickets": [3, 1], }, ) @@ -120,24 +124,11 @@ def test_cron_is_silent_without_environment(self): self.Release._cron_push_releases() request.assert_not_called() - def test_failed_push_keeps_release_pending(self): + def test_failed_push_raises_and_keeps_release_pending(self): release = self.Release._register_deployment("14.0.1.0", [1]) with _set_running_env("preprod"), self._patch_request() as request: - request.side_effect = Exception("boom") - self.Release._cron_push_releases() + request.side_effect = requests_exceptions.ConnectionError("boom") + with self.assertRaises(requests_exceptions.ConnectionError): + self.Release._cron_push_releases() self.assertFalse(release.is_sent) self.assertEqual(self.Release._next_release_to_send(), release) - - def test_button_pushes_all_pending(self): - first = self.Release._register_deployment("14.0.1.0", [1]) - second = self.Release._register_deployment("14.0.2.0", [2]) - with _set_running_env("prod"), self._patch_request() as request: - self.Release.action_push_pending() - self.assertEqual(request.call_count, 2) - self.assertTrue(first.is_sent) - self.assertTrue(second.is_sent) - - def test_button_requires_an_environment(self): - with _set_running_env("dev"): - with self.assertRaises(UserError): - self.Release.action_push_pending() diff --git a/support_release/views/support_release_views.xml b/support_release/views/support_release_views.xml index 3c42b90..9b01462 100644 --- a/support_release/views/support_release_views.xml +++ b/support_release/views/support_release_views.xml @@ -17,15 +17,7 @@ support.release -
-
-
+ @@ -60,8 +52,9 @@ diff --git a/support_server_release/README.rst b/support_server_release/README.rst index e90708b..e3156cb 100644 --- a/support_server_release/README.rst +++ b/support_server_release/README.rst @@ -50,15 +50,24 @@ Support Releases*: - the preprod stage, applied when a release is received from preprod; - the prod stage, applied when a release is received from prod. -A ticket is never moved backwards: the stage is only written when the -target stage is further than the current one, comparing their sequences. +The stage of the environment is applied to every ticket of the release, +without comparing it with the current one: the payload describes the +state after the deployment, and a release received out of order corrects +the previous one. If the stage is not linked to the project of a ticket, +it is linked first, otherwise the web client would silently replace it. Once a release has a production deployment date, a payload coming from preprod for the same version is ignored, so a preprod redeployment cannot reopen tickets that are already closed. -The ``next`` release of a client is absorbed by the first numbered -version received for that client, then removed. +The ``next`` release of a client is deleted when the first numbered +version arrives for that client, its tickets being part of the new +payload. + +Whatever could not be applied is written on the release, in the +*Message* field shown when it is not empty: unknown tickets, tickets of +another partner or of a project without partner, no stage configured for +the environment, no user to notify. Nothing is dropped silently. Production notification ----------------------- diff --git a/support_server_release/models/fastapi_endpoint.py b/support_server_release/models/fastapi_endpoint.py index cc1a1d3..f102016 100644 --- a/support_server_release/models/fastapi_endpoint.py +++ b/support_server_release/models/fastapi_endpoint.py @@ -12,5 +12,5 @@ class FastapiEndpoint(models.Model): def _get_fastapi_routers(self): routers = super()._get_fastapi_routers() if self.app == "cross_connect": - routers += [release_router] + routers.append(release_router) return routers diff --git a/support_server_release/models/support_server_release.py b/support_server_release/models/support_server_release.py index cacabd6..0dedb3c 100644 --- a/support_server_release/models/support_server_release.py +++ b/support_server_release/models/support_server_release.py @@ -1,11 +1,8 @@ # Copyright 2026 Akretion (http://www.akretion.com). # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -import logging - -from odoo import api, fields, models - -_logger = logging.getLogger(__name__) +from odoo import _, api, fields, models +from odoo.fields import Command NEXT_VERSION = "next" STAGE_PARAMETER = "support_server_release.stage_%s_id" @@ -21,7 +18,7 @@ class SupportServerRelease(models.Model): _name = "support.server.release" _description = "Support Release" _rec_name = "version" - _order = "version desc, id desc" + _order = "id desc" version = fields.Char( required=True, @@ -49,6 +46,11 @@ class SupportServerRelease(models.Model): ) preprod_deployed_at = fields.Datetime() prod_deployed_at = fields.Datetime() + message = fields.Text( + readonly=True, + copy=False, + help="What could not be applied to this release, to be checked by hand.", + ) _sql_constraints = [ ( @@ -59,25 +61,13 @@ class SupportServerRelease(models.Model): ] @api.model - def _get_tickets(self, ticket_ids): - """Resolve the ticket ids of a payload, ignoring the unknown ones.""" - tasks = self.env["project.task"].browse(ticket_ids).exists() - unknown = sorted(set(ticket_ids) - set(tasks.ids)) - if unknown: - _logger.warning("Ignoring unknown ticket ids %s", unknown) - return tasks - - @api.model - 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, - ) - return partners[:1] + def _get_release(self, client, version): + release = self.search( + [("cross_connect_client_id", "=", client.id), ("version", "=", version)] + ) + if release: + return release + return self.create({"version": version, "cross_connect_client_id": client.id}) @api.model def _get_stage(self, environment): @@ -90,45 +80,57 @@ def _get_stage(self, environment): return self.env["project.task.type"] return self.env["project.task.type"].browse(int(stage_id)).exists() - def _apply_stages(self, stage): - """Move the tickets forward to the stage of the environment, never back.""" - self.ensure_one() - moved = 0 - for task in self.task_ids: - try: - current_stage = task.stage_id - if current_stage and (stage.sequence, stage.id) <= ( - current_stage.sequence, - current_stage.id, - ): - continue - with self.env.cr.savepoint(): - if stage not in task.project_id.type_ids: - task.project_id.sudo().write({"type_ids": [(4, stage.id)]}) - task.sudo().write({"stage_id": stage.id}) - moved += 1 - except Exception: - _logger.exception( - "Unable to move ticket %s to stage %s", task.id, stage.name + def _filter_tickets(self, release, ticket_ids): + """Keep the tickets of the partner of the release, report what is dropped.""" + tasks = self.env["project.task"].browse(ticket_ids).exists() + messages = [] + unknown = sorted(set(ticket_ids) - set(tasks.ids)) + if unknown: + messages.append(_("Unknown tickets: %s", unknown)) + partner = release.partner_id or tasks.project_id.partner_id[:1] + if not partner: + if tasks: + messages.append( + _("No partner on the projects of the tickets, no ticket linked.") ) - return moved + return self.env["project.task"], messages + kept = tasks.filtered(lambda task: task.project_id.partner_id == partner) + dropped = tasks - kept + if dropped: + messages.append( + _("Tickets of another partner, not linked: %s", dropped.ids) + ) + return kept, messages - def _absorb_next_release(self): - """Merge the pending 'next' release of the client, then drop it.""" + def _release_vals(self, environment, tasks, messages): + vals = { + DEPLOYED_FIELDS[environment]: fields.Datetime.now(), + "message": "\n".join(messages) or False, + } + if tasks: + vals["task_ids"] = [Command.link(task.id) for task in tasks] + vals["partner_id"] = tasks.project_id.partner_id[:1].id + return vals + + def _add_message(self, message): self.ensure_one() - next_release = self.search( + self.message = "\n".join(filter(None, [self.message, message])) + + def _apply_stages(self, stage): + """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)]}) + task.sudo().write({"stage_id": stage.id}) + + def _remove_next_release(self): + """Drop the pending 'next' release, the payload already carries its tickets.""" + self.search( [ ("cross_connect_client_id", "=", self.cross_connect_client_id.id), ("version", "=", NEXT_VERSION), ] - ) - if not next_release: - return - if next_release.task_ids: - self.write( - {"task_ids": [(4, task_id) for task_id in next_release.task_ids.ids]} - ) - next_release.unlink() + ).unlink() def _get_task_url(self, task): """URL opening the ticket form in the web client of this database.""" @@ -145,61 +147,30 @@ def _notify_prod_release(self): """Tell the users of the client that the release reached production.""" self.ensure_one() if not self._get_notification_emails(): - _logger.info( - "No user to notify for release %s of %s", - self.version, - self.cross_connect_client_id.name, + self._add_message( + _("No user with an email address to notify the production deployment.") ) return - template = self.env.ref(RELEASE_IN_PROD_TEMPLATE, raise_if_not_found=False) - if template: - template.send_mail(self.id, force_send=False) + self.env.ref(RELEASE_IN_PROD_TEMPLATE).send_mail(self.id, force_send=False) @api.model def _process(self, client, payload): - """Handle a release payload received from a client.""" + """Record a release received from a client and apply it.""" environment = payload["environment"] - version = payload["version"] - release = self.search( - [ - ("cross_connect_client_id", "=", client.id), - ("version", "=", version), - ] - ) + release = self._get_release(client, payload["version"]) if environment == "preprod" and release.prod_deployed_at: - _logger.info( - "Release %s of %s is already in prod, preprod payload ignored", - version, - client.name, - ) return release - tasks = self._get_tickets(payload.get("tickets") or []) - partner = release.partner_id or self._get_partner(tasks) - if partner: - foreign = tasks.filtered(lambda task: task.project_id.partner_id != partner) - if foreign: - _logger.warning( - "Release %s: tickets %s belong to another partner, ignored", - version, - foreign.ids, - ) - tasks -= foreign - if not release: - release = self.create( - {"version": version, "cross_connect_client_id": client.id} - ) - field_name = DEPLOYED_FIELDS[environment] - vals = {field_name: fields.Datetime.now()} - if partner: - vals["partner_id"] = partner.id - if tasks: - vals["task_ids"] = [(4, task_id) for task_id in tasks.ids] - release.write(vals) + tasks, messages = self._filter_tickets(release, payload.get("tickets") or []) + release.write(self._release_vals(environment, tasks, messages)) stage = self._get_stage(environment) if stage: release._apply_stages(stage) - if version != NEXT_VERSION: - release._absorb_next_release() + else: + release._add_message( + _("No stage configured for %s, no ticket moved.", environment) + ) + if payload["version"] != NEXT_VERSION: + release._remove_next_release() if environment == "prod": release._notify_prod_release() return release diff --git a/support_server_release/readme/USAGE.md b/support_server_release/readme/USAGE.md index af25b21..060e846 100644 --- a/support_server_release/readme/USAGE.md +++ b/support_server_release/readme/USAGE.md @@ -4,15 +4,23 @@ Releases*: * the preprod stage, applied when a release is received from preprod; * the prod stage, applied when a release is received from prod. -A ticket is never moved backwards: the stage is only written when the target -stage is further than the current one, comparing their sequences. +The stage of the environment is applied to every ticket of the release, without +comparing it with the current one: the payload describes the state after the +deployment, and a release received out of order corrects the previous one. If +the stage is not linked to the project of a ticket, it is linked first, +otherwise the web client would silently replace it. Once a release has a production deployment date, a payload coming from preprod for the same version is ignored, so a preprod redeployment cannot reopen tickets that are already closed. -The `next` release of a client is absorbed by the first numbered version -received for that client, then removed. +The `next` release of a client is deleted when the first numbered version +arrives for that client, its tickets being part of the new payload. + +Whatever could not be applied is written on the release, in the *Message* field +shown when it is not empty: unknown tickets, tickets of another partner or of a +project without partner, no stage configured for the environment, no user to +notify. Nothing is dropped silently. ## Production notification diff --git a/support_server_release/schemas.py b/support_server_release/schemas.py index 0806071..68e0128 100644 --- a/support_server_release/schemas.py +++ b/support_server_release/schemas.py @@ -8,5 +8,5 @@ class ReleasePayload(BaseModel): environment: Literal["preprod", "prod"] - version: str + version: str = Field(min_length=1) tickets: list[int] = Field(default_factory=list) diff --git a/support_server_release/security/ir.model.access.csv b/support_server_release/security/ir.model.access.csv index cc3338b..c9f4658 100644 --- a/support_server_release/security/ir.model.access.csv +++ b/support_server_release/security/ir.model.access.csv @@ -1,2 +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 +access_support_server_release_user,support.server.release user,model_support_server_release,base.group_user,1,1,0,0 diff --git a/support_server_release/static/description/index.html b/support_server_release/static/description/index.html index b0996aa..fe4dd1c 100644 --- a/support_server_release/static/description/index.html +++ b/support_server_release/static/description/index.html @@ -405,13 +405,21 @@

Usage

  • the preprod stage, applied when a release is received from preprod;
  • the prod stage, applied when a release is received from prod.
  • -

    A ticket is never moved backwards: the stage is only written when the -target stage is further than the current one, comparing their sequences.

    +

    The stage of the environment is applied to every ticket of the release, +without comparing it with the current one: the payload describes the +state after the deployment, and a release received out of order corrects +the previous one. If the stage is not linked to the project of a ticket, +it is linked first, otherwise the web client would silently replace it.

    Once a release has a production deployment date, a payload coming from preprod for the same version is ignored, so a preprod redeployment cannot reopen tickets that are already closed.

    -

    The next release of a client is absorbed by the first numbered -version received for that client, then removed.

    +

    The next release of a client is deleted when the first numbered +version arrives for that client, its tickets being part of the new +payload.

    +

    Whatever could not be applied is written on the release, in the +Message field shown when it is not empty: unknown tickets, tickets of +another partner or of a project without partner, no stage configured for +the environment, no user to notify. Nothing is dropped silently.

    Production notification

    When a release reaches production, the users of its client (the portal diff --git a/support_server_release/tests/test_support_server_release.py b/support_server_release/tests/test_support_server_release.py index 4ef09d1..b1f4da4 100644 --- a/support_server_release/tests/test_support_server_release.py +++ b/support_server_release/tests/test_support_server_release.py @@ -20,7 +20,13 @@ def _setup_release_data(cls): cls.other_project = cls.env["project.project"].create( {"name": "Other project", "partner_id": cls.other_partner.id} ) + cls.project_without_partner = cls.env["project.project"].create( + {"name": "Anonymous project"} + ) projects = (cls.project | cls.other_project).ids + cls.stage_new = cls.env["project.task.type"].create( + {"name": "New", "sequence": 1, "project_ids": [(6, 0, projects)]} + ) cls.stage_preprod = cls.env["project.task.type"].create( { "name": "To release", @@ -96,22 +102,24 @@ def test_preprod_creates_release(self): self.assertEqual(release.task_ids, task) self.assertTrue(release.preprod_deployed_at) self.assertFalse(release.prod_deployed_at) + self.assertFalse(release.message) self.assertEqual(task.stage_id, self.stage_preprod) self.assertEqual(task.release_ids, release) - def test_unknown_ticket_is_ignored(self): + def test_unknown_ticket_is_reported(self): release = self.Release._process( self.client, self._payload("preprod", "next", [99999999]) ) self.assertTrue(release) self.assertFalse(release.task_ids) + self.assertIn("99999999", release.message) - def test_numbered_version_absorbs_next(self): + def test_numbered_version_removes_next(self): first = self._task() second = self._task() self.Release._process(self.client, self._payload("preprod", "next", [first.id])) release = self.Release._process( - self.client, self._payload("preprod", "14.0.1.0", [second.id]) + self.client, self._payload("preprod", "14.0.1.0", [first.id, second.id]) ) self.assertFalse(self.Release.search([("version", "=", "next")])) self.assertEqual(release.version, "14.0.1.0") @@ -136,10 +144,22 @@ def test_preprod_ignored_once_in_prod(self): self.assertFalse(release.preprod_deployed_at) self.assertEqual(task.stage_id, self.stage_prod) - def test_stage_never_goes_back(self): + def test_stage_is_applied_without_condition(self): task = self._task(stage=self.stage_prod) self.Release._process(self.client, self._payload("preprod", "next", [task.id])) - self.assertEqual(task.stage_id, self.stage_prod) + self.assertEqual(task.stage_id, self.stage_preprod) + + def test_stage_missing_from_project_is_attached(self): + stage = self.env["project.task.type"].create( + {"name": "Shipped", "sequence": 30} + ) + self.env["ir.config_parameter"].sudo().set_param( + "support_server_release.stage_preprod_id", stage.id + ) + task = self._task() + self.Release._process(self.client, self._payload("preprod", "next", [task.id])) + self.assertEqual(task.stage_id, stage) + self.assertIn(stage, self.project.type_ids) def test_no_stage_configured(self): self.env["ir.config_parameter"].sudo().set_param( @@ -151,16 +171,28 @@ def test_no_stage_configured(self): ) self.assertTrue(release) self.assertEqual(task.stage_id, self.stage_preprod) + self.assertIn("No stage configured", release.message) - def test_foreign_partner_ticket_is_ignored(self): + def test_foreign_partner_ticket_is_not_linked(self): task = self._task() - foreign = self._task(project=self.other_project) + foreign = self._task(stage=self.stage_new, project=self.other_project) release = self.Release._process( self.client, self._payload("preprod", "next", [task.id, foreign.id]) ) self.assertEqual(release.task_ids, task) self.assertEqual(release.partner_id, self.partner) - self.assertEqual(foreign.stage_id, self.stage_preprod) + self.assertEqual(foreign.stage_id, self.stage_new) + self.assertIn(str(foreign.id), release.message) + + def test_no_partner_links_no_ticket(self): + task = self._task(project=self.project_without_partner) + release = self.Release._process( + self.client, self._payload("preprod", "next", [task.id]) + ) + self.assertFalse(release.task_ids) + self.assertFalse(release.partner_id) + self.assertIn("No partner", release.message) + self.assertEqual(task.stage_id, self.stage_preprod) def test_second_payload_is_idempotent(self): task = self._task() @@ -210,6 +242,7 @@ def test_prod_without_client_user_sends_no_mail(self): ) self.assertTrue(release.prod_deployed_at) self.assertFalse(self.env["mail.mail"].search([])) + self.assertIn("No user with an email", release.message) @tagged("post_install", "-at_install") @@ -284,3 +317,12 @@ def test_post_release_invalid_environment(self): json=self._payload("dev", "next", []), ) self.assertEqual(response.status_code, 422) + + def test_post_release_empty_version(self): + with self._create_test_client(raise_server_exceptions=False) as test_client: + response = test_client.post( + "/cross_connect/release", + headers={"api-key": "release-api-key"}, + json=self._payload("preprod", "", []), + ) + self.assertEqual(response.status_code, 422) diff --git a/support_server_release/views/res_config_settings_views.xml b/support_server_release/views/res_config_settings_views.xml index 720aace..9865983 100644 --- a/support_server_release/views/res_config_settings_views.xml +++ b/support_server_release/views/res_config_settings_views.xml @@ -15,61 +15,22 @@ data-key="support_server_release" groups="base.group_system" > -

    - -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    + + + + + + + + diff --git a/support_server_release/views/support_server_release_views.xml b/support_server_release/views/support_server_release_views.xml index 0467420..b844f06 100644 --- a/support_server_release/views/support_server_release_views.xml +++ b/support_server_release/views/support_server_release_views.xml @@ -5,12 +5,13 @@ support.server.release - + + @@ -35,6 +36,13 @@ + From 2cbeb7e56626f381138c1f519877041bd704a305 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20BEAU?= Date: Mon, 2 Nov 2026 15:21:06 +0000 Subject: [PATCH 05/23] [IMP] support_release, support_server_release: apply the second review 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) --- PLAN-support-release.md | 123 ++++++++++------ support_release/models/support_release.py | 5 +- support_server_release/README.rst | 24 +++- support_server_release/__manifest__.py | 1 + .../models/support_server_release.py | 61 ++++---- support_server_release/readme/USAGE.md | 27 ++-- .../static/description/icon.png | Bin 10254 -> 3347 bytes .../static/description/index.html | 22 ++- .../tests/test_support_server_release.py | 134 ++++++++++-------- 9 files changed, 239 insertions(+), 158 deletions(-) diff --git a/PLAN-support-release.md b/PLAN-support-release.md index 2190edc..a591a3e 100644 --- a/PLAN-support-release.md +++ b/PLAN-support-release.md @@ -120,24 +120,24 @@ Trois règles : ## 5. Décisions -| # | Décision | Raison | -| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| D1 | Deux modules dans akretion/support : `support_release` (client) et `support_server_release` (serveur), avec des modèles aux noms distincts (`support.release` / `support.server.release`). | Réponse utilisateur ; aucun risque de fusion de modèle. | -| D2 | L'information entre par une migration générée qui appelle `env["support.release"]._register_deployment(version=..., tickets=[...])`, sous `if "support.release" not in env`. | Seul mécanisme disponible au moment de la mise à jour, sans git ni réseau ; la garde évite de casser une base où le module manque. | -| D3 | La version est la clé d'idempotence, `next` quand il n'y a pas de numéro ; chaque payload porte la liste complète des tickets, donc la première version numérotée supprime simplement `next` (le serveur fait de même). | Réponse utilisateur ; rejouable, aucun ticket compté deux fois, aucune fusion à maintenir. | -| D4 | Environnement = `tools.config.get("running_env")`, sans paramètre de module ; seuls `preprod` et `prod` déclenchent un envoi. | Réponse utilisateur ; `server_environment` fournit déjà la valeur. | -| D5 | Envoi par cron (10 min), une release par tick, la plus ancienne non envoyée ; `is_sent` posé seulement en cas de succès, jamais invalidé ; un échec remonte dans la cron au lieu d'être avalé. | Réponse utilisateur ; aucun état d'erreur à maintenir, l'exception reste visible. | -| D6 | Transport par `cross.connect.server._request()` sur `/cross_connect/release`, route ajoutée à l'endpoint `api-support` existant. | Infrastructure en place : ni authentification ni endpoint nouveau. | -| D7 | Le serveur ne dépend ni de GitLab ni de `project_customer_access` : manifeste `cross_connect_server` + `project`. | Réponse utilisateur ; le partenaire venant des projets des tickets, plus rien n'a besoin du lien client → projets, et `gitlab_integration` sort de la chaîne de dépendances. | -| D8 | La release n'a pas de `project_id` ; elle porte le `cross_connect_client_id` (issu de la clé d'API) et le `partner_id` **déduit du projet des tickets liés** ; les tickets dont le projet pointe un autre partenaire ne sont pas liés. | Réponse utilisateur : le partenaire vient du projet de la task, pas de la configuration du client. | -| D9 | Deux étapes globales paramétrables (préprod, prod) dans les Réglages, appliquées à chaque réception sans comparaison avec l'étape courante, avec rattachement de l'étape au projet si besoin. | Réponse utilisateur (« on applique la release et on met à jour l'état sans condition »), plus le piège du calcul de `stage_id`. | -| D10 | Préprod ignorée pour une version déjà en prod. | Réponse utilisateur. | -| D11 | Les horodatages de déploiement sont posés par le central à la réception et écrasés à chaque réception. | Le client n'envoie pas de dates, l'heure du central est la seule fiable. | -| D12 | Versions non validées en format ; `next` normalisé en minuscules. | Les versions en usage ont cinq segments. | -| D13 | Pas de backport. | Réponse utilisateur. | -| D14 | Tri des releases du central sur `id desc` : la version est une `Char`, un tri `version desc` placerait `14.0.2610.9.0` avant `14.0.2610.26.0`. | Revue de la PR 84. | -| D15 | Ce qui ne peut pas être appliqué est écrit dans un champ `message` visible sur la release (tickets inconnus, autre partenaire, aucun partenaire, étape non configurée, aucun destinataire) : aucun avertissement silencieux dans les logs. | Revue de la PR 84 (« jamais de choses silencieuses »). | -| D16 | Droits : lecture/écriture pour les internes côté central, lecture/écriture pour `base.group_system` côté client, jamais de création ni de suppression depuis l'interface. | Revue de la PR 84 : seul le code crée les releases, et supprimer effacerait la seule trace du déploiement. | +| # | Décision | Raison | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| D1 | Deux modules dans akretion/support : `support_release` (client) et `support_server_release` (serveur), avec des modèles aux noms distincts (`support.release` / `support.server.release`). | Réponse utilisateur ; aucun risque de fusion de modèle. | +| D2 | L'information entre par une migration générée qui appelle `env["support.release"]._register_deployment(version=..., tickets=[...])`, sous `if "support.release" not in env`. | Seul mécanisme disponible au moment de la mise à jour, sans git ni réseau ; la garde évite de casser une base où le module manque. | +| D3 | La version est la clé d'idempotence, `next` quand il n'y a pas de numéro ; chaque payload porte la liste complète des tickets, donc la première version numérotée supprime simplement `next` (le serveur fait de même). | Réponse utilisateur ; rejouable, aucun ticket compté deux fois, aucune fusion à maintenir. | +| D4 | Environnement = `tools.config.get("running_env")`, sans paramètre de module ; seuls `preprod` et `prod` déclenchent un envoi. | Réponse utilisateur ; `server_environment` fournit déjà la valeur. | +| D5 | Envoi par cron (10 min), une release par tick, la plus ancienne non envoyée ; `is_sent` posé seulement en cas de succès, jamais invalidé ; un échec remonte dans la cron au lieu d'être avalé. | Réponse utilisateur ; aucun état d'erreur à maintenir, l'exception reste visible. | +| D6 | Transport par `cross.connect.server._request()` sur `/cross_connect/release`, route ajoutée à l'endpoint `api-support` existant. | Infrastructure en place : ni authentification ni endpoint nouveau. | +| D7 | Le serveur ne porte aucune logique GitLab, mais dépend de `project_customer_access` pour savoir quels projets appartiennent à un client (`project.project.cross_connect_client_id`). | Revue de la PR 84 (02/11) : le filtrage par projet du client remplace le filtrage par partenaire, qui laissait passer les tickets des projets sans partenaire. `project_customer_access` est déjà installé sur le central. | +| D8 | La release n'a pas de `project_id` ; elle porte le `cross_connect_client_id` (issu de la clé d'API) et le `partner_id` des **projets de ce client** ; un client dont les projets portent plusieurs partenaires fait lever une erreur. Un ticket n'est lié que si son projet appartient au client. | Revue de la PR 84 (02/11) : le client identifie l'émetteur _et_ le propriétaire des tickets ; plusieurs partenaires sur ses projets sont une incohérence de configuration, pas un cas à arbitrer en silence. | +| D9 | Deux étapes globales paramétrables (préprod, prod) dans les Réglages, appliquées à chaque réception sans comparaison avec l'étape courante et sans rattacher l'étape au projet. | Réponse utilisateur (« on applique la release et on met à jour l'état sans condition », puis « pas besoin d'ajouter le stage au projet »). | +| D10 | Tout payload reçu pour une version déjà déployée en prod est ignoré, préprod comme prod : plus de second mail, plus de second déplacement. | Revue de la PR 84 (02/11) : un payload rejoué après un timeout renvoyait un second mail de mise en production. | +| D11 | Les horodatages de déploiement sont posés par le central à la réception et écrasés à chaque réception. | Le client n'envoie pas de dates, l'heure du central est la seule fiable. | +| D12 | Versions non validées en format ; `next` normalisé en minuscules. | Les versions en usage ont cinq segments. | +| D13 | Pas de backport. | Réponse utilisateur. | +| D14 | Tri des releases du central sur `id desc` : la version est une `Char`, un tri `version desc` placerait `14.0.2610.9.0` avant `14.0.2610.26.0`. | Revue de la PR 84. | +| D15 | Ce qui ne peut pas être appliqué est écrit dans un champ `message` visible sur la release (tickets inconnus, autre partenaire, aucun partenaire, étape non configurée, aucun destinataire) : aucun avertissement silencieux dans les logs. | Revue de la PR 84 (« jamais de choses silencieuses »). | +| D16 | Droits : lecture/écriture pour les internes côté central, lecture/écriture pour `base.group_system` côté client, jamais de création ni de suppression depuis l'interface. | Revue de la PR 84 : seul le code crée les releases, et supprimer effacerait la seule trace du déploiement. | --- @@ -251,15 +251,15 @@ tickets. ### `support.server.release` (`_rec_name = "version"`) -| Champ | Type | Rôle | -| ------------------------- | ------------------------------ | --------------------------------------- | -| `version` | Char, requis, indexé | unique par client | -| `cross_connect_client_id` | Many2one, requis, indexé | client propriétaire | -| `partner_id` | Many2one `res.partner`, indexé | `partner_id` du projet des tickets liés | -| `task_ids` | Many2many `project.task` | tickets liés, tous projets confondus | -| `preprod_deployed_at` | Datetime | dernière réception préprod | -| `prod_deployed_at` | Datetime | dernière réception prod | -| `message` | Text, lecture seule | ce qui n'a pas pu être appliqué (D15) | +| Champ | Type | Rôle | +| ------------------------- | ------------------------------ | ------------------------------------- | +| `version` | Char, requis, indexé | unique par client | +| `cross_connect_client_id` | Many2one, requis, indexé | client propriétaire | +| `partner_id` | Many2one `res.partner`, indexé | partenaire des projets du client | +| `task_ids` | Many2many `project.task` | tickets liés, tous projets confondus | +| `preprod_deployed_at` | Datetime | dernière réception préprod | +| `prod_deployed_at` | Datetime | dernière réception prod | +| `message` | Text, lecture seule | ce qui n'a pas pu être appliqué (D15) | Ordre par défaut : `id desc`, donc la dernière release reçue en tête (D14). @@ -281,15 +281,15 @@ Contrainte SQL : unicité (`cross_connect_client_id`, `version`). `project.task` 1. `_get_release(client, version)` : `search` sur (`cross_connect_client_id`, `version`), sinon `create` ; -2. si `environment == "preprod"` et que la release a déjà un `prod_deployed_at`, on - s'arrête là : rien n'est modifié, pas même le champ `message` ; -3. `_filter_tickets(release, ticket_ids)` : résout les ids (`browse().exists()`), met - dans `message` les ids inconnus, garde les tickets dont le projet porte le partenaire - de la release (ou le premier partenaire trouvé si la release n'en a pas encore) et - met dans `message` ceux d'un autre partenaire ; si aucun projet n'a de partenaire, - aucun ticket n'est lié et `message` l'indique ; -4. `_release_vals(...)` : horodatage de l'environnement à `now()` (écrasé), `message`, - `task_ids` liés et `partner_id` ; +2. si la release a déjà un `prod_deployed_at`, on s'arrête là : rien n'est modifié, ni + horodatage, ni tickets, ni étape, ni mail (D10) ; +3. `_filter_tickets(client, ticket_ids)` : résout les ids (`browse().exists()`), met + dans `message` les ids inconnus, garde les tickets dont le projet porte ce + `cross_connect_client_id` et met dans `message` ceux d'un autre client ; +4. `_prepare_release_vals(client, environment, tasks, messages)` : horodatage de + l'environnement à `now()` (écrasé), `message`, `partner_id` des projets du client + (`_get_client_partner`, qui lève si ces projets portent plusieurs partenaires) et + `task_ids` **remplacés** par `Command.set(tasks.ids)` ; 5. `_apply_stages(stage)` puis, si aucune étape n'est configurée, une ligne dans `message` ; 6. `_remove_next_release()` si la version n'est pas `next` ; @@ -300,14 +300,13 @@ Contrainte SQL : unicité (`cross_connect_client_id`, `version`). `project.task` `_apply_stages(stage)` : - cible = `support_server_release.stage_preprod_id` ou `.stage_prod_id` ; -- l'étape est écrite sur **chaque** ticket, sans comparaison avec l'étape courante ; -- si la cible n'est pas dans `task.project_id.type_ids`, elle y est rattachée - (`Command.link`) pour que la colonne existe dans le kanban et que le calcul de - `stage_id` ne la remplace pas ; -- écriture en `sudo()`, une entrée de chatter par ticket (`stage_id` est - `tracking=True`) ; -- aucune gestion d'erreur par ticket : une écriture impossible fait échouer la - réception, ce qui est visible et rejoué par le client. +- l'étape est écrite sur **chaque** ticket, sans comparaison avec l'étape courante, en + une seule écriture (`self.task_ids.sudo().stage_id = stage`) ; +- l'étape n'est pas rattachée au projet : les étapes configurées doivent déjà figurer + dans les projets concernés pour apparaître dans le kanban ; +- une entrée de chatter par ticket (`stage_id` est `tracking=True`) ; +- aucune gestion d'erreur : une écriture impossible fait échouer la réception, ce qui + est visible et rejoué par le client. ### Paramétrage @@ -473,8 +472,8 @@ Deux points d'exploitation découverts à cette occasion, à retenir pour le pil ## 17. Notification e-mail de mise en production Ajout demandé après la première livraison. À la réception d'un payload `prod`, et -seulement après l'absorption de `next` (pour que la liste des tickets soit complète), le -central envoie le template `mail_template_release_in_prod` : +seulement si la release n'a pas encore de `prod_deployed_at` (D10), le central envoie le +template `mail_template_release_in_prod` : - destinataires : les `user_ids` du `cross.connect.client`, c'est-à-dire les utilisateurs portail créés par la connexion croisée, filtrés sur `active` et sur la @@ -544,3 +543,35 @@ nouveaux cas : remplacement des tickets, `ValueError` sur version vide ou ticket invalides, étape appliquée en arrière, étape rattachée au projet, `message` rempli pour un ticket inconnu, un ticket d'un autre partenaire, un projet sans partenaire, une étape non configurée et l'absence de destinataire, `version` vide en 422. + +--- + +## 19. Deuxième revue de la PR 84 (02/11) + +Trois points de `rob-beau`, dont deux confirmés et un laissé en attente d'arbitrage, et +huit directives de `sebastienbeau`. + +| Commentaire | Traitement | +| ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| le central ajoute les tickets, il ne les remplace pas | `Command.set(tasks.ids)` : le payload fait foi, un ticket retiré n'est plus lié, donc plus annoncé dans le mail (D3) | +| le mail de prod repart à chaque payload | garde en tête de `_process` sur `release.prod_deployed_at`, quelle que soit l'environnement (D10) ; test avec deux payloads prod | +| les destinataires du mail (opt-in) | rien de changé : arbitrage en cours côté utilisateur, le comportement actuel est documenté en §17 | +| `_order` du client : `id desc` | appliqué ; `_next_release_to_send()` garde un `order="id"` explicite pour continuer à envoyer la plus ancienne | +| projets du client via `cross_connect_client_id`, puis le partenaire | `_get_client_partner()` lit `client.project_ids.partner_id` et lève si plusieurs partenaires | +| plusieurs `partner_id` → erreur | `ValidationError`, donc réception en échec et cron cliente en échec, comme demandé | +| filtrer sur les tickets qui existent et qui sont du bon client | `_filter_tickets(client, ...)` garde les tickets dont le projet porte ce `cross_connect_client_id`, les autres vont dans `message` | +| écraser les `task_ids` | `Command.set`, y compris pour vider la liste | +| pas besoin d'ajouter le stage au projet | bloc `type_ids` supprimé ; `_apply_stages` devient une seule écriture | +| nommer la méthode `_prepare_release_vals` | renommée | +| `release.task_ids.stage_id = stage` | appliqué (`self.task_ids.sudo().stage_id = stage`) | + +Le manifeste gagne `project_customer_access`, seul propriétaire de +`project.project.cross_connect_client_id` : c'est un renversement de D7, assumé, puisque +le filtrage par projet du client remplace le filtrage par partenaire (D7 et D8 amendés). + +**Vérifié** : 34 tests verts sur le banc, cette fois avec `project_customer_access` et +sa chaîne réellement installées (564 modules disponibles, dont `gitlab_integration`), +donc sur le vrai champ `cross_connect_client_id`. Les cas ajoutés : payload qui rétrécit +et détache le ticket retiré, ticket d'un autre client non lié et signalé, projet sans +partenaire toujours lié, deux partenaires sur les projets du client qui lèvent, deux +payloads `prod` qui ne produisent qu'un `mail.mail`. diff --git a/support_release/models/support_release.py b/support_release/models/support_release.py index 1c0e92a..ce496c8 100644 --- a/support_release/models/support_release.py +++ b/support_release/models/support_release.py @@ -12,7 +12,7 @@ class SupportRelease(models.Model): _name = "support.release" _description = "Support Release" _rec_name = "version" - _order = "create_date, id" + _order = "id desc" version = fields.Char( required=True, @@ -68,7 +68,8 @@ def _register_deployment(self, version, tickets): @api.model def _next_release_to_send(self): - return self.search([("is_sent", "=", False)], limit=1) + """Oldest pending release first, whatever the default order is.""" + return self.search([("is_sent", "=", False)], order="id", limit=1) def _build_payload(self): self.ensure_one() diff --git a/support_server_release/README.rst b/support_server_release/README.rst index e3156cb..6fbbce3 100644 --- a/support_server_release/README.rst +++ b/support_server_release/README.rst @@ -53,12 +53,22 @@ Support Releases*: The stage of the environment is applied to every ticket of the release, without comparing it with the current one: the payload describes the state after the deployment, and a release received out of order corrects -the previous one. If the stage is not linked to the project of a ticket, -it is linked first, otherwise the web client would silently replace it. +the previous one. The stage is not added to the projects: the two +configured stages have to be linked to the projects of the client to +appear in their kanban. -Once a release has a production deployment date, a payload coming from -preprod for the same version is ignored, so a preprod redeployment -cannot reopen tickets that are already closed. +A client owns the projects whose *Cross Connect Client* is set to it. +The partner of the release is the partner of those projects; if they +carry several partners, the module raises and the reception fails, since +the client must be one customer. A ticket is linked only when its +project belongs to the client sending the payload. + +Each payload replaces the tickets of the release: a ticket removed from +the release is unlinked, so the production mail never announces it. + +Once a release has a production deployment date, any further payload for +that version is ignored, whether it comes from preprod or from prod: a +timeout that replays the payload cannot send the mail twice. The ``next`` release of a client is deleted when the first numbered version arrives for that client, its tickets being part of the new @@ -66,8 +76,8 @@ payload. Whatever could not be applied is written on the release, in the *Message* field shown when it is not empty: unknown tickets, tickets of -another partner or of a project without partner, no stage configured for -the environment, no user to notify. Nothing is dropped silently. +a project of another client, no stage configured for the environment, no +user to notify. Nothing is dropped silently. Production notification ----------------------- diff --git a/support_server_release/__manifest__.py b/support_server_release/__manifest__.py index 0bdb5e0..024bc02 100644 --- a/support_server_release/__manifest__.py +++ b/support_server_release/__manifest__.py @@ -12,6 +12,7 @@ "depends": [ "cross_connect_server", "project", + "project_customer_access", ], "data": [ "security/ir.model.access.csv", diff --git a/support_server_release/models/support_server_release.py b/support_server_release/models/support_server_release.py index 0dedb3c..4c6b192 100644 --- a/support_server_release/models/support_server_release.py +++ b/support_server_release/models/support_server_release.py @@ -2,6 +2,7 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). from odoo import _, api, fields, models +from odoo.exceptions import ValidationError from odoo.fields import Command NEXT_VERSION = "next" @@ -35,7 +36,7 @@ class SupportServerRelease(models.Model): partner_id = fields.Many2one( "res.partner", index=True, - help="Partner of the projects of the tickets of this release.", + help="Partner of the projects of the client.", ) task_ids = fields.Many2many( "project.task", @@ -80,37 +81,44 @@ def _get_stage(self, environment): return self.env["project.task.type"] return self.env["project.task.type"].browse(int(stage_id)).exists() - def _filter_tickets(self, release, ticket_ids): - """Keep the tickets of the partner of the release, report what is dropped.""" + @api.model + def _get_client_partner(self, client): + """Partner of the projects of the client, which has to be unique.""" + partners = client.project_ids.partner_id + if len(partners) > 1: + raise ValidationError( + _( + "The projects of %(client)s belong to several partners: " + "%(partners)s", + client=client.name, + partners=", ".join(partners.mapped("name")), + ) + ) + return partners + + @api.model + def _filter_tickets(self, client, ticket_ids): + """Keep the tickets of the projects of the client, report the others.""" tasks = self.env["project.task"].browse(ticket_ids).exists() messages = [] unknown = sorted(set(ticket_ids) - set(tasks.ids)) if unknown: messages.append(_("Unknown tickets: %s", unknown)) - partner = release.partner_id or tasks.project_id.partner_id[:1] - if not partner: - if tasks: - messages.append( - _("No partner on the projects of the tickets, no ticket linked.") - ) - return self.env["project.task"], messages - kept = tasks.filtered(lambda task: task.project_id.partner_id == partner) + kept = tasks.filtered( + lambda task: task.project_id.cross_connect_client_id == client + ) dropped = tasks - kept if dropped: - messages.append( - _("Tickets of another partner, not linked: %s", dropped.ids) - ) + messages.append(_("Tickets of another client, not linked: %s", dropped.ids)) return kept, messages - def _release_vals(self, environment, tasks, messages): - vals = { + def _prepare_release_vals(self, client, environment, tasks, messages): + return { DEPLOYED_FIELDS[environment]: fields.Datetime.now(), "message": "\n".join(messages) or False, + "partner_id": self._get_client_partner(client).id, + "task_ids": [Command.set(tasks.ids)], } - if tasks: - vals["task_ids"] = [Command.link(task.id) for task in tasks] - vals["partner_id"] = tasks.project_id.partner_id[:1].id - return vals def _add_message(self, message): self.ensure_one() @@ -118,10 +126,7 @@ def _add_message(self, message): def _apply_stages(self, stage): """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)]}) - task.sudo().write({"stage_id": stage.id}) + self.task_ids.sudo().stage_id = stage def _remove_next_release(self): """Drop the pending 'next' release, the payload already carries its tickets.""" @@ -156,12 +161,12 @@ def _notify_prod_release(self): @api.model def _process(self, client, payload): """Record a release received from a client and apply it.""" - environment = payload["environment"] release = self._get_release(client, payload["version"]) - if environment == "preprod" and release.prod_deployed_at: + if release.prod_deployed_at: return release - tasks, messages = self._filter_tickets(release, payload.get("tickets") or []) - release.write(self._release_vals(environment, tasks, messages)) + environment = payload["environment"] + tasks, messages = self._filter_tickets(client, payload.get("tickets") or []) + release.write(self._prepare_release_vals(client, environment, tasks, messages)) stage = self._get_stage(environment) if stage: release._apply_stages(stage) diff --git a/support_server_release/readme/USAGE.md b/support_server_release/readme/USAGE.md index 060e846..f88df12 100644 --- a/support_server_release/readme/USAGE.md +++ b/support_server_release/readme/USAGE.md @@ -6,21 +6,30 @@ Releases*: The stage of the environment is applied to every ticket of the release, without comparing it with the current one: the payload describes the state after the -deployment, and a release received out of order corrects the previous one. If -the stage is not linked to the project of a ticket, it is linked first, -otherwise the web client would silently replace it. +deployment, and a release received out of order corrects the previous one. The +stage is not added to the projects: the two configured stages have to be linked +to the projects of the client to appear in their kanban. -Once a release has a production deployment date, a payload coming from preprod -for the same version is ignored, so a preprod redeployment cannot reopen -tickets that are already closed. +A client owns the projects whose *Cross Connect Client* is set to it. The +partner of the release is the partner of those projects; if they carry several +partners, the module raises and the reception fails, since the client must be +one customer. A ticket is linked only when its project belongs to the client +sending the payload. + +Each payload replaces the tickets of the release: a ticket removed from the +release is unlinked, so the production mail never announces it. + +Once a release has a production deployment date, any further payload for that +version is ignored, whether it comes from preprod or from prod: a timeout that +replays the payload cannot send the mail twice. The `next` release of a client is deleted when the first numbered version arrives for that client, its tickets being part of the new payload. Whatever could not be applied is written on the release, in the *Message* field -shown when it is not empty: unknown tickets, tickets of another partner or of a -project without partner, no stage configured for the environment, no user to -notify. Nothing is dropped silently. +shown when it is not empty: unknown tickets, tickets of a project of another +client, no stage configured for the environment, no user to notify. Nothing is +dropped silently. ## Production notification diff --git a/support_server_release/static/description/icon.png b/support_server_release/static/description/icon.png index 1dcc49c24f364e9adf0afbc6fc0bac6dbecdeb11..b8a639ddf77d8aac1b56253147843211ce3b3148 100644 GIT binary patch literal 3347 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 K#x;h}Xa4~yh)Bf% literal 10254 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

    The stage of the environment is applied to every ticket of the release, without comparing it with the current one: the payload describes the state after the deployment, and a release received out of order corrects -the previous one. If the stage is not linked to the project of a ticket, -it is linked first, otherwise the web client would silently replace it.

    -

    Once a release has a production deployment date, a payload coming from -preprod for the same version is ignored, so a preprod redeployment -cannot reopen tickets that are already closed.

    +the previous one. The stage is not added to the projects: the two +configured stages have to be linked to the projects of the client to +appear in their kanban.

    +

    A client owns the projects whose Cross Connect Client is set to it. +The partner of the release is the partner of those projects; if they +carry several partners, the module raises and the reception fails, since +the client must be one customer. A ticket is linked only when its +project belongs to the client sending the payload.

    +

    Each payload replaces the tickets of the release: a ticket removed from +the release is unlinked, so the production mail never announces it.

    +

    Once a release has a production deployment date, any further payload for +that version is ignored, whether it comes from preprod or from prod: a +timeout that replays the payload cannot send the mail twice.

    The next release of a client is deleted when the first numbered version arrives for that client, its tickets being part of the new payload.

    Whatever could not be applied is written on the release, in the Message field shown when it is not empty: unknown tickets, tickets of -another partner or of a project without partner, no stage configured for -the environment, no user to notify. Nothing is dropped silently.

    +a project of another client, no stage configured for the environment, no +user to notify. Nothing is dropped silently.

    Production notification

    When a release reaches production, the users of its client (the portal diff --git a/support_server_release/tests/test_support_server_release.py b/support_server_release/tests/test_support_server_release.py index b1f4da4..9490812 100644 --- a/support_server_release/tests/test_support_server_release.py +++ b/support_server_release/tests/test_support_server_release.py @@ -1,6 +1,7 @@ # Copyright 2026 Akretion (http://www.akretion.com). # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from odoo.exceptions import ValidationError from odoo.tests.common import TransactionCase, tagged from odoo.addons.extendable_fastapi.tests.common import FastAPITransactionCase @@ -14,32 +15,49 @@ def _setup_release_data(cls): cls.Release = cls.env["support.server.release"] cls.partner = cls.env["res.partner"].create({"name": "Customer"}) cls.other_partner = cls.env["res.partner"].create({"name": "Other customer"}) + cls.endpoint = cls.env["fastapi.endpoint"].create( + {"name": "Support endpoint", "root_path": "/api", "app": "cross_connect"} + ) + cls.client = cls.env["cross.connect.client"].create( + { + "name": "Test client", + "endpoint_id": cls.endpoint.id, + "api_key": "release-api-key", + } + ) + cls.other_client = cls.env["cross.connect.client"].create( + { + "name": "Other client", + "endpoint_id": cls.endpoint.id, + "api_key": "other-api-key", + } + ) cls.project = cls.env["project.project"].create( - {"name": "Ticket project", "partner_id": cls.partner.id} + { + "name": "Ticket project", + "partner_id": cls.partner.id, + "cross_connect_client_id": cls.client.id, + } ) cls.other_project = cls.env["project.project"].create( - {"name": "Other project", "partner_id": cls.other_partner.id} + { + "name": "Other project", + "partner_id": cls.other_partner.id, + "cross_connect_client_id": cls.other_client.id, + } ) - cls.project_without_partner = cls.env["project.project"].create( - {"name": "Anonymous project"} + cls.anonymous_project = cls.env["project.project"].create( + {"name": "Anonymous project", "cross_connect_client_id": cls.client.id} ) - projects = (cls.project | cls.other_project).ids + projects = (cls.project | cls.other_project | cls.anonymous_project).ids cls.stage_new = cls.env["project.task.type"].create( {"name": "New", "sequence": 1, "project_ids": [(6, 0, projects)]} ) cls.stage_preprod = cls.env["project.task.type"].create( - { - "name": "To release", - "sequence": 10, - "project_ids": [(6, 0, projects)], - } + {"name": "To release", "sequence": 10, "project_ids": [(6, 0, projects)]} ) cls.stage_prod = cls.env["project.task.type"].create( - { - "name": "Closed", - "sequence": 20, - "project_ids": [(6, 0, projects)], - } + {"name": "Closed", "sequence": 20, "project_ids": [(6, 0, projects)]} ) cls.env["ir.config_parameter"].sudo().set_param( "support_server_release.stage_preprod_id", cls.stage_preprod.id @@ -80,16 +98,6 @@ class TestSupportServerRelease(ReleaseCaseMixin, TransactionCase): def setUpClass(cls): super().setUpClass() cls._setup_release_data() - cls.endpoint = cls.env["fastapi.endpoint"].create( - {"name": "Support endpoint", "root_path": "/api", "app": "cross_connect"} - ) - cls.client = cls.env["cross.connect.client"].create( - { - "name": "Test client", - "endpoint_id": cls.endpoint.id, - "api_key": "release-api-key", - } - ) def test_preprod_creates_release(self): task = self._task() @@ -135,7 +143,7 @@ def test_prod_applies_prod_stage(self): self.assertFalse(release.preprod_deployed_at) self.assertEqual(task.stage_id, self.stage_prod) - def test_preprod_ignored_once_in_prod(self): + def test_payload_after_prod_is_ignored(self): task = self._task() self.Release._process(self.client, self._payload("prod", "14.0.1.0", [task.id])) release = self.Release._process( @@ -149,18 +157,6 @@ def test_stage_is_applied_without_condition(self): self.Release._process(self.client, self._payload("preprod", "next", [task.id])) self.assertEqual(task.stage_id, self.stage_preprod) - def test_stage_missing_from_project_is_attached(self): - stage = self.env["project.task.type"].create( - {"name": "Shipped", "sequence": 30} - ) - self.env["ir.config_parameter"].sudo().set_param( - "support_server_release.stage_preprod_id", stage.id - ) - task = self._task() - self.Release._process(self.client, self._payload("preprod", "next", [task.id])) - self.assertEqual(task.stage_id, stage) - self.assertIn(stage, self.project.type_ids) - def test_no_stage_configured(self): self.env["ir.config_parameter"].sudo().set_param( "support_server_release.stage_preprod_id", False @@ -173,7 +169,7 @@ def test_no_stage_configured(self): self.assertEqual(task.stage_id, self.stage_preprod) self.assertIn("No stage configured", release.message) - def test_foreign_partner_ticket_is_not_linked(self): + def test_ticket_of_another_client_is_not_linked(self): task = self._task() foreign = self._task(stage=self.stage_new, project=self.other_project) release = self.Release._process( @@ -184,15 +180,40 @@ def test_foreign_partner_ticket_is_not_linked(self): self.assertEqual(foreign.stage_id, self.stage_new) self.assertIn(str(foreign.id), release.message) - def test_no_partner_links_no_ticket(self): - task = self._task(project=self.project_without_partner) + def test_project_without_partner_is_still_linked(self): + task = self._task(project=self.anonymous_project) release = self.Release._process( self.client, self._payload("preprod", "next", [task.id]) ) - self.assertFalse(release.task_ids) - self.assertFalse(release.partner_id) - self.assertIn("No partner", release.message) - self.assertEqual(task.stage_id, self.stage_preprod) + self.assertEqual(release.task_ids, task) + self.assertEqual(release.partner_id, self.partner) + + def test_several_partners_raise(self): + self.env["project.project"].create( + { + "name": "Second project of the client", + "partner_id": self.other_partner.id, + "cross_connect_client_id": self.client.id, + } + ) + task = self._task() + with self.assertRaises(ValidationError): + self.Release._process( + self.client, self._payload("preprod", "next", [task.id]) + ) + self.assertFalse(self.Release.search([])) + + def test_payload_replaces_tickets(self): + first = self._task() + second = self._task() + self.Release._process( + self.client, self._payload("preprod", "14.0.1.0", [first.id, second.id]) + ) + release = self.Release._process( + self.client, self._payload("preprod", "14.0.1.0", [first.id]) + ) + self.assertEqual(release.task_ids, first) + self.assertFalse(second.release_ids) def test_second_payload_is_idempotent(self): task = self._task() @@ -227,6 +248,15 @@ def test_prod_notifies_the_client_users(self): f"/odoo/action-project.action_view_task/{task.id}", mail.body_html ) + def test_prod_mail_is_sent_once(self): + task = self._task() + self._client_user("customer@example.com") + self.env["mail.mail"].search([]).unlink() + payload = self._payload("prod", "14.0.1.0", [task.id]) + self.Release._process(self.client, dict(payload)) + self.Release._process(self.client, dict(payload)) + self.assertEqual(len(self.env["mail.mail"].search([])), 1) + def test_preprod_sends_no_mail(self): task = self._task() self._client_user("customer@example.com") @@ -251,20 +281,6 @@ class TestSupportServerReleaseRouter(ReleaseCaseMixin, FastAPITransactionCase): def setUpClass(cls): super().setUpClass() cls._setup_release_data() - cls.endpoint = cls.env["fastapi.endpoint"].create( - { - "name": "Support Release Endpoint", - "root_path": "/api", - "app": "cross_connect", - } - ) - cls.client = cls.env["cross.connect.client"].create( - { - "name": "Test Client", - "endpoint_id": cls.endpoint.id, - "api_key": "release-api-key", - } - ) cls.endpoint_user = cls.env["res.users"].create( { "name": "FastAPI Endpoint User", From a0a3805a8e7d49ff080ed077377ef157b07ee80e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20BEAU?= Date: Mon, 2 Nov 2026 15:21:16 +0000 Subject: [PATCH 06/23] [IMP] support_release: use the Akretion icon Same icon as support_server_release: the repository is an Akretion one, the OCA icon was a mistake. Assisted-by: Hermes Agent (Nous Research) --- support_release/static/description/icon.png | Bin 10254 -> 3347 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/support_release/static/description/icon.png b/support_release/static/description/icon.png index 1dcc49c24f364e9adf0afbc6fc0bac6dbecdeb11..b8a639ddf77d8aac1b56253147843211ce3b3148 100644 GIT binary patch literal 3347 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 K#x;h}Xa4~yh)Bf% literal 10254 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 Date: Mon, 2 Nov 2026 16:53:51 +0000 Subject: [PATCH 07/23] [IMP] support_release, support_server_release: apply the third review 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) --- PLAN-support-release.md | 66 ++++++++++++------- support_release/README.rst | 3 +- support_release/models/support_release.py | 9 +-- support_release/readme/USAGE.md | 3 +- support_release/static/description/index.html | 3 +- support_release/tests/test_support_release.py | 17 ++--- support_server_release/README.rst | 19 ++++-- support_server_release/__manifest__.py | 1 + support_server_release/models/__init__.py | 1 + support_server_release/models/res_partner.py | 18 +++++ .../models/support_server_release.py | 37 +++++++---- support_server_release/readme/USAGE.md | 16 +++-- .../static/description/index.html | 19 ++++-- .../tests/test_support_server_release.py | 46 +++++++++---- .../views/res_partner_views.xml | 19 ++++++ 15 files changed, 185 insertions(+), 92 deletions(-) create mode 100644 support_server_release/models/res_partner.py create mode 100644 support_server_release/views/res_partner_views.xml diff --git a/PLAN-support-release.md b/PLAN-support-release.md index a591a3e..3b1d813 100644 --- a/PLAN-support-release.md +++ b/PLAN-support-release.md @@ -120,24 +120,24 @@ Trois règles : ## 5. Décisions -| # | Décision | Raison | -| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| D1 | Deux modules dans akretion/support : `support_release` (client) et `support_server_release` (serveur), avec des modèles aux noms distincts (`support.release` / `support.server.release`). | Réponse utilisateur ; aucun risque de fusion de modèle. | -| D2 | L'information entre par une migration générée qui appelle `env["support.release"]._register_deployment(version=..., tickets=[...])`, sous `if "support.release" not in env`. | Seul mécanisme disponible au moment de la mise à jour, sans git ni réseau ; la garde évite de casser une base où le module manque. | -| D3 | La version est la clé d'idempotence, `next` quand il n'y a pas de numéro ; chaque payload porte la liste complète des tickets, donc la première version numérotée supprime simplement `next` (le serveur fait de même). | Réponse utilisateur ; rejouable, aucun ticket compté deux fois, aucune fusion à maintenir. | -| D4 | Environnement = `tools.config.get("running_env")`, sans paramètre de module ; seuls `preprod` et `prod` déclenchent un envoi. | Réponse utilisateur ; `server_environment` fournit déjà la valeur. | -| D5 | Envoi par cron (10 min), une release par tick, la plus ancienne non envoyée ; `is_sent` posé seulement en cas de succès, jamais invalidé ; un échec remonte dans la cron au lieu d'être avalé. | Réponse utilisateur ; aucun état d'erreur à maintenir, l'exception reste visible. | -| D6 | Transport par `cross.connect.server._request()` sur `/cross_connect/release`, route ajoutée à l'endpoint `api-support` existant. | Infrastructure en place : ni authentification ni endpoint nouveau. | -| D7 | Le serveur ne porte aucune logique GitLab, mais dépend de `project_customer_access` pour savoir quels projets appartiennent à un client (`project.project.cross_connect_client_id`). | Revue de la PR 84 (02/11) : le filtrage par projet du client remplace le filtrage par partenaire, qui laissait passer les tickets des projets sans partenaire. `project_customer_access` est déjà installé sur le central. | -| D8 | La release n'a pas de `project_id` ; elle porte le `cross_connect_client_id` (issu de la clé d'API) et le `partner_id` des **projets de ce client** ; un client dont les projets portent plusieurs partenaires fait lever une erreur. Un ticket n'est lié que si son projet appartient au client. | Revue de la PR 84 (02/11) : le client identifie l'émetteur _et_ le propriétaire des tickets ; plusieurs partenaires sur ses projets sont une incohérence de configuration, pas un cas à arbitrer en silence. | -| D9 | Deux étapes globales paramétrables (préprod, prod) dans les Réglages, appliquées à chaque réception sans comparaison avec l'étape courante et sans rattacher l'étape au projet. | Réponse utilisateur (« on applique la release et on met à jour l'état sans condition », puis « pas besoin d'ajouter le stage au projet »). | -| D10 | Tout payload reçu pour une version déjà déployée en prod est ignoré, préprod comme prod : plus de second mail, plus de second déplacement. | Revue de la PR 84 (02/11) : un payload rejoué après un timeout renvoyait un second mail de mise en production. | -| D11 | Les horodatages de déploiement sont posés par le central à la réception et écrasés à chaque réception. | Le client n'envoie pas de dates, l'heure du central est la seule fiable. | -| D12 | Versions non validées en format ; `next` normalisé en minuscules. | Les versions en usage ont cinq segments. | -| D13 | Pas de backport. | Réponse utilisateur. | -| D14 | Tri des releases du central sur `id desc` : la version est une `Char`, un tri `version desc` placerait `14.0.2610.9.0` avant `14.0.2610.26.0`. | Revue de la PR 84. | -| D15 | Ce qui ne peut pas être appliqué est écrit dans un champ `message` visible sur la release (tickets inconnus, autre partenaire, aucun partenaire, étape non configurée, aucun destinataire) : aucun avertissement silencieux dans les logs. | Revue de la PR 84 (« jamais de choses silencieuses »). | -| D16 | Droits : lecture/écriture pour les internes côté central, lecture/écriture pour `base.group_system` côté client, jamais de création ni de suppression depuis l'interface. | Revue de la PR 84 : seul le code crée les releases, et supprimer effacerait la seule trace du déploiement. | +| # | Décision | Raison | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| D1 | Deux modules dans akretion/support : `support_release` (client) et `support_server_release` (serveur), avec des modèles aux noms distincts (`support.release` / `support.server.release`). | Réponse utilisateur ; aucun risque de fusion de modèle. | +| D2 | L'information entre par une migration générée qui appelle `env["support.release"]._register_deployment(version=..., tickets=[...])`, sous `if "support.release" not in env`. | Seul mécanisme disponible au moment de la mise à jour, sans git ni réseau ; la garde évite de casser une base où le module manque. | +| D3 | La version est la clé d'idempotence, `next` quand il n'y a pas de numéro ; chaque payload porte la liste complète des tickets, donc la première version numérotée supprime simplement `next` (le serveur fait de même). | Réponse utilisateur ; rejouable, aucun ticket compté deux fois, aucune fusion à maintenir. | +| D4 | Environnement = `tools.config.get("running_env")`, sans paramètre de module ; seuls `preprod` et `prod` déclenchent un envoi. | Réponse utilisateur ; `server_environment` fournit déjà la valeur. | +| D5 | Envoi par cron (10 min), une release par tick, dans l'ordre par défaut du modèle ; `is_sent` posé seulement en cas de succès, jamais invalidé ; un échec remonte dans la cron au lieu d'être avalé. | Réponse utilisateur ; aucun état d'erreur à maintenir, l'exception reste visible. | +| D6 | Transport par `cross.connect.server._request()` sur `/cross_connect/release`, route ajoutée à l'endpoint `api-support` existant. | Infrastructure en place : ni authentification ni endpoint nouveau. | +| D7 | Le serveur ne porte aucune logique GitLab, mais dépend de `project_customer_access` pour savoir quels projets appartiennent à un client (`project.project.cross_connect_client_id`). | Revue de la PR 84 (02/11) : le filtrage par projet du client remplace le filtrage par partenaire, qui laissait passer les tickets des projets sans partenaire. `project_customer_access` est déjà installé sur le central. | +| D8 | La release n'a pas de `project_id` ; elle porte le `cross_connect_client_id` (issu de la clé d'API) et le `partner_id` des **projets de ce client** ; un client dont les projets portent plusieurs partenaires fait lever une erreur. Un ticket est lié si son projet porte ce partenaire, ce qui inclut les projets internes sans cross connect. | Revue de la PR 84 (02/11, puis 16h) : le client identifie l'émetteur, le partenaire identifie le propriétaire des tickets ; plusieurs partenaires sur les projets du client sont une incohérence de configuration. | +| D9 | Deux étapes globales paramétrables (préprod, prod) dans les Réglages, appliquées à chaque réception sans comparaison avec l'étape courante et sans rattacher l'étape au projet. | Réponse utilisateur (« on applique la release et on met à jour l'état sans condition », puis « pas besoin d'ajouter le stage au projet »). | +| D10 | Tout payload reçu pour une version déjà déployée en prod est ignoré, préprod comme prod : plus de second mail, plus de second déplacement. | Revue de la PR 84 (02/11) : un payload rejoué après un timeout renvoyait un second mail de mise en production. | +| D11 | Les horodatages de déploiement sont posés par le central à la réception et écrasés à chaque réception. | Le client n'envoie pas de dates, l'heure du central est la seule fiable. | +| D12 | Versions non validées en format ; `next` normalisé en minuscules. | Les versions en usage ont cinq segments. | +| D13 | Pas de backport. | Réponse utilisateur. | +| D14 | Tri des releases du central sur `id desc` : la version est une `Char`, un tri `version desc` placerait `14.0.2610.9.0` avant `14.0.2610.26.0`. | Revue de la PR 84. | +| D15 | Ce qui ne peut pas être appliqué est écrit dans un champ `message` visible sur la release (tickets inconnus, autre partenaire, aucun partenaire, étape non configurée, aucun destinataire) : aucun avertissement silencieux dans les logs. | Revue de la PR 84 (« jamais de choses silencieuses »). | +| D16 | Droits : lecture/écriture pour les internes côté central, lecture/écriture pour `base.group_system` côté client, jamais de création ni de suppression depuis l'interface. | Revue de la PR 84 : seul le code crée les releases, et supprimer effacerait la seule trace du déploiement. | --- @@ -357,8 +357,9 @@ d'accès. 3. le `cross.connect.server` de support est déjà configuré par `support_akretion`. Côté erp-fr : installer `support_server_release`, créer le `cross.connect.client` du -client s'il n'existe pas, configurer les deux étapes. Rien à configurer sur les projets -: le partenaire vient du projet du ticket. +client s'il n'existe pas, renseigner son `cross_connect_client_id` sur les projets du +client, configurer les deux étapes et la liste _Release notification users_ sur le +partenaire. --- @@ -475,10 +476,10 @@ Ajout demandé après la première livraison. À la réception d'un payload `pro seulement si la release n'a pas encore de `prod_deployed_at` (D10), le central envoie le template `mail_template_release_in_prod` : -- destinataires : les `user_ids` du `cross.connect.client`, c'est-à-dire les - utilisateurs portail créés par la connexion croisée, filtrés sur `active` et sur la - présence d'un `email`, dédoublonnés. Aucun champ ajouté au modèle, aucun nouveau - réglage : une liste vide signifie aucun envoi, avec une ligne de log ; +- destinataires : le champ `support_release_notification_user_ids` du partenaire de la + release, une liste de `res.users` tenue à la main sur la fiche client, filtrée sur + `active` et sur la présence d'un `email`, dédoublonnée. Une liste vide signifie aucun + envoi, avec une ligne dans `message` ; - contenu : les titres des tickets, chacun pointant vers `/odoo/action-project.action_view_task/` construit depuis `get_base_url()`, et la description du ticket repliée dans un `

    ` ; @@ -575,3 +576,20 @@ donc sur le vrai champ `cross_connect_client_id`. Les cas ajoutés : payload qui et détache le ticket retiré, ticket d'un autre client non lié et signalé, projet sans partenaire toujours lié, deux partenaires sur les projets du client qui lèvent, deux payloads `prod` qui ne produisent qu'un `mail.mail`. + +--- + +## 20. Troisième revue de la PR 84 (02/11, 16 h) + +| Commentaire | Traitement | +| -------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `_next_release_to_send()` ne fait qu'une ligne, appel direct, sans `order` | méthode supprimée, la cron fait `self.search([("is_sent", "=", False)], limit=1)` ; l'ordre est celui du modèle, donc la plus récente d'abord (D5 amendé) | +| un projet sans `cross_connect_client_id` mais avec le même partenaire | le filtrage se fait désormais sur le **partenaire**, pas sur le client : les tickets des projets internes de maintenance sont liés, et donc envoyés dans le mail | +| un ticket d'un autre partenaire | il reste à son étape et son id est écrit dans `message` | +| les destinataires du mail | nouveau champ `support_release_notification_user_ids` (`res.users`) sur `res.partner`, éditable dans l'onglet _Notes internes_ de la fiche client ; `_get_notification_emails()` le lit via le partenaire de la release | + +**Vérifié** : 34 tests verts, dont `test_ticket_of_internal_project_is_linked` (projet +de maintenance sans cross connect, même partenaire, ticket lié et `message` vide) et +`test_ticket_of_project_without_partner_is_not_linked` (projet sans partenaire, ticket +non lié et signalé), plus le mail dont les destinataires viennent maintenant du +partenaire. diff --git a/support_release/README.rst b/support_release/README.rst index d4406ed..87766d1 100644 --- a/support_release/README.rst +++ b/support_release/README.rst @@ -63,7 +63,8 @@ The guard on the model presence keeps a database where this module is not installed from failing its module update. Releases are then pushed by the cron *Support Release: push pending -releases* (every 10 minutes, one release per run, oldest first). +releases* (every 10 minutes, one release per run, in the default order +of the model, so the most recently registered pending one first). Every deployment sends the complete list of tickets of the release, so registering the same version again replaces its tickets instead of diff --git a/support_release/models/support_release.py b/support_release/models/support_release.py index ce496c8..5d425a0 100644 --- a/support_release/models/support_release.py +++ b/support_release/models/support_release.py @@ -66,11 +66,6 @@ def _register_deployment(self, version, tickets): self.search([("version", "=", NEXT_VERSION)]).unlink() return release - @api.model - def _next_release_to_send(self): - """Oldest pending release first, whatever the default order is.""" - return self.search([("is_sent", "=", False)], order="id", limit=1) - def _build_payload(self): self.ensure_one() return { @@ -89,9 +84,9 @@ def _push(self): @api.model def _cron_push_releases(self): - """Push the oldest pending release, one per cron run.""" + """Push one pending release per cron run, in the default model order.""" if not self._get_environment(): return - release = self._next_release_to_send() + release = self.search([("is_sent", "=", False)], limit=1) if release: release._push() diff --git a/support_release/readme/USAGE.md b/support_release/readme/USAGE.md index 510364e..317c047 100644 --- a/support_release/readme/USAGE.md +++ b/support_release/readme/USAGE.md @@ -19,7 +19,8 @@ The guard on the model presence keeps a database where this module is not installed from failing its module update. Releases are then pushed by the cron *Support Release: push pending releases* -(every 10 minutes, one release per run, oldest first). +(every 10 minutes, one release per run, in the default order of the model, so +the most recently registered pending one first). Every deployment sends the complete list of tickets of the release, so registering the same version again replaces its tickets instead of completing diff --git a/support_release/static/description/index.html b/support_release/static/description/index.html index c3cbf99..44f97b2 100644 --- a/support_release/static/description/index.html +++ b/support_release/static/description/index.html @@ -412,7 +412,8 @@

    Usage

    The guard on the model presence keeps a database where this module is not installed from failing its module update.

    Releases are then pushed by the cron Support Release: push pending -releases (every 10 minutes, one release per run, oldest first).

    +releases (every 10 minutes, one release per run, in the default order +of the model, so the most recently registered pending one first).

    Every deployment sends the complete list of tickets of the release, so registering the same version again replaces its tickets instead of completing them. A call with a version or a ticket list that cannot be diff --git a/support_release/tests/test_support_release.py b/support_release/tests/test_support_release.py index 436a493..4d17e4f 100644 --- a/support_release/tests/test_support_release.py +++ b/support_release/tests/test_support_release.py @@ -92,13 +92,6 @@ def test_payload(self): }, ) - def test_next_release_to_send_is_oldest(self): - first = self.Release._register_deployment("14.0.1.0", [1]) - second = self.Release._register_deployment("14.0.2.0", [2]) - self.assertEqual(self.Release._next_release_to_send(), first) - first.is_sent = True - self.assertEqual(self.Release._next_release_to_send(), second) - def test_cron_pushes_one_release(self): first = self.Release._register_deployment("14.0.1.0", [1]) second = self.Release._register_deployment("14.0.2.0", [2]) @@ -111,12 +104,12 @@ def test_cron_pushes_one_release(self): kwargs["data"], { "environment": "preprod", - "version": "14.0.1.0", - "tickets": [1], + "version": "14.0.2.0", + "tickets": [2], }, ) - self.assertTrue(first.is_sent) - self.assertFalse(second.is_sent) + self.assertTrue(second.is_sent) + self.assertFalse(first.is_sent) def test_cron_is_silent_without_environment(self): self.Release._register_deployment("14.0.1.0", [1]) @@ -131,4 +124,4 @@ def test_failed_push_raises_and_keeps_release_pending(self): with self.assertRaises(requests_exceptions.ConnectionError): self.Release._cron_push_releases() self.assertFalse(release.is_sent) - self.assertEqual(self.Release._next_release_to_send(), release) + self.assertEqual(self.Release.search([("is_sent", "=", False)]), release) diff --git a/support_server_release/README.rst b/support_server_release/README.rst index 6fbbce3..42b3774 100644 --- a/support_server_release/README.rst +++ b/support_server_release/README.rst @@ -60,8 +60,11 @@ appear in their kanban. A client owns the projects whose *Cross Connect Client* is set to it. The partner of the release is the partner of those projects; if they carry several partners, the module raises and the reception fails, since -the client must be one customer. A ticket is linked only when its -project belongs to the client sending the payload. +the client must be one customer. A ticket is linked when its project +carries that partner, so the tickets of the internal projects +(maintenance done for the customer, without a cross connect client) are +linked too, while a ticket of another partner is reported in the +*Message* field and left untouched. Each payload replaces the tickets of the release: a ticket removed from the release is unlinked, so the production mail never announces it. @@ -82,11 +85,13 @@ user to notify. Nothing is dropped silently. Production notification ----------------------- -When a release reaches production, the users of its client (the portal -users attached to the ``cross.connect.client``, the ones the connection -creates) get an email listing the tickets of the release: every title -links to the ticket, and the description of the ticket is folded under a -*Description* summary. +When a release reaches production, the users listed in *Release +notification users* on the partner of the release get an email listing +the tickets of the release: every title links to the ticket, and the +description of the ticket is folded under a *Description* summary. That +list is set on the customer, so the same people are notified for every +release of that customer, and a customer with no user listed simply gets +no mail. The mail is queued like any other Odoo mail and sent by the mail queue, so a mail server problem never fails the release. A client without a diff --git a/support_server_release/__manifest__.py b/support_server_release/__manifest__.py index 024bc02..ea8241c 100644 --- a/support_server_release/__manifest__.py +++ b/support_server_release/__manifest__.py @@ -19,6 +19,7 @@ "data/mail_template.xml", "views/support_server_release_views.xml", "views/project_task_views.xml", + "views/res_partner_views.xml", "views/res_config_settings_views.xml", ], "installable": True, diff --git a/support_server_release/models/__init__.py b/support_server_release/models/__init__.py index 6200257..f444769 100644 --- a/support_server_release/models/__init__.py +++ b/support_server_release/models/__init__.py @@ -4,4 +4,5 @@ from . import fastapi_endpoint from . import project_task from . import res_config_settings +from . import res_partner from . import support_server_release diff --git a/support_server_release/models/res_partner.py b/support_server_release/models/res_partner.py new file mode 100644 index 0000000..f4a6b8c --- /dev/null +++ b/support_server_release/models/res_partner.py @@ -0,0 +1,18 @@ +# Copyright 2026 Akretion (http://www.akretion.com). +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ResPartner(models.Model): + _inherit = "res.partner" + + support_release_notification_user_ids = fields.Many2many( + "res.users", + "support_release_notification_user_rel", + "partner_id", + "user_id", + string="Release notification users", + help="Users notified by mail when a release of this customer " + "reaches production.", + ) diff --git a/support_server_release/models/support_server_release.py b/support_server_release/models/support_server_release.py index 4c6b192..a8aa8f5 100644 --- a/support_server_release/models/support_server_release.py +++ b/support_server_release/models/support_server_release.py @@ -97,26 +97,36 @@ def _get_client_partner(self, client): return partners @api.model - def _filter_tickets(self, client, ticket_ids): - """Keep the tickets of the projects of the client, report the others.""" + def _filter_tickets(self, partner, ticket_ids): + """Keep the tickets of the partner of the client, report the others. + + The tickets of an internal project have no cross connect client but + share the partner of the customer, so they belong to the release too. + """ tasks = self.env["project.task"].browse(ticket_ids).exists() messages = [] unknown = sorted(set(ticket_ids) - set(tasks.ids)) if unknown: messages.append(_("Unknown tickets: %s", unknown)) - kept = tasks.filtered( - lambda task: task.project_id.cross_connect_client_id == client - ) + if not partner: + if tasks: + messages.append( + _("No partner on the projects of the client, no ticket linked.") + ) + return self.env["project.task"], messages + kept = tasks.filtered(lambda task: task.project_id.partner_id == partner) dropped = tasks - kept if dropped: - messages.append(_("Tickets of another client, not linked: %s", dropped.ids)) + messages.append( + _("Tickets of another partner, not linked: %s", dropped.ids) + ) return kept, messages - def _prepare_release_vals(self, client, environment, tasks, messages): + def _prepare_release_vals(self, partner, environment, tasks, messages): return { DEPLOYED_FIELDS[environment]: fields.Datetime.now(), "message": "\n".join(messages) or False, - "partner_id": self._get_client_partner(client).id, + "partner_id": partner.id, "task_ids": [Command.set(tasks.ids)], } @@ -142,14 +152,14 @@ def _get_task_url(self, task): return f"{self.get_base_url()}/odoo/action-{TASK_ACTION_XMLID}/{task.id}" 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( + """Emails of the users to notify, set on the partner of the release.""" + users = self.partner_id.support_release_notification_user_ids.filtered( lambda user: user.active and user.email ) return ", ".join(sorted(set(users.mapped("email")))) def _notify_prod_release(self): - """Tell the users of the client that the release reached production.""" + """Tell the users of the customer that the release reached production.""" self.ensure_one() if not self._get_notification_emails(): self._add_message( @@ -165,8 +175,9 @@ def _process(self, client, payload): if release.prod_deployed_at: return release environment = payload["environment"] - tasks, messages = self._filter_tickets(client, payload.get("tickets") or []) - release.write(self._prepare_release_vals(client, environment, tasks, messages)) + partner = self._get_client_partner(client) + tasks, messages = self._filter_tickets(partner, payload.get("tickets") or []) + release.write(self._prepare_release_vals(partner, environment, tasks, messages)) stage = self._get_stage(environment) if stage: release._apply_stages(stage) diff --git a/support_server_release/readme/USAGE.md b/support_server_release/readme/USAGE.md index f88df12..ba52efc 100644 --- a/support_server_release/readme/USAGE.md +++ b/support_server_release/readme/USAGE.md @@ -13,8 +13,10 @@ to the projects of the client to appear in their kanban. A client owns the projects whose *Cross Connect Client* is set to it. The partner of the release is the partner of those projects; if they carry several partners, the module raises and the reception fails, since the client must be -one customer. A ticket is linked only when its project belongs to the client -sending the payload. +one customer. A ticket is linked when its project carries that partner, so the +tickets of the internal projects (maintenance done for the customer, without a +cross connect client) are linked too, while a ticket of another partner is +reported in the *Message* field and left untouched. Each payload replaces the tickets of the release: a ticket removed from the release is unlinked, so the production mail never announces it. @@ -33,10 +35,12 @@ dropped silently. ## Production notification -When a release reaches production, the users of its client (the portal users -attached to the `cross.connect.client`, the ones the connection creates) get an -email listing the tickets of the release: every title links to the ticket, and -the description of the ticket is folded under a *Description* summary. +When a release reaches production, the users listed in *Release notification +users* on the partner of the release get an email listing the tickets of the +release: every title links to the ticket, and the description of the ticket is +folded under a *Description* summary. That list is set on the customer, so the +same people are notified for every release of that customer, and a customer +with no user listed simply gets no mail. The mail is queued like any other Odoo mail and sent by the mail queue, so a mail server problem never fails the release. A client without a user having an diff --git a/support_server_release/static/description/index.html b/support_server_release/static/description/index.html index 119e634..b8de304 100644 --- a/support_server_release/static/description/index.html +++ b/support_server_release/static/description/index.html @@ -414,8 +414,11 @@

    Usage

    A client owns the projects whose Cross Connect Client is set to it. The partner of the release is the partner of those projects; if they carry several partners, the module raises and the reception fails, since -the client must be one customer. A ticket is linked only when its -project belongs to the client sending the payload.

    +the client must be one customer. A ticket is linked when its project +carries that partner, so the tickets of the internal projects +(maintenance done for the customer, without a cross connect client) are +linked too, while a ticket of another partner is reported in the +Message field and left untouched.

    Each payload replaces the tickets of the release: a ticket removed from the release is unlinked, so the production mail never announces it.

    Once a release has a production deployment date, any further payload for @@ -430,11 +433,13 @@

    Usage

    user to notify. Nothing is dropped silently.

    Production notification

    -

    When a release reaches production, the users of its client (the portal -users attached to the cross.connect.client, the ones the connection -creates) get an email listing the tickets of the release: every title -links to the ticket, and the description of the ticket is folded under a -Description summary.

    +

    When a release reaches production, the users listed in Release +notification users on the partner of the release get an email listing +the tickets of the release: every title links to the ticket, and the +description of the ticket is folded under a Description summary. That +list is set on the customer, so the same people are notified for every +release of that customer, and a customer with no user listed simply gets +no mail.

    The mail is queued like any other Odoo mail and sent by the mail queue, so a mail server problem never fails the release. A client without a user having an email address simply gets no mail.

    diff --git a/support_server_release/tests/test_support_server_release.py b/support_server_release/tests/test_support_server_release.py index 9490812..e5d2fd4 100644 --- a/support_server_release/tests/test_support_server_release.py +++ b/support_server_release/tests/test_support_server_release.py @@ -49,7 +49,15 @@ def _setup_release_data(cls): cls.anonymous_project = cls.env["project.project"].create( {"name": "Anonymous project", "cross_connect_client_id": cls.client.id} ) - projects = (cls.project | cls.other_project | cls.anonymous_project).ids + cls.internal_project = cls.env["project.project"].create( + {"name": "Internal maintenance", "partner_id": cls.partner.id} + ) + projects = ( + cls.project + | cls.other_project + | cls.anonymous_project + | cls.internal_project + ).ids cls.stage_new = cls.env["project.task.type"].create( {"name": "New", "sequence": 1, "project_ids": [(6, 0, projects)]} ) @@ -76,9 +84,9 @@ def _task(self, stage=None, project=None): def _payload(self, environment, version, tickets): return {"environment": environment, "version": version, "tickets": tickets} - def _client_user(self, email): - """A portal user of the client, the recipient of the release mail.""" - return ( + def _notification_user(self, email): + """A user notified when a release of the partner reaches production.""" + user = ( self.env["res.users"] .with_context(no_reset_password=True) .create( @@ -86,11 +94,12 @@ def _client_user(self, email): "name": "Customer user", "login": email, "email": email, - "cross_connect_client_id": self.client.id, "groups_id": [(6, 0, [self.env.ref("base.group_portal").id])], } ) ) + self.partner.support_release_notification_user_ids = [(4, user.id)] + return user class TestSupportServerRelease(ReleaseCaseMixin, TransactionCase): @@ -169,7 +178,7 @@ def test_no_stage_configured(self): self.assertEqual(task.stage_id, self.stage_preprod) self.assertIn("No stage configured", release.message) - def test_ticket_of_another_client_is_not_linked(self): + def test_ticket_of_another_partner_is_not_linked(self): task = self._task() foreign = self._task(stage=self.stage_new, project=self.other_project) release = self.Release._process( @@ -180,13 +189,24 @@ def test_ticket_of_another_client_is_not_linked(self): self.assertEqual(foreign.stage_id, self.stage_new) self.assertIn(str(foreign.id), release.message) - def test_project_without_partner_is_still_linked(self): + def test_ticket_of_internal_project_is_linked(self): + internal = self._task(stage=self.stage_new, project=self.internal_project) + release = self.Release._process( + self.client, self._payload("preprod", "next", [internal.id]) + ) + self.assertEqual(release.task_ids, internal) + self.assertEqual(release.partner_id, self.partner) + self.assertFalse(release.message) + + def test_ticket_of_project_without_partner_is_not_linked(self): task = self._task(project=self.anonymous_project) release = self.Release._process( self.client, self._payload("preprod", "next", [task.id]) ) - self.assertEqual(release.task_ids, task) + self.assertFalse(release.task_ids) self.assertEqual(release.partner_id, self.partner) + self.assertIn(str(task.id), release.message) + self.assertEqual(task.stage_id, self.stage_preprod) def test_several_partners_raise(self): self.env["project.project"].create( @@ -226,10 +246,10 @@ def test_second_payload_is_idempotent(self): self.assertEqual(release.partner_id, self.partner) self.assertGreaterEqual(release.preprod_deployed_at, deployed_at) - def test_prod_notifies_the_client_users(self): + def test_prod_notifies_the_notification_users(self): task = self._task() task.description = "

    The content of the ticket

    " - self._client_user("customer@example.com") + self._notification_user("customer@example.com") self.env["mail.mail"].search([]).unlink() release = self.Release._process( self.client, self._payload("prod", "14.0.1.0", [task.id]) @@ -250,7 +270,7 @@ def test_prod_notifies_the_client_users(self): def test_prod_mail_is_sent_once(self): task = self._task() - self._client_user("customer@example.com") + self._notification_user("customer@example.com") self.env["mail.mail"].search([]).unlink() payload = self._payload("prod", "14.0.1.0", [task.id]) self.Release._process(self.client, dict(payload)) @@ -259,12 +279,12 @@ def test_prod_mail_is_sent_once(self): def test_preprod_sends_no_mail(self): task = self._task() - self._client_user("customer@example.com") + self._notification_user("customer@example.com") self.env["mail.mail"].search([]).unlink() self.Release._process(self.client, self._payload("preprod", "next", [task.id])) self.assertFalse(self.env["mail.mail"].search([])) - def test_prod_without_client_user_sends_no_mail(self): + def test_prod_without_notification_user_sends_no_mail(self): task = self._task() self.env["mail.mail"].search([]).unlink() release = self.Release._process( diff --git a/support_server_release/views/res_partner_views.xml b/support_server_release/views/res_partner_views.xml new file mode 100644 index 0000000..5d271eb --- /dev/null +++ b/support_server_release/views/res_partner_views.xml @@ -0,0 +1,19 @@ + + + + + res.partner + + + + + + + + + + From 46d0b61f16c9790bb306aee04bc861293f1b9cfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20BEAU?= Date: Mon, 2 Nov 2026 23:31:13 +0000 Subject: [PATCH 08/23] [IMP] support_server_release: send the production mail in the customer 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) --- support_server_release/data/mail_template.xml | 1 + support_server_release/i18n/fr.po | 88 +++++++++++++++++++ .../tests/test_support_server_release.py | 22 +++++ 3 files changed, 111 insertions(+) create mode 100644 support_server_release/i18n/fr.po diff --git a/support_server_release/data/mail_template.xml b/support_server_release/data/mail_template.xml index 84a9ee1..158a01d 100644 --- a/support_server_release/data/mail_template.xml +++ b/support_server_release/data/mail_template.xml @@ -9,6 +9,7 @@ name="subject" >{{ object.cross_connect_client_id.name }}: version {{ object.version }} in production {{ object._get_notification_emails() }} + {{ object.partner_id.lang }}
    \n" +"Language-Team: French\n" +"Language: fr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: support_server_release +#: model:mail.template,body_html:support_server_release.mail_template_release_in_prod +msgid "" +"
    \n" +"

    Hello,

    \n" +"

    \n" +" Version\n" +" \n" +" of\n" +" \n" +" is now deployed in production. It contains the following\n" +" tickets:\n" +"

    \n" +" \n" +"

    Regards,

    \n" +"
    \n" +" " +msgstr "" +"\n" +" " + +#. module: support_server_release +#: model:mail.template,name:support_server_release.mail_template_release_in_prod +msgid "Support Release: release in production" +msgstr "Support Release : version mise en production" + +#. module: support_server_release +#: model:mail.template,subject:support_server_release.mail_template_release_in_prod +msgid "" +"{{ object.cross_connect_client_id.name }}: version {{ object.version }} in " +"production" +msgstr "" +"{{ object.cross_connect_client_id.name }} : version {{ object.version }} en " +"production" diff --git a/support_server_release/tests/test_support_server_release.py b/support_server_release/tests/test_support_server_release.py index e5d2fd4..5925039 100644 --- a/support_server_release/tests/test_support_server_release.py +++ b/support_server_release/tests/test_support_server_release.py @@ -268,6 +268,28 @@ def test_prod_notifies_the_notification_users(self): f"/odoo/action-project.action_view_task/{task.id}", mail.body_html ) + def test_prod_mail_is_sent_in_the_language_of_the_customer(self): + self.env["res.lang"]._activate_lang("fr_FR") + self.env.ref("base.module_support_server_release")._update_translations( + ["fr_FR"] + ) + self.env.invalidate_all() + template = self.env.ref("support_server_release.mail_template_release_in_prod") + self.assertIn("en production", template.with_context(lang="fr_FR").subject) + task = self._task() + self._notification_user("customer@example.com") + self.partner.lang = "fr_FR" + self.env["mail.mail"].search([]).unlink() + release = self.Release._process( + self.client, self._payload("prod", "14.0.1.0", [task.id]) + ) + mail = self.env["mail.mail"].search( + [("model", "=", "support.server.release"), ("res_id", "=", release.id)] + ) + self.assertIn("version 14.0.1.0 en production", mail.subject) + self.assertIn("Bonjour", mail.body_html) + self.assertIn("Cordialement", mail.body_html) + def test_prod_mail_is_sent_once(self): task = self._task() self._notification_user("customer@example.com") From 5168437244b94e1d63bf20aafba0f4a155d41ff7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20BEAU?= Date: Tue, 3 Nov 2026 10:19:37 +0000 Subject: [PATCH 09/23] [IMP] support_server_release: move the release stages in the project settings The two stages are part of the project configuration: they belong to the Project app, in a block after the task management, rather than in an app of their own. --- .../views/res_config_settings_views.xml | 48 ++++++++----------- 1 file changed, 21 insertions(+), 27 deletions(-) diff --git a/support_server_release/views/res_config_settings_views.xml b/support_server_release/views/res_config_settings_views.xml index 9865983..5a7176e 100644 --- a/support_server_release/views/res_config_settings_views.xml +++ b/support_server_release/views/res_config_settings_views.xml @@ -3,35 +3,29 @@ License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). --> - res.config.settings.view.form.inherit.support.release + res.config.settings.view.form.inherit.project.support.release res.config.settings - + - - - - - - - - - - - + + + + + + + + + From 71234335640536111273e477dd9962931f2f3b26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20BEAU?= Date: Tue, 3 Nov 2026 10:32:54 +0000 Subject: [PATCH 10/23] [IMP] support_server_release: group the tickets by project in the mail The mail now lists one block per project, headed by the name of the project, and numbers each ticket as it is in the titles of the merge requests, "[5353] title", instead of a single flat list. The French translation follows the new body, and a test checks that each ticket is listed under the block of its own project. --- support_server_release/data/mail_template.xml | 49 +++++++++------ support_server_release/i18n/fr.po | 62 ++++++++++--------- .../tests/test_support_server_release.py | 26 +++++++- 3 files changed, 88 insertions(+), 49 deletions(-) diff --git a/support_server_release/data/mail_template.xml b/support_server_release/data/mail_template.xml index 158a01d..961ae38 100644 --- a/support_server_release/data/mail_template.xml +++ b/support_server_release/data/mail_template.xml @@ -24,26 +24,35 @@ is now deployed in production. It contains the following tickets:

    -
    +

    Regards,

    diff --git a/support_server_release/i18n/fr.po b/support_server_release/i18n/fr.po index 40ee9a0..b1cd9c9 100644 --- a/support_server_release/i18n/fr.po +++ b/support_server_release/i18n/fr.po @@ -6,8 +6,8 @@ msgid "" msgstr "" "Project-Id-Version: Odoo Server 18.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-11-02 23:23+0000\n" -"PO-Revision-Date: 2026-11-02 23:23+0000\n" +"POT-Creation-Date: 2026-11-03 10:28+0000\n" +"PO-Revision-Date: 2026-11-03 10:28+0000\n" "Last-Translator: Akretion \n" "Language-Team: French\n" "Language: fr\n" @@ -29,19 +29,22 @@ msgid "" " is now deployed in production. It contains the following\n" " tickets:\n" "

    \n" -"
    \n" +" \n" "

    Regards,

    \n" "
    \n" " " @@ -56,19 +59,22 @@ msgstr "" " est désormais déployée en production. Elle contient les\n" " tickets suivants :\n" "

    \n" -"
    \n" +" \n" "

    Cordialement,

    \n" "
    \n" " " diff --git a/support_server_release/tests/test_support_server_release.py b/support_server_release/tests/test_support_server_release.py index 5925039..9d6c200 100644 --- a/support_server_release/tests/test_support_server_release.py +++ b/support_server_release/tests/test_support_server_release.py @@ -260,7 +260,8 @@ def test_prod_notifies_the_notification_users(self): self.assertEqual(len(mail), 1) self.assertEqual(mail.email_to, "customer@example.com") self.assertIn("14.0.1.0", mail.subject) - self.assertIn(task.name, mail.body_html) + self.assertIn(self.project.name, mail.body_html) + self.assertIn(f"[{task.id}] {task.name}", mail.body_html) self.assertIn("The content of the ticket", mail.body_html) self.assertIn("
    ", mail.body_html) self.assertNotIn("mail/unfollow", mail.body_html) @@ -268,6 +269,29 @@ def test_prod_notifies_the_notification_users(self): f"/odoo/action-project.action_view_task/{task.id}", mail.body_html ) + def test_prod_mail_groups_the_tickets_by_project(self): + task = self._task() + internal_task = self._task(project=self.internal_project) + self._notification_user("customer@example.com") + self.env["mail.mail"].search([]).unlink() + release = self.Release._process( + self.client, + self._payload("prod", "14.0.1.0", [task.id, internal_task.id]), + ) + mail = self.env["mail.mail"].search( + [("model", "=", "support.server.release"), ("res_id", "=", release.id)] + ) + body = mail.body_html + self.assertEqual(body.count(self.project.name), 1) + self.assertEqual(body.count(self.internal_project.name), 1) + self.assertIn(f"[{task.id}] {task.name}", body) + self.assertIn(f"[{internal_task.id}] {internal_task.name}", body) + # each ticket is listed under the block of its own project + self.assertLess(body.index(self.project.name), body.index(f"[{task.id}]")) + self.assertLess( + body.index(f"[{task.id}]"), body.index(self.internal_project.name) + ) + def test_prod_mail_is_sent_in_the_language_of_the_customer(self): self.env["res.lang"]._activate_lang("fr_FR") self.env.ref("base.module_support_server_release")._update_translations( From 0c530a79de3b8405a1ce87b4945cd4f740e00e75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20BEAU?= Date: Tue, 3 Nov 2026 10:59:29 +0000 Subject: [PATCH 11/23] [IMP] support_server_release: keep the internal tickets out of the mail The mail only announces the tickets whose project carries a cross connect client: the projects of internal maintenance share the partner of the customer but are invisible on his portal, so their tickets stay linked to the release without being listed, and a release with nothing to announce sends no mail at all. The body loops once on the tickets to announce and filters the block of each project with a condition, instead of reading all the tasks of the project. --- support_server_release/data/mail_template.xml | 6 ++- support_server_release/i18n/fr.po | 15 +++++--- .../models/support_server_release.py | 19 +++++++++- .../tests/test_support_server_release.py | 38 ++++++++++++++++--- 4 files changed, 63 insertions(+), 15 deletions(-) diff --git a/support_server_release/data/mail_template.xml b/support_server_release/data/mail_template.xml index 961ae38..bc61b54 100644 --- a/support_server_release/data/mail_template.xml +++ b/support_server_release/data/mail_template.xml @@ -24,15 +24,17 @@ is now deployed in production. It contains the following tickets:

    -
    + +

    \n" diff --git a/support_server_release/models/support_server_release.py b/support_server_release/models/support_server_release.py index dddb239..47e5b66 100644 --- a/support_server_release/models/support_server_release.py +++ b/support_server_release/models/support_server_release.py @@ -198,8 +198,12 @@ def action_open_tasks(self): return action def _get_task_url(self, task): - """URL opening the ticket form in the web client of this database.""" - return f"{self.get_base_url()}/odoo/action-{TASK_ACTION_XMLID}/{task.id}" + """URL opening the ticket, with the action of the customer. + + The action of the project module is not open to the users of the + support group, so the link points to the one of project_customer_access. + """ + return f"{self.get_base_url()}/odoo/action-{CUSTOMER_TASK_ACTION}/{task.id}" def _get_notification_emails(self): """Emails of the users to notify, set on the partner of the release.""" diff --git a/support_server_release/tests/test_support_server_release.py b/support_server_release/tests/test_support_server_release.py index d79557b..b0ec1df 100644 --- a/support_server_release/tests/test_support_server_release.py +++ b/support_server_release/tests/test_support_server_release.py @@ -390,12 +390,16 @@ def test_prod_notifies_the_notification_users(self): self.assertIn("14.0.1.0", mail.subject) self.assertIn(self.project.name, mail.body_html) self.assertIn(f"[{task.id}] {task.name}", mail.body_html) - self.assertIn("The content of the ticket", mail.body_html) - self.assertIn("
    ", mail.body_html) self.assertNotIn("mail/unfollow", mail.body_html) + # the description would be unreadable in the mail boxes, see the ticket + self.assertNotIn("The content of the ticket", mail.body_html) + self.assertNotIn("
    ", mail.body_html) + # the link opens the action the customer has access to self.assertIn( - f"/odoo/action-project.action_view_task/{task.id}", mail.body_html + f"/odoo/action-project_customer_access.action_view_all_task/{task.id}", + mail.body_html, ) + self.assertNotIn("action-project.action_view_task", mail.body_html) def test_prod_mail_groups_the_tickets_by_project(self): second_project = self.env["project.project"].create(