diff --git a/PLAN-support-release.md b/PLAN-support-release.md new file mode 100644 index 0000000..3b1d813 --- /dev/null +++ b/PLAN-support-release.md @@ -0,0 +1,595 @@ +# 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 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, 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. **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. **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. + +--- + +## 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, 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 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 : + +- 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 ; 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. | + +--- + +## 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 → `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. + +--- + +## 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 (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. + +### `support.release` (`_rec_name = "version"`) + +| Champ | Type | Rôle | +| --------- | ---------------------------- | --------------------------------------------------------------- | +| `version` | Char, requis, unique, indexé | clé ; `next` pour la future version | +| `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)` : `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)], 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()`, 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 > 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 : 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 : l'exception remonte, `is_sent` reste faux et la release est retentée. + +--- + +## 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é | 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). + +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)` 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 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` ; +7. `_notify_prod_release()` en production. + +### Étapes + +`_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, 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 + +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`, 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é, `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 : 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é 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 ; +- 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. + +--- + +## 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, renseigner son `cross_connect_client_id` sur les projets du +client, configurer les deux étapes et la liste _Release notification users_ sur le +partenaire. + +--- + +## 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é, 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. | + +--- + +## 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. + +--- + +## 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 si la release n'a pas encore de `prod_deployed_at` (D10), le central envoie le +template `mail_template_release_in_prod` : + +- 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 `
` ; +- 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`. + +--- + +## 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. + +--- + +## 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`. + +--- + +## 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/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..87766d1 --- /dev/null +++ b/support_release/README.rst @@ -0,0 +1,114 @@ +=============== +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, 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 +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. + +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..5d425a0 --- /dev/null +++ b/support_release/models/support_release.py @@ -0,0 +1,92 @@ +# Copyright 2026 Akretion (http://www.akretion.com). +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models, tools +from odoo.exceptions import UserError + +NEXT_VERSION = "next" +SUPPORTED_ENVIRONMENTS = ("preprod", "prod") + + +class SupportRelease(models.Model): + _name = "support.release" + _description = "Support Release" + _rec_name = "version" + _order = "id desc" + + version = fields.Char( + required=True, + index=True, + help="Version of the deployment, or 'next' while no version is defined.", + ) + tickets = fields.Json( + help="Ids of the tickets 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 _register_deployment(self, version, tickets): + """Record a deployment, called from the migration of the project. + + Each deployment sends every ticket of the release, so the last payload + replaces the previous one instead of completing it. + """ + version = (version or "").strip() + if version.lower() == NEXT_VERSION: + version = NEXT_VERSION + if not version: + 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: + self.search([("version", "=", NEXT_VERSION)]).unlink() + return release + + def _build_payload(self): + self.ensure_one() + return { + "environment": self._get_environment(), + "version": self.version, + "tickets": self.tickets or [], + } + + 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 one pending release per cron run, in the default model order.""" + if not self._get_environment(): + return + release = self.search([("is_sent", "=", False)], limit=1) + if release: + release._push() 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..317c047 --- /dev/null +++ b/support_release/readme/USAGE.md @@ -0,0 +1,39 @@ +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, 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 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. + +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..25455a3 --- /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,0,0 diff --git a/support_release/static/description/icon.png b/support_release/static/description/icon.png new file mode 100644 index 0000000..b8a639d Binary files /dev/null and b/support_release/static/description/icon.png differ diff --git a/support_release/static/description/index.html b/support_release/static/description/index.html new file mode 100644 index 0000000..44f97b2 --- /dev/null +++ b/support_release/static/description/index.html @@ -0,0 +1,461 @@ + + + + + +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, 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 +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.

+

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..4d17e4f --- /dev/null +++ b/support_release/tests/test_support_release.py @@ -0,0 +1,127 @@ +# 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 requests import exceptions as requests_exceptions + +from odoo import tools +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]) + self.assertEqual(release.version, "next") + self.assertFalse(release.is_sent) + self.assertEqual(release.tickets, [42, 12]) + + 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.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.tickets, [2]) + + def test_numbered_version_removes_next(self): + self.Release._register_deployment("next", [1, 2]) + 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.tickets, [3]) + + 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.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_raises(self): + with self.assertRaises(ValueError): + self.Release._register_deployment("", [1]) + self.assertFalse(self.Release.search_count([])) + + 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"): + 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": [3, 1], + }, + ) + + 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.2.0", + "tickets": [2], + }, + ) + 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]) + with _set_running_env("dev"), self._patch_request() as request: + self.Release._cron_push_releases() + request.assert_not_called() + + 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 = requests_exceptions.ConnectionError("boom") + with self.assertRaises(requests_exceptions.ConnectionError): + self.Release._cron_push_releases() + self.assertFalse(release.is_sent) + self.assertEqual(self.Release.search([("is_sent", "=", False)]), release) diff --git a/support_release/views/support_release_views.xml b/support_release/views/support_release_views.xml new file mode 100644 index 0000000..9b01462 --- /dev/null +++ b/support_release/views/support_release_views.xml @@ -0,0 +1,60 @@ + + + + + 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..42b3774 --- /dev/null +++ b/support_server_release/README.rst @@ -0,0 +1,136 @@ +====================== +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. + +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:: + :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. + +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. 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 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 +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 +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 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. + +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 +=========== + +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..ef0d8a1 --- /dev/null +++ b/support_server_release/__manifest__.py @@ -0,0 +1,27 @@ +# 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", + "project_customer_access", + ], + "data": [ + "security/ir.model.access.csv", + "security/support_server_release_security.xml", + "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/data/mail_template.xml b/support_server_release/data/mail_template.xml new file mode 100644 index 0000000..040d6fa --- /dev/null +++ b/support_server_release/data/mail_template.xml @@ -0,0 +1,51 @@ + + + + + Support Release: release in production + + {{ object.cross_connect_client_id.name }}: version {{ object.version }} in production + {{ object._get_notification_emails() }} + {{ object.partner_id.lang }} + + +
+

Hello,

+

+ Version + + of + + 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 new file mode 100644 index 0000000..af0dd30 --- /dev/null +++ b/support_server_release/i18n/fr.po @@ -0,0 +1,81 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * support_server_release +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-11-03 10:40+0000\n" +"PO-Revision-Date: 2026-11-03 10:40+0000\n" +"Last-Translator: Akretion \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" +" " +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/models/__init__.py b/support_server_release/models/__init__.py new file mode 100644 index 0000000..f444769 --- /dev/null +++ b/support_server_release/models/__init__.py @@ -0,0 +1,8 @@ +# 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 res_partner +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..f102016 --- /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.append(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..5f655e8 --- /dev/null +++ b/support_server_release/models/project_task.py @@ -0,0 +1,44 @@ +# Copyright 2026 Akretion (http://www.akretion.com). +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, 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", + ) + latest_release_version = fields.Char( + compute="_compute_latest_release_version", + help="Version of the most recent release the task belongs to.", + ) + + @api.depends("release_ids.version") + def _compute_latest_release_version(self): + for task in self: + task.latest_release_version = task.release_ids.sorted("id")[-1:].version + + def action_open_releases(self): + """Open the releases of the task, the form when there is only one.""" + self.ensure_one() + action = { + "type": "ir.actions.act_window", + "name": _("Releases"), + "res_model": "support.server.release", + } + if len(self.release_ids) == 1: + action.update({"view_mode": "form", "res_id": self.release_ids.id}) + else: + action.update( + { + "view_mode": "list,form", + "domain": [("id", "in", self.release_ids.ids)], + } + ) + return action 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/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 new file mode 100644 index 0000000..47e5b66 --- /dev/null +++ b/support_server_release/models/support_server_release.py @@ -0,0 +1,262 @@ +# Copyright 2026 Akretion (http://www.akretion.com). +# 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" +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" +CUSTOMER_GROUP = "project_customer_access.group_customer" +CUSTOMER_TASK_ACTION = "project_customer_access.action_view_all_task" +UNGROUPED_TASK_LIST = "project.open_view_all_tasks_list_view" +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 = "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 client.", + ) + 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() + task_count = fields.Integer(compute="_compute_task_count") + message = fields.Text( + readonly=True, + copy=False, + help="What could not be applied to this release, to be checked by hand.", + ) + + _sql_constraints = [ + ( + "client_version_uniq", + "unique(cross_connect_client_id, version)", + "This version is already registered for this client.", + ), + ] + + @api.model + 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): + 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() + + @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, 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, + the mail leaving them out. + """ + 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)) + 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 partner, not linked: %s", dropped.ids) + ) + return kept, 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": partner.id, + "task_ids": [Command.set(tasks.ids)], + } + + def _add_message(self, message): + self.ensure_one() + 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.""" + self.task_ids.sudo().stage_id = stage + + 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), + ] + ).unlink() + + @api.depends("task_ids") + def _compute_task_count(self): + for release in self: + release.task_count = len(release.task_ids) + + def action_open_tasks(self): + """Open the tickets linked to the release. + + The customers have their own views of the tasks, carried by the action + of project_customer_access, the default action of the project module + serves the others. + """ + self.ensure_one() + xmlid = ( + CUSTOMER_TASK_ACTION + if self.env.user.has_group(CUSTOMER_GROUP) + else TASK_ACTION_XMLID + ) + action = self.env["ir.actions.actions"]._for_xml_id(xmlid) + views = [] + for view_id, view_mode in action.get("views") or []: + if view_mode not in ("list", "form"): + continue + if view_mode == "list" and not view_id: + # the default list of the tasks groups them by stage + view_id = self.env.ref(UNGROUPED_TASK_LIST).id + views.append((view_id, view_mode)) + action.update( + { + "domain": [("id", "in", self.task_ids.ids)], + "context": { + # a task created from here would not be linked to the release + "create": False, + # the favourites of the user are bound to the action we reuse, + # so its default one would filter the tickets unasked + "search_disable_custom_filters": True, + }, + # the tickets of a release share the same stage, so the kanban + # of the source action would open on a single grouped column + "view_mode": "list,form", + "views": views, + } + ) + return action + + def _get_task_url(self, task): + """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.""" + 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 _get_notified_tasks(self): + """Tickets announced to the customer. + + The projects without a cross connect client are internal maintenance, + invisible on the portal of the customer, so their tickets stay out of + the mail even when they are linked to the release. + """ + return self.task_ids.filtered( + lambda task: task.project_id.cross_connect_client_id + ) + + def _notify_prod_release(self): + """Tell the users of the customer that the release reached production.""" + self.ensure_one() + if not self._get_notification_emails(): + self._add_message( + _("No user with an email address to notify the production deployment.") + ) + return + if not self._get_notified_tasks(): + self._add_message( + _("No ticket of a customer project to announce in the release mail.") + ) + return + self.env.ref(RELEASE_IN_PROD_TEMPLATE).send_mail(self.id, force_send=False) + + @api.model + def _process(self, client, payload): + """Record a release received from a client and apply it.""" + release = self._get_release(client, payload["version"]) + if release.prod_deployed_at: + return release + environment = payload["environment"] + 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) + 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/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..c8b201f --- /dev/null +++ b/support_server_release/readme/DESCRIPTION.md @@ -0,0 +1,11 @@ +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. + +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 new file mode 100644 index 0000000..ba52efc --- /dev/null +++ b/support_server_release/readme/USAGE.md @@ -0,0 +1,55 @@ +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. + +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. 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 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 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 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 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. + +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/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..68e0128 --- /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 = 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 new file mode 100644 index 0000000..28b4ea4 --- /dev/null +++ b/support_server_release/security/ir.model.access.csv @@ -0,0 +1,3 @@ +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,0,0 +access_support_server_release_customer,support.server.release customer,model_support_server_release,project_customer_access.group_customer,1,0,0,0 diff --git a/support_server_release/security/support_server_release_security.xml b/support_server_release/security/support_server_release_security.xml new file mode 100644 index 0000000..6a6ba87 --- /dev/null +++ b/support_server_release/security/support_server_release_security.xml @@ -0,0 +1,20 @@ + + + + + Support Server Release: releases of the client + + + [("cross_connect_client_id", "=", user.cross_connect_client_id.id)] + + + + + + + + diff --git a/support_server_release/static/description/icon.png b/support_server_release/static/description/icon.png new file mode 100644 index 0000000..b8a639d Binary files /dev/null and b/support_server_release/static/description/icon.png differ diff --git a/support_server_release/static/description/index.html b/support_server_release/static/description/index.html new file mode 100644 index 0000000..b8de304 --- /dev/null +++ b/support_server_release/static/description/index.html @@ -0,0 +1,484 @@ + + + + + +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.

+

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

+

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.
  • +
+

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. 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 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 +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 +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 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.

+

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

+

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..b0ec1df --- /dev/null +++ b/support_server_release/tests/test_support_server_release.py @@ -0,0 +1,568 @@ +# Copyright 2026 Akretion (http://www.akretion.com). +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.exceptions import AccessError, ValidationError +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.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, + "cross_connect_client_id": cls.client.id, + } + ) + cls.other_project = cls.env["project.project"].create( + { + "name": "Other project", + "partner_id": cls.other_partner.id, + "cross_connect_client_id": cls.other_client.id, + } + ) + cls.anonymous_project = cls.env["project.project"].create( + {"name": "Anonymous project", "cross_connect_client_id": cls.client.id} + ) + 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)]} + ) + 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} + + 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( + { + "name": "Customer user", + "login": email, + "email": email, + "groups_id": [(6, 0, [self.env.ref("base.group_portal").id])], + } + ) + ) + self.partner.support_release_notification_user_ids = [(4, user.id)] + return user + + def _customer_user(self, login="release-customer@example.com"): + """A user of the support group of the client.""" + return ( + self.env["res.users"] + .with_context(no_reset_password=True) + .create( + { + "name": "Customer user", + "login": login, + "groups_id": [ + ( + 6, + 0, + [self.env.ref("project_customer_access.group_customer").id], + ) + ], + "cross_connect_client_id": self.client.id, + } + ) + ) + + +class TestSupportServerRelease(ReleaseCaseMixin, TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._setup_release_data() + + 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.assertFalse(release.message) + self.assertEqual(task.stage_id, self.stage_preprod) + self.assertEqual(task.release_ids, release) + + def test_task_shows_the_latest_release(self): + task = self._task() + self.Release._process( + self.client, self._payload("preprod", "14.0.1.0", [task.id]) + ) + self.Release._process( + self.client, self._payload("preprod", "14.0.2.0", [task.id]) + ) + self.assertEqual(task.release_ids.mapped("version"), ["14.0.1.0", "14.0.2.0"]) + self.assertEqual(task.latest_release_version, "14.0.2.0") + + def test_release_opens_its_tickets(self): + task = self._task() + release = self.Release._process( + self.client, self._payload("preprod", "next", [task.id]) + ) + self.assertEqual(release.task_count, 1) + action = release.action_open_tasks() + self.assertEqual(action["res_model"], "project.task") + self.assertEqual(action["domain"], [("id", "in", task.ids)]) + # the default favourite of the user must not filter the release + self.assertTrue(action["context"]["search_disable_custom_filters"]) + self.assertFalse(action["context"]["create"]) + # the default list of the tasks opens grouped by stage, this one does not + self.assertEqual( + action["views"], + [ + (self.env.ref("project.open_view_all_tasks_list_view").id, "list"), + (False, "form"), + ], + ) + self.assertNotIn( + "default_group_by", + str( + self.env.ref( + "project.open_view_all_tasks_list_view" + ).get_combined_arch() + ), + ) + + def test_customer_sees_only_the_releases_of_its_client(self): + task = self._task() + self.Release._process( + self.client, self._payload("preprod", "14.0.1.0", [task.id]) + ) + other_task = self._task(project=self.other_project) + self.Release._process( + self.other_client, self._payload("preprod", "14.0.9.0", [other_task.id]) + ) + user = self._customer_user() + menu = self.env.ref( + "support_server_release.support_server_release_menu_customer" + ) + self.assertEqual( + menu.parent_id, + self.env.ref("project_customer_access.menu_project_customer_root"), + ) + releases = self.Release.with_user(user) + self.assertEqual(releases.search([]).mapped("version"), ["14.0.1.0"]) + with self.assertRaises(AccessError): + releases.search([]).write({"version": "tampered"}) + + def test_customer_opens_the_tickets_with_its_views(self): + task = self._task() + release = self.Release._process( + self.client, self._payload("preprod", "14.0.1.0", [task.id]) + ) + user = self._customer_user() + action = release.with_user(user).action_open_tasks() + self.assertEqual(action["domain"], [("id", "in", task.ids)]) + self.assertEqual(action["res_model"], "project.task") + search_view = action["search_view_id"] + if isinstance(search_view, list | tuple): + search_view = search_view[0] + self.assertEqual( + search_view, + self.env.ref("project_customer_access.view_task_search_form").id, + ) + # the kanban would open grouped by stage, on a single column + self.assertEqual(action["view_mode"], "list,form") + self.assertEqual( + [view[0] for view in action["views"]], + [ + self.env.ref("project_customer_access.view_task_tree").id, + self.env.ref("project_customer_access.view_task_form").id, + ], + ) + + def test_release_opens_the_ticket_form_when_there_is_one(self): + task = self._task() + release = self.Release._process( + self.client, self._payload("preprod", "14.0.1.0", [task.id]) + ) + action = task.action_open_releases() + self.assertEqual(action["view_mode"], "form") + self.assertEqual(action["res_id"], release.id) + + def test_task_opens_the_list_when_several_releases(self): + task = self._task() + for version in ("14.0.1.0", "14.0.2.0"): + self.Release._process( + self.client, self._payload("preprod", version, [task.id]) + ) + action = task.action_open_releases() + self.assertEqual(action["view_mode"], "list,form") + self.assertEqual(action["domain"], [("id", "in", task.release_ids.ids)]) + + 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_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", [first.id, 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_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( + 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_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_preprod) + + 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) + self.assertIn("No stage configured", release.message) + + 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( + 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_new) + self.assertIn(str(foreign.id), release.message) + + 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.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( + { + "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() + 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) + + def test_prod_notifies_the_notification_users(self): + task = self._task() + task.description = "

The content of the ticket

" + 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]) + ) + 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(self.project.name, mail.body_html) + self.assertIn(f"[{task.id}] {task.name}", 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_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( + { + "name": "Second project", + "partner_id": self.partner.id, + "cross_connect_client_id": self.client.id, + "type_ids": [(6, 0, (self.stage_preprod | self.stage_prod).ids)], + } + ) + task = self._task() + second_task = self._task(project=second_project) + 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, second_task.id, internal_task.id] + ), + ) + mail = self.env["mail.mail"].search( + [("model", "=", "support.server.release"), ("res_id", "=", release.id)] + ) + body = mail.body_html + # the internal ticket is linked to the release but kept out of the mail + self.assertIn(internal_task, release.task_ids) + self.assertNotIn(self.internal_project.name, body) + self.assertNotIn(f"[{internal_task.id}]", body) + # each ticket is listed under the block of its own project + self.assertIn(f"[{task.id}] {task.name}", body) + self.assertIn(f"[{second_task.id}] {second_task.name}", body) + self.assertLess(body.index(self.project.name), body.index(f"[{task.id}]")) + self.assertLess(body.index(f"[{task.id}]"), body.index(second_project.name)) + self.assertLess( + body.index(second_project.name), body.index(f"[{second_task.id}]") + ) + + 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") + 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._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_customer_ticket_sends_no_mail(self): + 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", [internal_task.id]) + ) + # the internal ticket is linked, but there is nothing to announce + self.assertIn(internal_task, release.task_ids) + self.assertFalse(self.env["mail.mail"].search([])) + self.assertIn("No ticket of a customer project", release.message) + + def test_prod_without_notification_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([])) + self.assertIn("No user with an email", release.message) + + +@tagged("post_install", "-at_install") +class TestSupportServerReleaseRouter(ReleaseCaseMixin, FastAPITransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._setup_release_data() + 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) + + 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/project_task_views.xml b/support_server_release/views/project_task_views.xml new file mode 100644 index 0000000..2f0b29b --- /dev/null +++ b/support_server_release/views/project_task_views.xml @@ -0,0 +1,52 @@ + + + + + project.task + + + + + + + + + project.task + + + + + + + + + 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..5a7176e --- /dev/null +++ b/support_server_release/views/res_config_settings_views.xml @@ -0,0 +1,32 @@ + + + + + res.config.settings.view.form.inherit.project.support.release + res.config.settings + + + + + + + + + + + + + + + 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 + + + + + + + + + + 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..a721a21 --- /dev/null +++ b/support_server_release/views/support_server_release_views.xml @@ -0,0 +1,128 @@ + + + + + support.server.release + + + + + + + + + + + + + support.server.release + +
+ +
+ +
+
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + support.server.release + + + + + + + + + + + + + + + + + Releases + support.server.release + list,form + + + + + +