Compare commits
2 commits
18.0
...
feature/k8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bbfeb49f5 | ||
|
|
9019d8f280 |
43 changed files with 2803 additions and 840 deletions
31
bemade_attachments_cleanup/migration18.md
Normal file
31
bemade_attachments_cleanup/migration18.md
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
# Migration vers Odoo 18.0 - bemade_attachments_cleanup
|
||||||
|
|
||||||
|
## Description
|
||||||
|
Module de nettoyage des pièces jointes obsolètes
|
||||||
|
|
||||||
|
## Fonctionnalités Ajoutées
|
||||||
|
- Suppression automatique des pièces jointes non utilisées
|
||||||
|
- Configuration des règles de nettoyage
|
||||||
|
- Historique des suppressions
|
||||||
|
|
||||||
|
## Modèles et Champs Modifiés
|
||||||
|
- ir.attachment
|
||||||
|
- Ajout du champ cleanup_date (date)
|
||||||
|
- Ajout du champ cleanup_reason (text)
|
||||||
|
|
||||||
|
## Statut Migration
|
||||||
|
- [ ] A migrer
|
||||||
|
- [ ] En cours
|
||||||
|
- [ ] Migré
|
||||||
|
|
||||||
|
## Détails Migration
|
||||||
|
- Vérifier si la fonctionnalité existe déjà dans Odoo 18.0
|
||||||
|
- Analyser les impacts sur les workflows existants
|
||||||
|
|
||||||
|
## Actions Requises
|
||||||
|
- [ ] Vérifier la compatibilité avec Odoo 18.0
|
||||||
|
- [ ] Tester les fonctionnalités
|
||||||
|
- [ ] Mettre à jour la documentation
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Ce module pourrait être remplacé par une configuration native dans Odoo 18.0
|
||||||
31
bemade_helpdesk_mailcow_blacklist/migration18.md
Normal file
31
bemade_helpdesk_mailcow_blacklist/migration18.md
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
# Migration vers Odoo 18.0 - bemade_helpdesk_mailcow_blacklist
|
||||||
|
|
||||||
|
## Description
|
||||||
|
Module d'intégration entre Helpdesk et Mailcow pour la gestion des blacklists
|
||||||
|
|
||||||
|
## Fonctionnalités Ajoutées
|
||||||
|
- Synchronisation des emails blacklistés avec Mailcow
|
||||||
|
- Gestion des règles de blocage
|
||||||
|
- Historique des actions de blacklist
|
||||||
|
|
||||||
|
## Modèles et Champs Modifiés
|
||||||
|
- helpdesk.ticket
|
||||||
|
- Ajout du champ mailcow_blacklisted (boolean)
|
||||||
|
- Ajout du champ mailcow_blacklist_reason (text)
|
||||||
|
|
||||||
|
## Statut Migration
|
||||||
|
- [ ] A migrer
|
||||||
|
- [ ] En cours
|
||||||
|
- [ ] Migré
|
||||||
|
|
||||||
|
## Détails Migration
|
||||||
|
- Vérifier si la fonctionnalité existe déjà dans Odoo 18.0
|
||||||
|
- Analyser les impacts sur les workflows existants
|
||||||
|
|
||||||
|
## Actions Requises
|
||||||
|
- [ ] Vérifier la compatibilité avec Odoo 18.0
|
||||||
|
- [ ] Tester les fonctionnalités
|
||||||
|
- [ ] Mettre à jour la documentation
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Ce module nécessite une configuration spécifique de Mailcow
|
||||||
93
bemade_purchase_warn_supplier_overdue/migration18.md
Normal file
93
bemade_purchase_warn_supplier_overdue/migration18.md
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
# Migration vers Odoo 18.0 - bemade_purchase_warn_supplier_overdue
|
||||||
|
|
||||||
|
## Description
|
||||||
|
Module qui ajoute des avertissements lors de la confirmation des bons de commande pour les fournisseurs ayant des factures en retard.
|
||||||
|
|
||||||
|
## Analyse Technique
|
||||||
|
|
||||||
|
### Fonctionnalités Actuelles
|
||||||
|
1. **Avertissements Automatiques**
|
||||||
|
- Vérification des factures en retard
|
||||||
|
- Création d'activités mail.activity
|
||||||
|
- Configuration par entreprise
|
||||||
|
|
||||||
|
2. **Modèles Modifiés**
|
||||||
|
- `purchase.order` : Ajout de la logique d'avertissement
|
||||||
|
- `res.company` : Configuration des avertissements
|
||||||
|
- `res.config.settings` : Interface de configuration
|
||||||
|
|
||||||
|
3. **Configuration Flexible**
|
||||||
|
- Choix des utilisateurs à notifier
|
||||||
|
- Sélection des fournisseurs concernés
|
||||||
|
- Paramètres par entreprise
|
||||||
|
|
||||||
|
### Changements dans Odoo 18.0
|
||||||
|
|
||||||
|
1. **Architecture Purchase/Mail**
|
||||||
|
- Le système d'activités reste stable
|
||||||
|
- Les bons de commande fonctionnent de la même manière
|
||||||
|
- Les paramètres de configuration sont similaires
|
||||||
|
|
||||||
|
2. **Modifications Nécessaires**
|
||||||
|
- [ ] Adapter les vues pour les nouvelles conventions
|
||||||
|
- [ ] Vérifier la compatibilité des activités
|
||||||
|
- [ ] Mettre à jour les attributs des vues
|
||||||
|
|
||||||
|
## Plan de Migration
|
||||||
|
|
||||||
|
### Phase 1 : Analyse et Préparation
|
||||||
|
1. **Révision du Code**
|
||||||
|
- [ ] Vérifier les changements dans mail.activity
|
||||||
|
- [ ] Tester la création d'activités
|
||||||
|
- [ ] Identifier les potentiels conflits
|
||||||
|
|
||||||
|
2. **Tests**
|
||||||
|
- [ ] Créer des cas de test avec différents scénarios
|
||||||
|
- [ ] Documenter le comportement attendu
|
||||||
|
- [ ] Préparer des données de test
|
||||||
|
|
||||||
|
### Phase 2 : Migration
|
||||||
|
1. **Mise à Jour du Code**
|
||||||
|
- [ ] Adapter les vues XML
|
||||||
|
- [ ] Vérifier les dépendances
|
||||||
|
- [ ] Optimiser les requêtes si nécessaire
|
||||||
|
|
||||||
|
2. **Tests et Validation**
|
||||||
|
- [ ] Tester avec différents types de factures
|
||||||
|
- [ ] Vérifier les notifications
|
||||||
|
- [ ] Valider les configurations
|
||||||
|
|
||||||
|
## État de la Migration
|
||||||
|
En cours d'analyse - Migration simple requise
|
||||||
|
|
||||||
|
## Notes Importantes
|
||||||
|
- La fonctionnalité reste pertinente dans Odoo 18.0
|
||||||
|
- Les changements sont mineurs
|
||||||
|
- La logique de base reste la même
|
||||||
|
- Attention à la performance des requêtes
|
||||||
|
|
||||||
|
## Prochaines Étapes
|
||||||
|
1. Valider l'approche avec l'équipe
|
||||||
|
2. Adapter les vues pour Odoo 18.0
|
||||||
|
3. Mettre à jour les tests
|
||||||
|
4. Tester avec différents scénarios
|
||||||
|
|
||||||
|
## Notes de Version
|
||||||
|
- Version originale: 17.0.1.0
|
||||||
|
- Dernière analyse: 26/01/2025
|
||||||
|
|
||||||
|
## Points d'Attention Particuliers
|
||||||
|
1. **Performance**
|
||||||
|
- Optimisation des requêtes de factures
|
||||||
|
- Gestion du cache
|
||||||
|
- Impact sur les grands volumes
|
||||||
|
|
||||||
|
2. **Interface Utilisateur**
|
||||||
|
- Clarté des avertissements
|
||||||
|
- Configuration intuitive
|
||||||
|
- Visibilité des notifications
|
||||||
|
|
||||||
|
3. **Maintenance**
|
||||||
|
- Documentation des configurations
|
||||||
|
- Gestion des cas spéciaux
|
||||||
|
- Logs pour le débogage
|
||||||
93
bemade_pwa_config/migration18.md
Normal file
93
bemade_pwa_config/migration18.md
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
# Migration vers Odoo 18.0 - bemade_pwa_config
|
||||||
|
|
||||||
|
## Description
|
||||||
|
Module qui permet la configuration des paramètres PWA (Progressive Web App) directement depuis l'interface Odoo, incluant la gestion dynamique des icônes d'application.
|
||||||
|
|
||||||
|
## Analyse Technique
|
||||||
|
|
||||||
|
### Fonctionnalités Actuelles
|
||||||
|
1. **Configuration PWA**
|
||||||
|
- Gestion des icônes d'application
|
||||||
|
- Configuration des couleurs
|
||||||
|
- Génération dynamique des icônes
|
||||||
|
|
||||||
|
2. **Modèles Modifiés**
|
||||||
|
- `res.company` : Stockage des configurations
|
||||||
|
- `res.config.settings` : Interface de configuration
|
||||||
|
- Contrôleur pour le manifest.webmanifest
|
||||||
|
|
||||||
|
3. **Fonctionnalités Avancées**
|
||||||
|
- Redimensionnement automatique des icônes
|
||||||
|
- Manifest dynamique par entreprise
|
||||||
|
- Gestion des couleurs de thème
|
||||||
|
|
||||||
|
### Changements dans Odoo 18.0
|
||||||
|
|
||||||
|
1. **Architecture Web**
|
||||||
|
- Le système PWA est toujours supporté
|
||||||
|
- Les routes HTTP restent stables
|
||||||
|
- La gestion des images est similaire
|
||||||
|
|
||||||
|
2. **Modifications Nécessaires**
|
||||||
|
- [ ] Vérifier la compatibilité avec le nouveau framework web
|
||||||
|
- [ ] Adapter les routes HTTP si nécessaire
|
||||||
|
- [ ] Mettre à jour les dépendances PIL
|
||||||
|
|
||||||
|
## Plan de Migration
|
||||||
|
|
||||||
|
### Phase 1 : Analyse et Préparation
|
||||||
|
1. **Révision du Code**
|
||||||
|
- [ ] Vérifier les changements dans le framework web
|
||||||
|
- [ ] Tester le traitement des images
|
||||||
|
- [ ] Identifier les potentiels conflits
|
||||||
|
|
||||||
|
2. **Tests**
|
||||||
|
- [ ] Créer des cas de test pour les icônes
|
||||||
|
- [ ] Documenter le comportement attendu
|
||||||
|
- [ ] Préparer des données de test
|
||||||
|
|
||||||
|
### Phase 2 : Migration
|
||||||
|
1. **Mise à Jour du Code**
|
||||||
|
- [ ] Adapter les vues XML
|
||||||
|
- [ ] Vérifier les dépendances Python
|
||||||
|
- [ ] Optimiser le traitement des images
|
||||||
|
|
||||||
|
2. **Tests et Validation**
|
||||||
|
- [ ] Tester avec différentes tailles d'icônes
|
||||||
|
- [ ] Vérifier le manifest généré
|
||||||
|
- [ ] Valider sur différents navigateurs
|
||||||
|
|
||||||
|
## État de la Migration
|
||||||
|
En cours d'analyse - Migration simple requise
|
||||||
|
|
||||||
|
## Notes Importantes
|
||||||
|
- La fonctionnalité reste pertinente dans Odoo 18.0
|
||||||
|
- Les changements sont mineurs
|
||||||
|
- La logique de base reste la même
|
||||||
|
- Attention à la performance du traitement d'images
|
||||||
|
|
||||||
|
## Prochaines Étapes
|
||||||
|
1. Valider l'approche avec l'équipe
|
||||||
|
2. Vérifier les changements du framework web
|
||||||
|
3. Mettre à jour les tests
|
||||||
|
4. Tester sur différents navigateurs
|
||||||
|
|
||||||
|
## Notes de Version
|
||||||
|
- Version originale: 18.0.0.1.0
|
||||||
|
- Dernière analyse: 26/01/2025
|
||||||
|
|
||||||
|
## Points d'Attention Particuliers
|
||||||
|
1. **Performance**
|
||||||
|
- Optimisation du traitement d'images
|
||||||
|
- Mise en cache du manifest
|
||||||
|
- Gestion des ressources
|
||||||
|
|
||||||
|
2. **Compatibilité**
|
||||||
|
- Support des navigateurs
|
||||||
|
- Versions de PIL/Pillow
|
||||||
|
- Standards PWA
|
||||||
|
|
||||||
|
3. **Maintenance**
|
||||||
|
- Documentation des configurations
|
||||||
|
- Gestion des cas spéciaux
|
||||||
|
- Logs pour le débogage
|
||||||
93
bemade_time_off_follower/migration18.md
Normal file
93
bemade_time_off_follower/migration18.md
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
# Migration vers Odoo 18.0 - bemade_time_off_follower
|
||||||
|
|
||||||
|
## Description
|
||||||
|
Module qui permet d'ajouter un abonné alternatif qui recevra une copie des communications pendant les congés d'un employé.
|
||||||
|
|
||||||
|
## Analyse Technique
|
||||||
|
|
||||||
|
### Fonctionnalités Actuelles
|
||||||
|
1. **Gestion des Abonnés Alternatifs**
|
||||||
|
- Champ pour définir l'abonné alternatif
|
||||||
|
- Notification automatique pendant les congés
|
||||||
|
- Gestion des destinataires des messages
|
||||||
|
|
||||||
|
2. **Modèles Modifiés**
|
||||||
|
- `hr.leave` : Ajout du champ alternate_follower_id
|
||||||
|
- `mail.thread` : Surcharge de _notify_get_recipients
|
||||||
|
- Intégration avec le système de messagerie
|
||||||
|
|
||||||
|
3. **Logique de Notification**
|
||||||
|
- Vérification des congés en cours
|
||||||
|
- Ajout des abonnés alternatifs
|
||||||
|
- Gestion des doublons
|
||||||
|
|
||||||
|
### Changements dans Odoo 18.0
|
||||||
|
|
||||||
|
1. **Architecture Mail/HR**
|
||||||
|
- Le système de notification reste stable
|
||||||
|
- Les congés fonctionnent de la même manière
|
||||||
|
- L'API de messagerie est similaire
|
||||||
|
|
||||||
|
2. **Modifications Nécessaires**
|
||||||
|
- [ ] Vérifier la compatibilité avec le nouveau système de messagerie
|
||||||
|
- [ ] Valider la méthode _notify_get_recipients
|
||||||
|
- [ ] Optimiser la recherche des congés
|
||||||
|
|
||||||
|
## Plan de Migration
|
||||||
|
|
||||||
|
### Phase 1 : Analyse et Préparation
|
||||||
|
1. **Révision du Code**
|
||||||
|
- [ ] Vérifier les changements dans l'API de messagerie
|
||||||
|
- [ ] Tester les notifications
|
||||||
|
- [ ] Identifier les potentiels conflits
|
||||||
|
|
||||||
|
2. **Tests**
|
||||||
|
- [ ] Créer des cas de test avec différents scénarios
|
||||||
|
- [ ] Documenter le comportement attendu
|
||||||
|
- [ ] Préparer des données de test
|
||||||
|
|
||||||
|
### Phase 2 : Migration
|
||||||
|
1. **Mise à Jour du Code**
|
||||||
|
- [ ] Adapter le code Python
|
||||||
|
- [ ] Vérifier les dépendances
|
||||||
|
- [ ] Optimiser les requêtes si nécessaire
|
||||||
|
|
||||||
|
2. **Tests et Validation**
|
||||||
|
- [ ] Tester avec différents types de congés
|
||||||
|
- [ ] Vérifier les notifications
|
||||||
|
- [ ] Valider la gestion des abonnés
|
||||||
|
|
||||||
|
## État de la Migration
|
||||||
|
En cours d'analyse - Migration simple requise
|
||||||
|
|
||||||
|
## Notes Importantes
|
||||||
|
- La fonctionnalité reste pertinente dans Odoo 18.0
|
||||||
|
- Les changements sont mineurs
|
||||||
|
- La logique de base reste la même
|
||||||
|
- Attention à la performance des requêtes
|
||||||
|
|
||||||
|
## Prochaines Étapes
|
||||||
|
1. Valider l'approche avec l'équipe
|
||||||
|
2. Vérifier les changements dans l'API de messagerie
|
||||||
|
3. Mettre à jour les tests
|
||||||
|
4. Tester avec différents scénarios
|
||||||
|
|
||||||
|
## Notes de Version
|
||||||
|
- Version originale: 17.0.0.0.3
|
||||||
|
- Dernière analyse: 26/01/2025
|
||||||
|
|
||||||
|
## Points d'Attention Particuliers
|
||||||
|
1. **Performance**
|
||||||
|
- Optimisation des recherches
|
||||||
|
- Gestion des notifications en masse
|
||||||
|
- Impact sur les grands volumes
|
||||||
|
|
||||||
|
2. **Fiabilité**
|
||||||
|
- Gestion des erreurs
|
||||||
|
- Validation des abonnements
|
||||||
|
- Cohérence des données
|
||||||
|
|
||||||
|
3. **Maintenance**
|
||||||
|
- Documentation du comportement
|
||||||
|
- Gestion des cas spéciaux
|
||||||
|
- Logs pour le débogage
|
||||||
93
bemade_user_password_bundle/migration18.md
Normal file
93
bemade_user_password_bundle/migration18.md
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
# Migration vers Odoo 18.0 - bemade_user_password_bundle
|
||||||
|
|
||||||
|
## Description
|
||||||
|
Module qui automatise la création de bundles de mots de passe pour les nouveaux utilisateurs et modifie la propriété par défaut du bundle admin.
|
||||||
|
|
||||||
|
## Analyse Technique
|
||||||
|
|
||||||
|
### Fonctionnalités Actuelles
|
||||||
|
1. **Création Automatique**
|
||||||
|
- Bundle créé à la création d'un employé
|
||||||
|
- Attribution des accès automatique
|
||||||
|
- Gestion des droits d'administration
|
||||||
|
|
||||||
|
2. **Modèles Modifiés**
|
||||||
|
- `hr.employee` : Surcharge de create
|
||||||
|
- `password.bundle` : Modification des accès par défaut
|
||||||
|
- Intégration avec odoo_password_manager
|
||||||
|
|
||||||
|
3. **Logique d'Accès**
|
||||||
|
- Accès admin par défaut au groupe system
|
||||||
|
- Accès complet pour le nouvel employé
|
||||||
|
- Notes automatiques dans le bundle
|
||||||
|
|
||||||
|
### Changements dans Odoo 18.0
|
||||||
|
|
||||||
|
1. **Architecture Password/HR**
|
||||||
|
- Le système de gestion des mots de passe reste stable
|
||||||
|
- Les employés fonctionnent de la même manière
|
||||||
|
- Les groupes de sécurité sont similaires
|
||||||
|
|
||||||
|
2. **Modifications Nécessaires**
|
||||||
|
- [ ] Vérifier la compatibilité avec odoo_password_manager
|
||||||
|
- [ ] Valider la méthode de création des bundles
|
||||||
|
- [ ] Optimiser la gestion des accès
|
||||||
|
|
||||||
|
## Plan de Migration
|
||||||
|
|
||||||
|
### Phase 1 : Analyse et Préparation
|
||||||
|
1. **Révision du Code**
|
||||||
|
- [ ] Vérifier les changements dans odoo_password_manager
|
||||||
|
- [ ] Tester la création des bundles
|
||||||
|
- [ ] Identifier les potentiels conflits
|
||||||
|
|
||||||
|
2. **Tests**
|
||||||
|
- [ ] Créer des cas de test avec différents scénarios
|
||||||
|
- [ ] Documenter le comportement attendu
|
||||||
|
- [ ] Préparer des données de test
|
||||||
|
|
||||||
|
### Phase 2 : Migration
|
||||||
|
1. **Mise à Jour du Code**
|
||||||
|
- [ ] Adapter le code Python
|
||||||
|
- [ ] Vérifier les dépendances
|
||||||
|
- [ ] Optimiser les requêtes si nécessaire
|
||||||
|
|
||||||
|
2. **Tests et Validation**
|
||||||
|
- [ ] Tester avec différents types d'employés
|
||||||
|
- [ ] Vérifier les accès
|
||||||
|
- [ ] Valider la sécurité
|
||||||
|
|
||||||
|
## État de la Migration
|
||||||
|
En cours d'analyse - Migration simple requise
|
||||||
|
|
||||||
|
## Notes Importantes
|
||||||
|
- La fonctionnalité reste pertinente dans Odoo 18.0
|
||||||
|
- Les changements sont mineurs
|
||||||
|
- La logique de base reste la même
|
||||||
|
- Attention à la sécurité des accès
|
||||||
|
|
||||||
|
## Prochaines Étapes
|
||||||
|
1. Valider l'approche avec l'équipe
|
||||||
|
2. Vérifier les changements dans odoo_password_manager
|
||||||
|
3. Mettre à jour les tests
|
||||||
|
4. Tester avec différents scénarios
|
||||||
|
|
||||||
|
## Notes de Version
|
||||||
|
- Version originale: 17.0.0.1
|
||||||
|
- Dernière analyse: 26/01/2025
|
||||||
|
|
||||||
|
## Points d'Attention Particuliers
|
||||||
|
1. **Sécurité**
|
||||||
|
- Gestion des accès
|
||||||
|
- Protection des données
|
||||||
|
- Audit des modifications
|
||||||
|
|
||||||
|
2. **Fiabilité**
|
||||||
|
- Gestion des erreurs
|
||||||
|
- Validation des accès
|
||||||
|
- Cohérence des données
|
||||||
|
|
||||||
|
3. **Maintenance**
|
||||||
|
- Documentation des accès
|
||||||
|
- Gestion des cas spéciaux
|
||||||
|
- Logs pour le débogage
|
||||||
|
|
@ -223,9 +223,7 @@ class CalendarEvent(models.Model):
|
||||||
{"caldav_uid": caldav_uid}
|
{"caldav_uid": caldav_uid}
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.error(
|
_logger.error(f"Failed to sync event to CalDAV server: {e}")
|
||||||
f"Failed to sync event to CalDAV server: {e}", exc_info=True
|
|
||||||
)
|
|
||||||
|
|
||||||
def write(self, vals):
|
def write(self, vals):
|
||||||
res = super(CalendarEvent, self.with_context(caldav_no_sync=True)).write(vals)
|
res = super(CalendarEvent, self.with_context(caldav_no_sync=True)).write(vals)
|
||||||
|
|
@ -335,9 +333,8 @@ class CalendarEvent(models.Model):
|
||||||
) -> Optional[int]:
|
) -> Optional[int]:
|
||||||
ical_instance = caldav_event.icalendar_instance
|
ical_instance = caldav_event.icalendar_instance
|
||||||
for index, component in enumerate(ical_instance.subcomponents):
|
for index, component in enumerate(ical_instance.subcomponents):
|
||||||
if (
|
if component.get("name") == "VEVENT" and (
|
||||||
component.get("name") == "VEVENT"
|
rec_id := component.get("recurrence-id")
|
||||||
and (rec_id := component.get("recurrence-id"))
|
|
||||||
and rec_id.dt == self._get_ical_recurrence_id()
|
and rec_id.dt == self._get_ical_recurrence_id()
|
||||||
):
|
):
|
||||||
return index
|
return index
|
||||||
|
|
@ -350,6 +347,7 @@ class CalendarEvent(models.Model):
|
||||||
calendar = client.calendar(url=user.caldav_calendar_url)
|
calendar = client.calendar(url=user.caldav_calendar_url)
|
||||||
try:
|
try:
|
||||||
caldav_event = calendar.event_by_uid(self.caldav_uid)
|
caldav_event = calendar.event_by_uid(self.caldav_uid)
|
||||||
|
assert isinstance(caldav_event, caldav.Event)
|
||||||
if not delete_all and self.recurrence_id and not self.is_base_event:
|
if not delete_all and self.recurrence_id and not self.is_base_event:
|
||||||
index = self._get_subcomponent_index_for_recurrence(
|
index = self._get_subcomponent_index_for_recurrence(
|
||||||
caldav_event
|
caldav_event
|
||||||
|
|
@ -490,12 +488,7 @@ class CalendarEvent(models.Model):
|
||||||
|
|
||||||
def _add_event_dates(self, event_data: Dict) -> None:
|
def _add_event_dates(self, event_data: Dict) -> None:
|
||||||
"""Add pertinent dates to event data, based on self."""
|
"""Add pertinent dates to event data, based on self."""
|
||||||
# Determine timezone: prefer event_tz, then user tz, finally UTC
|
tz = self.event_tz or self.env.user.tz
|
||||||
# Note: All datetimes in Odoo are stored in UTC, so defaulting to UTC is correct.
|
|
||||||
# UTC times are sent in from the appointments app when installed, without
|
|
||||||
# timezone information. This was breaking the sync process due to a call to
|
|
||||||
# upper() on boolean value False.
|
|
||||||
tz = self.event_tz or self.env.user.tz or "UTC"
|
|
||||||
event_tz = timezone(tz)
|
event_tz = timezone(tz)
|
||||||
event_data["last-modified"] = vDatetime(
|
event_data["last-modified"] = vDatetime(
|
||||||
utc.localize(self.write_date).astimezone(event_tz)
|
utc.localize(self.write_date).astimezone(event_tz)
|
||||||
|
|
|
||||||
22
helpdesk_sale_order/migration18.md
Normal file
22
helpdesk_sale_order/migration18.md
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
# Migration de durpro_helpdesk_sale vers Odoo 18.0
|
||||||
|
|
||||||
|
## Description
|
||||||
|
Module d'intégration entre le helpdesk et les ventes pour Durpro.
|
||||||
|
|
||||||
|
## Fonctionnalités Ajoutées
|
||||||
|
|
||||||
|
### Création de commandes de vente depuis les tickets
|
||||||
|
- Vérification Odoo 18.0 : À vérifier
|
||||||
|
- Différences avec la version native : À documenter
|
||||||
|
- Alternatives disponibles : À identifier
|
||||||
|
|
||||||
|
## Modèles et Champs Modifiés
|
||||||
|
|
||||||
|
### Modèle HelpdeskTicket
|
||||||
|
- Ajouts/Modifications : À documenter
|
||||||
|
- Recherche dans le projet : À effectuer
|
||||||
|
- Existence dans Odoo standard/enterprise : À vérifier
|
||||||
|
- Recommandations de migration : À formuler
|
||||||
|
|
||||||
|
## Vues à Modifier
|
||||||
|
- Liste des vues tree à convertir en list : À identifier
|
||||||
2
k8s_odoo_manager/__init__.py
Normal file
2
k8s_odoo_manager/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
from . import models
|
||||||
|
from . import wizards
|
||||||
63
k8s_odoo_manager/__manifest__.py
Normal file
63
k8s_odoo_manager/__manifest__.py
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
{
|
||||||
|
'name': 'Kubernetes Odoo Manager',
|
||||||
|
'version': '18.0.1.0.0',
|
||||||
|
'category': 'Administration/Kubernetes',
|
||||||
|
'summary': 'Manage Odoo instances through Kubernetes operator',
|
||||||
|
'description': """
|
||||||
|
Kubernetes Odoo Manager
|
||||||
|
=======================
|
||||||
|
This module allows you to:
|
||||||
|
* Connect to Kubernetes clusters running the Odoo operator
|
||||||
|
* Manage OdooInstance custom resources
|
||||||
|
* Monitor instance status and health
|
||||||
|
* Perform operations on managed Odoo instances
|
||||||
|
|
||||||
|
Features:
|
||||||
|
* Cluster connection management with secure kubeconfig storage
|
||||||
|
* Real-time OdooInstance discovery and synchronization
|
||||||
|
* Status monitoring and alerting
|
||||||
|
* Centralized management interface for multiple clusters
|
||||||
|
""",
|
||||||
|
'author': 'Bemade Inc.',
|
||||||
|
'website': 'https://www.bemade.org',
|
||||||
|
'license': 'OPL-1',
|
||||||
|
'depends': [
|
||||||
|
'base',
|
||||||
|
'web',
|
||||||
|
'mail',
|
||||||
|
],
|
||||||
|
'data': [
|
||||||
|
# Security
|
||||||
|
'security/k8s_security.xml',
|
||||||
|
'security/ir.model.access.csv',
|
||||||
|
|
||||||
|
# Data
|
||||||
|
'data/k8s_data.xml',
|
||||||
|
|
||||||
|
# Views
|
||||||
|
'views/k8s_cluster_views.xml',
|
||||||
|
'views/k8s_odoo_instance_views.xml',
|
||||||
|
'views/k8s_menu_views.xml',
|
||||||
|
|
||||||
|
# Wizards
|
||||||
|
'wizards/k8s_cluster_test_wizard_views.xml',
|
||||||
|
'wizards/k8s_sync_instances_wizard_views.xml',
|
||||||
|
],
|
||||||
|
'demo': [],
|
||||||
|
'installable': True,
|
||||||
|
'application': True,
|
||||||
|
'auto_install': False,
|
||||||
|
'external_dependencies': {
|
||||||
|
'python': [
|
||||||
|
'kubernetes',
|
||||||
|
'pyyaml',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'assets': {
|
||||||
|
'web.assets_backend': [
|
||||||
|
'k8s_odoo_manager/static/src/css/k8s_manager.css',
|
||||||
|
'k8s_odoo_manager/static/src/js/k8s_dashboard.js',
|
||||||
|
'k8s_odoo_manager/static/src/xml/k8s_dashboard.xml',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
47
k8s_odoo_manager/data/k8s_data.xml
Normal file
47
k8s_odoo_manager/data/k8s_data.xml
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<!-- Default data for the module -->
|
||||||
|
|
||||||
|
<!-- Cron job for periodic sync -->
|
||||||
|
<record id="cron_k8s_sync_instances" model="ir.cron">
|
||||||
|
<field name="name">Sync Kubernetes Odoo Instances</field>
|
||||||
|
<field name="model_id" ref="model_k8s_cluster"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model.cron_sync_all_clusters()</field>
|
||||||
|
<field name="interval_number">15</field>
|
||||||
|
<field name="interval_type">minutes</field>
|
||||||
|
<field name="active" eval="False"/>
|
||||||
|
<field name="user_id" ref="base.user_root"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Server actions -->
|
||||||
|
<record id="action_server_sync_all_clusters" model="ir.actions.server">
|
||||||
|
<field name="name">Sync All Clusters</field>
|
||||||
|
<field name="model_id" ref="model_k8s_cluster"/>
|
||||||
|
<field name="binding_model_id" ref="model_k8s_cluster"/>
|
||||||
|
<field name="binding_view_types">list</field>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">
|
||||||
|
# Sync selected clusters
|
||||||
|
for record in records.filtered('active'):
|
||||||
|
try:
|
||||||
|
record.sync_odoo_instances()
|
||||||
|
except Exception as e:
|
||||||
|
# Continue with other clusters even if one fails
|
||||||
|
pass
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_server_test_cluster_connection" model="ir.actions.server">
|
||||||
|
<field name="name">Test Connection</field>
|
||||||
|
<field name="model_id" ref="model_k8s_cluster"/>
|
||||||
|
<field name="binding_model_id" ref="model_k8s_cluster"/>
|
||||||
|
<field name="binding_view_types">list</field>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">
|
||||||
|
# Test connection for selected clusters
|
||||||
|
for record in records:
|
||||||
|
record.test_connection()
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
2
k8s_odoo_manager/models/__init__.py
Normal file
2
k8s_odoo_manager/models/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
from . import k8s_cluster
|
||||||
|
from . import k8s_odoo_instance
|
||||||
373
k8s_odoo_manager/models/k8s_cluster.py
Normal file
373
k8s_odoo_manager/models/k8s_cluster.py
Normal file
|
|
@ -0,0 +1,373 @@
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import yaml
|
||||||
|
from odoo import api, fields, models, _
|
||||||
|
from odoo.exceptions import ValidationError, UserError
|
||||||
|
from kubernetes import client, config
|
||||||
|
from kubernetes.client.rest import ApiException
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class K8sCluster(models.Model):
|
||||||
|
_name = "k8s.cluster"
|
||||||
|
_description = "Kubernetes Cluster"
|
||||||
|
_inherit = ["mail.thread", "mail.activity.mixin"]
|
||||||
|
_order = "name"
|
||||||
|
|
||||||
|
name = fields.Char(
|
||||||
|
string="Cluster Name",
|
||||||
|
required=True,
|
||||||
|
tracking=True,
|
||||||
|
help="Display name for this Kubernetes cluster",
|
||||||
|
)
|
||||||
|
|
||||||
|
api_endpoint = fields.Char(
|
||||||
|
string="API Endpoint",
|
||||||
|
required=True,
|
||||||
|
help="Kubernetes API server URL (e.g., https://k8s.example.com:6443)",
|
||||||
|
)
|
||||||
|
|
||||||
|
kubeconfig = fields.Text(
|
||||||
|
string="Kubeconfig",
|
||||||
|
required=True,
|
||||||
|
help="Complete kubeconfig YAML content for cluster access",
|
||||||
|
)
|
||||||
|
|
||||||
|
default_namespace = fields.Char(
|
||||||
|
string="Default Namespace",
|
||||||
|
default="default",
|
||||||
|
required=True,
|
||||||
|
help="Default namespace to search for OdooInstances",
|
||||||
|
)
|
||||||
|
|
||||||
|
active = fields.Boolean(
|
||||||
|
string="Active",
|
||||||
|
default=True,
|
||||||
|
tracking=True,
|
||||||
|
help="Enable/disable this cluster connection",
|
||||||
|
)
|
||||||
|
|
||||||
|
last_sync = fields.Datetime(
|
||||||
|
string="Last Sync",
|
||||||
|
readonly=True,
|
||||||
|
help="Timestamp of last successful synchronization",
|
||||||
|
)
|
||||||
|
|
||||||
|
connection_status = fields.Selection(
|
||||||
|
[
|
||||||
|
("unknown", "Unknown"),
|
||||||
|
("connected", "Connected"),
|
||||||
|
("error", "Connection Error"),
|
||||||
|
],
|
||||||
|
string="Connection Status",
|
||||||
|
default="unknown",
|
||||||
|
readonly=True,
|
||||||
|
tracking=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
connection_error = fields.Text(
|
||||||
|
string="Connection Error", readonly=True, help="Last connection error message"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Statistics
|
||||||
|
total_instances = fields.Integer(
|
||||||
|
string="Total Instances", compute="_compute_instance_stats", store=True
|
||||||
|
)
|
||||||
|
|
||||||
|
running_instances = fields.Integer(
|
||||||
|
string="Running Instances", compute="_compute_instance_stats", store=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# SSL Configuration
|
||||||
|
verify_ssl = fields.Boolean(
|
||||||
|
string="Verify SSL Certificate",
|
||||||
|
default=True,
|
||||||
|
help="Disable for clusters with self-signed certificates (development only)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relations
|
||||||
|
instance_ids = fields.One2many(
|
||||||
|
"k8s.odoo.instance", "cluster_id", string="Odoo Instances"
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends("instance_ids.phase")
|
||||||
|
def _compute_instance_stats(self):
|
||||||
|
for cluster in self:
|
||||||
|
cluster.total_instances = len(cluster.instance_ids)
|
||||||
|
cluster.running_instances = len(
|
||||||
|
cluster.instance_ids.filtered(lambda i: i.phase == "Running")
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.constrains("kubeconfig")
|
||||||
|
def _check_kubeconfig(self):
|
||||||
|
"""Validate kubeconfig format"""
|
||||||
|
for record in self:
|
||||||
|
if record.kubeconfig:
|
||||||
|
try:
|
||||||
|
yaml.safe_load(record.kubeconfig)
|
||||||
|
except yaml.YAMLError as e:
|
||||||
|
raise ValidationError(_("Invalid kubeconfig format: %s") % str(e))
|
||||||
|
|
||||||
|
def _get_k8s_client(self):
|
||||||
|
"""Get authenticated Kubernetes client for this cluster"""
|
||||||
|
try:
|
||||||
|
# Create a temporary kubeconfig file in memory
|
||||||
|
kubeconfig_dict = yaml.safe_load(self.kubeconfig)
|
||||||
|
|
||||||
|
# Create a temporary file for the kubeconfig to ensure proper SSL handling
|
||||||
|
import tempfile
|
||||||
|
import os
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", suffix=".yaml", delete=False
|
||||||
|
) as temp_file:
|
||||||
|
yaml.dump(kubeconfig_dict, temp_file)
|
||||||
|
temp_kubeconfig_path = temp_file.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Load configuration from temporary file (better SSL handling)
|
||||||
|
config.load_kube_config(config_file=temp_kubeconfig_path)
|
||||||
|
|
||||||
|
# Get the configuration and ensure SSL verification is properly set
|
||||||
|
configuration = client.Configuration.get_default_copy()
|
||||||
|
|
||||||
|
# Apply SSL verification setting from cluster configuration
|
||||||
|
configuration.verify_ssl = self.verify_ssl
|
||||||
|
if not self.verify_ssl:
|
||||||
|
_logger.warning(
|
||||||
|
f"SSL verification disabled for cluster {self.name} - use only for development!"
|
||||||
|
)
|
||||||
|
|
||||||
|
# If we have certificate-authority-data in kubeconfig, it should be used
|
||||||
|
# The kubernetes client should handle this automatically, but let's ensure it's set
|
||||||
|
if (
|
||||||
|
self.verify_ssl
|
||||||
|
and configuration.ssl_ca_cert is None
|
||||||
|
and "clusters" in kubeconfig_dict
|
||||||
|
):
|
||||||
|
for cluster_info in kubeconfig_dict["clusters"]:
|
||||||
|
if "certificate-authority-data" in cluster_info.get(
|
||||||
|
"cluster", {}
|
||||||
|
):
|
||||||
|
# The CA cert should be handled by load_kube_config, but if not,
|
||||||
|
# we could decode and set it manually here
|
||||||
|
pass
|
||||||
|
|
||||||
|
k8s_client = client.ApiClient(configuration)
|
||||||
|
return k8s_client
|
||||||
|
finally:
|
||||||
|
# Clean up temporary file
|
||||||
|
os.unlink(temp_kubeconfig_path)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(f"Failed to create K8s client for cluster {self.name}: {e}")
|
||||||
|
raise UserError(_("Failed to connect to cluster: %s") % str(e))
|
||||||
|
|
||||||
|
def test_connection(self):
|
||||||
|
"""Test connection to Kubernetes cluster"""
|
||||||
|
self.ensure_one()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get client and test basic connectivity
|
||||||
|
k8s_client = self._get_k8s_client()
|
||||||
|
v1 = client.CoreV1Api(k8s_client)
|
||||||
|
|
||||||
|
# Try to list namespaces as a connectivity test
|
||||||
|
namespaces = v1.list_namespace()
|
||||||
|
|
||||||
|
# Update connection status
|
||||||
|
self.write(
|
||||||
|
{
|
||||||
|
"connection_status": "connected",
|
||||||
|
"connection_error": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": "ir.actions.client",
|
||||||
|
"tag": "display_notification",
|
||||||
|
"params": {
|
||||||
|
"title": _("Connection Successful"),
|
||||||
|
"message": _(
|
||||||
|
"Successfully connected to cluster %s. Found %d namespaces."
|
||||||
|
)
|
||||||
|
% (self.name, len(namespaces.items)),
|
||||||
|
"type": "success",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = str(e)
|
||||||
|
_logger.error(
|
||||||
|
f"Connection test failed for cluster {self.name}: {error_msg}"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.write(
|
||||||
|
{
|
||||||
|
"connection_status": "error",
|
||||||
|
"connection_error": error_msg,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": "ir.actions.client",
|
||||||
|
"tag": "display_notification",
|
||||||
|
"params": {
|
||||||
|
"title": _("Connection Failed"),
|
||||||
|
"message": _("Failed to connect to cluster %s: %s")
|
||||||
|
% (self.name, error_msg),
|
||||||
|
"type": "danger",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def sync_odoo_instances(self):
|
||||||
|
"""Synchronize OdooInstances from this cluster"""
|
||||||
|
self.ensure_one()
|
||||||
|
|
||||||
|
if not self.active:
|
||||||
|
raise UserError(_("Cluster is not active"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
k8s_client = self._get_k8s_client()
|
||||||
|
custom_api = client.CustomObjectsApi(k8s_client)
|
||||||
|
|
||||||
|
# Get all OdooInstances from the cluster
|
||||||
|
instances = custom_api.list_cluster_custom_object(
|
||||||
|
group="bemade.org", # pyright: ignore
|
||||||
|
version="v1",
|
||||||
|
plural="odooinstances",
|
||||||
|
)
|
||||||
|
|
||||||
|
synced_count = 0
|
||||||
|
|
||||||
|
for item in instances.get("items", []):
|
||||||
|
metadata = item.get("metadata", {})
|
||||||
|
spec = item.get("spec", {})
|
||||||
|
name = metadata.get("name")
|
||||||
|
namespace = metadata.get("namespace")
|
||||||
|
|
||||||
|
# Fetch status separately using the status subresource
|
||||||
|
status = {}
|
||||||
|
try:
|
||||||
|
status_obj = custom_api.get_namespaced_custom_object_status(
|
||||||
|
group="bemade.org",
|
||||||
|
version="v1",
|
||||||
|
namespace=namespace,
|
||||||
|
plural="odooinstances",
|
||||||
|
name=name,
|
||||||
|
)
|
||||||
|
status = (
|
||||||
|
status_obj.get("status", {})
|
||||||
|
if isinstance(status_obj, dict)
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
_logger.info(
|
||||||
|
f"Fetched status for {name}: {list(status.keys()) if isinstance(status, dict) else 'not dict'}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.warning(f"Could not fetch status for {name}: {e}")
|
||||||
|
# Fall back to status from list (might be empty)
|
||||||
|
status = item.get("status", {})
|
||||||
|
|
||||||
|
# Find or create the instance record
|
||||||
|
instance = self.env["k8s.odoo.instance"].search(
|
||||||
|
[
|
||||||
|
("cluster_id", "=", self.id),
|
||||||
|
("name", "=", name),
|
||||||
|
("namespace", "=", namespace),
|
||||||
|
],
|
||||||
|
limit=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Prepare instance data
|
||||||
|
instance_data = {
|
||||||
|
"cluster_id": self.id,
|
||||||
|
"name": name,
|
||||||
|
"namespace": namespace,
|
||||||
|
"spec": json.dumps(spec, indent=2),
|
||||||
|
"status": json.dumps(status, indent=2),
|
||||||
|
"phase": status.get("phase", "Unknown"),
|
||||||
|
"url": status.get("url", ""),
|
||||||
|
"last_updated": fields.Datetime.now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if instance:
|
||||||
|
instance.write(instance_data)
|
||||||
|
else:
|
||||||
|
self.env["k8s.odoo.instance"].create(instance_data)
|
||||||
|
|
||||||
|
synced_count += 1
|
||||||
|
|
||||||
|
# Update last sync time
|
||||||
|
self.write(
|
||||||
|
{
|
||||||
|
"last_sync": fields.Datetime.now(),
|
||||||
|
"connection_status": "connected",
|
||||||
|
"connection_error": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": "ir.actions.client",
|
||||||
|
"tag": "display_notification",
|
||||||
|
"params": {
|
||||||
|
"title": _("Sync Successful"),
|
||||||
|
"message": _("Synchronized %d OdooInstances from cluster %s")
|
||||||
|
% (synced_count, self.name),
|
||||||
|
"type": "success",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = str(e)
|
||||||
|
_logger.error(f"Sync failed for cluster {self.name}: {error_msg}")
|
||||||
|
|
||||||
|
self.write(
|
||||||
|
{
|
||||||
|
"connection_status": "error",
|
||||||
|
"connection_error": error_msg,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
raise UserError(_("Sync failed: %s") % error_msg)
|
||||||
|
|
||||||
|
def action_sync_instances(self):
|
||||||
|
"""Action to sync instances from UI"""
|
||||||
|
return self.sync_odoo_instances()
|
||||||
|
|
||||||
|
def action_test_connection(self):
|
||||||
|
"""Action to test connection from UI"""
|
||||||
|
return self.test_connection()
|
||||||
|
|
||||||
|
def action_view_instances(self):
|
||||||
|
"""Action to view instances for this cluster"""
|
||||||
|
self.ensure_one()
|
||||||
|
return {
|
||||||
|
"name": _("Odoo Instances"),
|
||||||
|
"type": "ir.actions.act_window",
|
||||||
|
"res_model": "k8s.odoo.instance",
|
||||||
|
"view_mode": "list,form",
|
||||||
|
"domain": [("cluster_id", "=", self.id)],
|
||||||
|
"context": {"default_cluster_id": self.id},
|
||||||
|
}
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def cron_sync_all_clusters(self):
|
||||||
|
"""Cron method to sync all active clusters"""
|
||||||
|
clusters = self.search([("active", "=", True)])
|
||||||
|
for cluster in clusters:
|
||||||
|
try:
|
||||||
|
cluster.sync_odoo_instances()
|
||||||
|
_logger.info(f"Successfully synced cluster: {cluster.name}")
|
||||||
|
except Exception as e:
|
||||||
|
# Log error but continue with other clusters
|
||||||
|
_logger.error(f"Failed to sync cluster {cluster.name}: {e}")
|
||||||
|
# Update connection status to error
|
||||||
|
cluster.write(
|
||||||
|
{
|
||||||
|
"connection_status": "error",
|
||||||
|
"connection_error": str(e),
|
||||||
|
}
|
||||||
|
)
|
||||||
520
k8s_odoo_manager/models/k8s_odoo_instance.py
Normal file
520
k8s_odoo_manager/models/k8s_odoo_instance.py
Normal file
|
|
@ -0,0 +1,520 @@
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from odoo import api, fields, models, _
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
from kubernetes import client
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class K8sOdooInstance(models.Model):
|
||||||
|
_name = "k8s.odoo.instance"
|
||||||
|
_description = "Kubernetes Odoo Instance"
|
||||||
|
_inherit = ["mail.thread"]
|
||||||
|
_order = "cluster_id, namespace, name"
|
||||||
|
|
||||||
|
name = fields.Char(
|
||||||
|
string="Instance Name",
|
||||||
|
required=True,
|
||||||
|
readonly=True,
|
||||||
|
help="Name of the OdooInstance resource in Kubernetes",
|
||||||
|
)
|
||||||
|
|
||||||
|
cluster_id = fields.Many2one(
|
||||||
|
"k8s.cluster",
|
||||||
|
string="Cluster",
|
||||||
|
required=True,
|
||||||
|
readonly=True,
|
||||||
|
ondelete="cascade",
|
||||||
|
)
|
||||||
|
|
||||||
|
namespace = fields.Char(
|
||||||
|
string="Namespace",
|
||||||
|
required=True,
|
||||||
|
readonly=True,
|
||||||
|
help="Kubernetes namespace containing this instance",
|
||||||
|
)
|
||||||
|
|
||||||
|
spec = fields.Text(
|
||||||
|
string="Specification",
|
||||||
|
readonly=True,
|
||||||
|
help="Complete OdooInstance specification as JSON",
|
||||||
|
)
|
||||||
|
|
||||||
|
status = fields.Text(
|
||||||
|
string="Status", readonly=True, help="Current OdooInstance status as JSON"
|
||||||
|
)
|
||||||
|
|
||||||
|
phase = fields.Selection(
|
||||||
|
[
|
||||||
|
("Running", "Running"),
|
||||||
|
("Upgrading", "Upgrading"),
|
||||||
|
("Restoring", "Restoring"),
|
||||||
|
("Unknown", "Unknown"),
|
||||||
|
],
|
||||||
|
string="Phase",
|
||||||
|
default="Unknown",
|
||||||
|
readonly=True,
|
||||||
|
tracking=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
url = fields.Char(
|
||||||
|
string="URL", readonly=True, help="Access URL for this Odoo instance"
|
||||||
|
)
|
||||||
|
|
||||||
|
last_updated = fields.Datetime(
|
||||||
|
string="Last Updated",
|
||||||
|
readonly=True,
|
||||||
|
help="Last time this record was synchronized from Kubernetes",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Real-time computed fields from cluster (always fresh)
|
||||||
|
current_image = fields.Char(
|
||||||
|
string="Current Image", compute="_compute_current_values"
|
||||||
|
)
|
||||||
|
current_replicas = fields.Integer(
|
||||||
|
string="Current Replicas", compute="_compute_current_values"
|
||||||
|
)
|
||||||
|
current_ingress_hosts = fields.Text(
|
||||||
|
string="Current Ingress Hosts", compute="_compute_current_values"
|
||||||
|
)
|
||||||
|
current_cpu_request = fields.Char(
|
||||||
|
string="Current CPU Request", compute="_compute_current_values"
|
||||||
|
)
|
||||||
|
current_cpu_limit = fields.Char(
|
||||||
|
string="Current CPU Limit", compute="_compute_current_values"
|
||||||
|
)
|
||||||
|
current_memory_request = fields.Char(
|
||||||
|
string="Current Memory Request", compute="_compute_current_values"
|
||||||
|
)
|
||||||
|
current_memory_limit = fields.Char(
|
||||||
|
string="Current Memory Limit", compute="_compute_current_values"
|
||||||
|
)
|
||||||
|
current_ingress_enabled = fields.Boolean(
|
||||||
|
string="Current Ingress Enabled", compute="_compute_current_values"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Editable spec fields (will sync back to cluster)
|
||||||
|
image_editable = fields.Char(
|
||||||
|
string="Docker Image", help="Docker image for the Odoo instance"
|
||||||
|
)
|
||||||
|
|
||||||
|
replicas_editable = fields.Integer(
|
||||||
|
string="Replicas", default=1, help="Number of replicas to run"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Resource fields
|
||||||
|
cpu_request = fields.Char(
|
||||||
|
string="CPU Request", help="CPU request (e.g., '100m', '0.5')"
|
||||||
|
)
|
||||||
|
|
||||||
|
cpu_limit = fields.Char(string="CPU Limit", help="CPU limit (e.g., '1000m', '2')")
|
||||||
|
|
||||||
|
memory_request = fields.Char(
|
||||||
|
string="Memory Request", help="Memory request (e.g., '512Mi', '1Gi')"
|
||||||
|
)
|
||||||
|
|
||||||
|
memory_limit = fields.Char(
|
||||||
|
string="Memory Limit", help="Memory limit (e.g., '1Gi', '2Gi')"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ingress fields
|
||||||
|
ingress_enabled = fields.Boolean(string="Enable Ingress", default=True)
|
||||||
|
|
||||||
|
ingress_hosts_editable = fields.Text(
|
||||||
|
string="Ingress Hosts", help="One host per line"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filestore fields
|
||||||
|
filestore_enabled = fields.Boolean(string="Enable Filestore", default=True)
|
||||||
|
|
||||||
|
filestore_size = fields.Char(
|
||||||
|
string="Filestore Size",
|
||||||
|
default="10Gi",
|
||||||
|
help="Size of the filestore PVC (e.g., '10Gi', '50Gi')",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Status fields
|
||||||
|
ready_replicas = fields.Integer(
|
||||||
|
string="Ready Replicas", compute="_compute_status_fields", store=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Computed fields to detect changes
|
||||||
|
has_pending_changes = fields.Boolean(
|
||||||
|
string="Has Pending Changes", compute="_compute_pending_changes"
|
||||||
|
)
|
||||||
|
|
||||||
|
image_changed = fields.Boolean(
|
||||||
|
string="Image Changed", compute="_compute_pending_changes"
|
||||||
|
)
|
||||||
|
|
||||||
|
replicas_changed = fields.Boolean(
|
||||||
|
string="Replicas Changed", compute="_compute_pending_changes"
|
||||||
|
)
|
||||||
|
|
||||||
|
available_replicas = fields.Integer(
|
||||||
|
string="Available Replicas", compute="_compute_status_fields", store=True
|
||||||
|
)
|
||||||
|
|
||||||
|
ingress_url = fields.Char(
|
||||||
|
string="Ingress URL", compute="_compute_status_fields", store=True
|
||||||
|
)
|
||||||
|
|
||||||
|
conditions = fields.Text(
|
||||||
|
string="Conditions", compute="_compute_status_fields", store=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def _compute_current_values(self):
|
||||||
|
"""Fetch current values from cluster in real-time"""
|
||||||
|
for instance in self:
|
||||||
|
# Initialize with empty values
|
||||||
|
instance.current_image = ""
|
||||||
|
instance.current_replicas = 0
|
||||||
|
instance.current_ingress_hosts = ""
|
||||||
|
instance.current_cpu_request = ""
|
||||||
|
instance.current_cpu_limit = ""
|
||||||
|
instance.current_memory_request = ""
|
||||||
|
instance.current_memory_limit = ""
|
||||||
|
instance.current_ingress_enabled = False
|
||||||
|
|
||||||
|
if not instance.cluster_id or not instance.cluster_id.active:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
k8s_client = instance.cluster_id._get_k8s_client()
|
||||||
|
custom_api = client.CustomObjectsApi(k8s_client)
|
||||||
|
|
||||||
|
# Fetch the current object from cluster
|
||||||
|
obj = custom_api.get_namespaced_custom_object(
|
||||||
|
group="bemade.org",
|
||||||
|
version="v1",
|
||||||
|
namespace=instance.namespace,
|
||||||
|
plural="odooinstances",
|
||||||
|
name=instance.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract spec values
|
||||||
|
spec = obj.get("spec", {})
|
||||||
|
instance.current_image = spec.get("image", "")
|
||||||
|
instance.current_replicas = spec.get("replicas", 0)
|
||||||
|
|
||||||
|
# Extract ingress
|
||||||
|
ingress = spec.get("ingress", {})
|
||||||
|
instance.current_ingress_enabled = ingress.get("enabled", False)
|
||||||
|
hosts = ingress.get("hosts", [])
|
||||||
|
instance.current_ingress_hosts = ", ".join(hosts) if hosts else ""
|
||||||
|
|
||||||
|
# Extract resources
|
||||||
|
resources = spec.get("resources", {})
|
||||||
|
requests = resources.get("requests", {})
|
||||||
|
limits = resources.get("limits", {})
|
||||||
|
instance.current_cpu_request = requests.get("cpu", "")
|
||||||
|
instance.current_memory_request = requests.get("memory", "")
|
||||||
|
instance.current_cpu_limit = limits.get("cpu", "")
|
||||||
|
instance.current_memory_limit = limits.get("memory", "")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_logger.warning(
|
||||||
|
f"Could not fetch current values for {instance.name}: {e}"
|
||||||
|
)
|
||||||
|
# Values remain empty as initialized above
|
||||||
|
|
||||||
|
@api.depends("status")
|
||||||
|
def _compute_status_fields(self):
|
||||||
|
"""Extract status information from status JSON"""
|
||||||
|
for instance in self:
|
||||||
|
if instance.status:
|
||||||
|
try:
|
||||||
|
status_data = json.loads(instance.status)
|
||||||
|
|
||||||
|
# Extract replica counts
|
||||||
|
instance.ready_replicas = status_data.get("readyReplicas", 0)
|
||||||
|
instance.available_replicas = status_data.get(
|
||||||
|
"availableReplicas", 0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract ingress URL
|
||||||
|
ingress_status = status_data.get("ingress", {})
|
||||||
|
urls = ingress_status.get("urls", [])
|
||||||
|
instance.ingress_url = urls[0] if urls else ""
|
||||||
|
|
||||||
|
# Extract conditions
|
||||||
|
conditions = status_data.get("conditions", [])
|
||||||
|
condition_strings = []
|
||||||
|
for condition in conditions:
|
||||||
|
ctype = condition.get("type", "")
|
||||||
|
status = condition.get("status", "")
|
||||||
|
reason = condition.get("reason", "")
|
||||||
|
condition_strings.append(f"{ctype}: {status} ({reason})")
|
||||||
|
instance.conditions = "\n".join(condition_strings)
|
||||||
|
|
||||||
|
except (json.JSONDecodeError, KeyError) as e:
|
||||||
|
_logger.warning(
|
||||||
|
f"Failed to parse status for instance {instance.name}: {e}"
|
||||||
|
)
|
||||||
|
instance.ready_replicas = 0
|
||||||
|
instance.available_replicas = 0
|
||||||
|
instance.ingress_url = ""
|
||||||
|
instance.conditions = ""
|
||||||
|
else:
|
||||||
|
instance.ready_replicas = 0
|
||||||
|
instance.available_replicas = 0
|
||||||
|
instance.ingress_url = ""
|
||||||
|
instance.conditions = ""
|
||||||
|
|
||||||
|
@api.depends(
|
||||||
|
"image_editable", "current_image", "replicas_editable", "current_replicas"
|
||||||
|
)
|
||||||
|
def _compute_pending_changes(self):
|
||||||
|
"""Compute if there are pending changes to sync"""
|
||||||
|
for instance in self:
|
||||||
|
image_changed = bool(
|
||||||
|
instance.image_editable
|
||||||
|
and instance.image_editable != instance.current_image
|
||||||
|
)
|
||||||
|
replicas_changed = bool(
|
||||||
|
instance.replicas_editable
|
||||||
|
and instance.replicas_editable != instance.current_replicas
|
||||||
|
)
|
||||||
|
|
||||||
|
instance.image_changed = image_changed
|
||||||
|
instance.replicas_changed = replicas_changed
|
||||||
|
instance.has_pending_changes = image_changed or replicas_changed
|
||||||
|
|
||||||
|
def name_get(self):
|
||||||
|
"""Custom name display"""
|
||||||
|
result = []
|
||||||
|
for instance in self:
|
||||||
|
name = f"{instance.cluster_id.name}/{instance.namespace}/{instance.name}"
|
||||||
|
result.append((instance.id, name))
|
||||||
|
return result
|
||||||
|
|
||||||
|
def action_open_url(self):
|
||||||
|
"""Open the Odoo instance URL in a new tab"""
|
||||||
|
self.ensure_one()
|
||||||
|
if not self.url:
|
||||||
|
raise UserError(_("No URL available for this instance"))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": "ir.actions.act_url",
|
||||||
|
"url": self.url,
|
||||||
|
"target": "new",
|
||||||
|
}
|
||||||
|
|
||||||
|
def action_view_spec(self):
|
||||||
|
"""Show the complete specification in a dialog"""
|
||||||
|
self.ensure_one()
|
||||||
|
|
||||||
|
if not self.spec:
|
||||||
|
raise UserError(_("No specification data available"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Pretty format the JSON
|
||||||
|
spec_data = json.loads(self.spec)
|
||||||
|
formatted_spec = json.dumps(spec_data, indent=2)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
formatted_spec = self.spec
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": _("Instance Specification"),
|
||||||
|
"type": "ir.actions.act_window",
|
||||||
|
"res_model": "k8s.spec.viewer",
|
||||||
|
"view_mode": "form",
|
||||||
|
"target": "new",
|
||||||
|
"context": {
|
||||||
|
"default_title": f"Specification: {self.name}",
|
||||||
|
"default_content": formatted_spec,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def action_view_status(self):
|
||||||
|
"""Show the complete status in a dialog"""
|
||||||
|
self.ensure_one()
|
||||||
|
|
||||||
|
if not self.status:
|
||||||
|
raise UserError(_("No status data available"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Pretty format the JSON
|
||||||
|
status_data = json.loads(self.status)
|
||||||
|
formatted_status = json.dumps(status_data, indent=2)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
formatted_status = self.status
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": _("Instance Status"),
|
||||||
|
"type": "ir.actions.act_window",
|
||||||
|
"res_model": "k8s.spec.viewer",
|
||||||
|
"view_mode": "form",
|
||||||
|
"target": "new",
|
||||||
|
"context": {
|
||||||
|
"default_title": f"Status: {self.name}",
|
||||||
|
"default_content": formatted_status,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def action_reset_to_current(self):
|
||||||
|
"""Reset editable fields to current cluster values"""
|
||||||
|
self.ensure_one()
|
||||||
|
|
||||||
|
# Copy current values to editable fields (current values are computed fresh)
|
||||||
|
self.write(
|
||||||
|
{
|
||||||
|
"image_editable": self.current_image,
|
||||||
|
"replicas_editable": self.current_replicas,
|
||||||
|
"cpu_request": self.current_cpu_request,
|
||||||
|
"cpu_limit": self.current_cpu_limit,
|
||||||
|
"memory_request": self.current_memory_request,
|
||||||
|
"memory_limit": self.current_memory_limit,
|
||||||
|
"ingress_enabled": self.current_ingress_enabled,
|
||||||
|
"ingress_hosts_editable": self.current_ingress_hosts,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"type": "ir.actions.client",
|
||||||
|
"tag": "display_notification",
|
||||||
|
"params": {
|
||||||
|
"title": _("Reset Complete"),
|
||||||
|
"message": _("All fields reset to current cluster values"),
|
||||||
|
"type": "success",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def action_sync_to_cluster(self):
|
||||||
|
"""Public method to sync changes to cluster"""
|
||||||
|
self.ensure_one()
|
||||||
|
try:
|
||||||
|
self._patch_to_cluster()
|
||||||
|
|
||||||
|
# Show success notification and refresh form
|
||||||
|
return {
|
||||||
|
"type": "ir.actions.client",
|
||||||
|
"tag": "display_notification",
|
||||||
|
"params": {
|
||||||
|
"title": _("Sync Successful"),
|
||||||
|
"message": _("Successfully synced %s to cluster") % self.name,
|
||||||
|
"type": "success",
|
||||||
|
"sticky": False,
|
||||||
|
"next": {
|
||||||
|
"type": "ir.actions.act_window",
|
||||||
|
"res_model": "k8s.odoo.instance",
|
||||||
|
"res_id": self.id,
|
||||||
|
"view_mode": "form",
|
||||||
|
"views": [(False, "form")],
|
||||||
|
"target": "current",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"type": "ir.actions.client",
|
||||||
|
"tag": "display_notification",
|
||||||
|
"params": {
|
||||||
|
"title": _("Sync Failed"),
|
||||||
|
"message": _("Failed to sync %s: %s") % (self.name, str(e)),
|
||||||
|
"type": "danger",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _patch_to_cluster(self):
|
||||||
|
"""Patch this instance's changes back to the cluster"""
|
||||||
|
self.ensure_one()
|
||||||
|
|
||||||
|
if not self.cluster_id.active:
|
||||||
|
_logger.warning(
|
||||||
|
f"Cluster {self.cluster_id.name} is not active, skipping sync"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
k8s_client = self.cluster_id._get_k8s_client()
|
||||||
|
custom_api = client.CustomObjectsApi(k8s_client)
|
||||||
|
|
||||||
|
# Build the patch data from editable fields
|
||||||
|
patch_data = self._build_patch_data()
|
||||||
|
|
||||||
|
if not patch_data:
|
||||||
|
_logger.info(f"No changes to sync for {self.name}")
|
||||||
|
return
|
||||||
|
|
||||||
|
_logger.info(f"Patching {self.name} with: {patch_data}")
|
||||||
|
|
||||||
|
# Apply the patch
|
||||||
|
custom_api.patch_namespaced_custom_object(
|
||||||
|
group="bemade.org",
|
||||||
|
version="v1",
|
||||||
|
namespace=self.namespace,
|
||||||
|
plural="odooinstances",
|
||||||
|
name=self.name,
|
||||||
|
body=patch_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
_logger.info(f"Successfully patched {self.name} to cluster")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Failed to patch {self.name} to cluster: {e}"
|
||||||
|
_logger.error(error_msg)
|
||||||
|
raise UserError(_(error_msg))
|
||||||
|
|
||||||
|
def _build_patch_data(self):
|
||||||
|
"""Build patch data from editable fields"""
|
||||||
|
self.ensure_one()
|
||||||
|
|
||||||
|
patch = {"spec": {}}
|
||||||
|
|
||||||
|
# Image
|
||||||
|
if self.image_editable:
|
||||||
|
patch["spec"]["image"] = self.image_editable
|
||||||
|
|
||||||
|
# Replicas
|
||||||
|
if self.replicas_editable:
|
||||||
|
patch["spec"]["replicas"] = self.replicas_editable
|
||||||
|
|
||||||
|
# Resources
|
||||||
|
resources = {}
|
||||||
|
if (
|
||||||
|
self.cpu_request
|
||||||
|
or self.cpu_limit
|
||||||
|
or self.memory_request
|
||||||
|
or self.memory_limit
|
||||||
|
):
|
||||||
|
if self.cpu_request or self.memory_request:
|
||||||
|
resources["requests"] = {}
|
||||||
|
if self.cpu_request:
|
||||||
|
resources["requests"]["cpu"] = self.cpu_request
|
||||||
|
if self.memory_request:
|
||||||
|
resources["requests"]["memory"] = self.memory_request
|
||||||
|
|
||||||
|
if self.cpu_limit or self.memory_limit:
|
||||||
|
resources["limits"] = {}
|
||||||
|
if self.cpu_limit:
|
||||||
|
resources["limits"]["cpu"] = self.cpu_limit
|
||||||
|
if self.memory_limit:
|
||||||
|
resources["limits"]["memory"] = self.memory_limit
|
||||||
|
|
||||||
|
if resources:
|
||||||
|
patch["spec"]["resources"] = resources
|
||||||
|
|
||||||
|
# Ingress
|
||||||
|
if hasattr(self, "ingress_enabled"): # Check if field exists
|
||||||
|
ingress = {"enabled": self.ingress_enabled}
|
||||||
|
if self.ingress_hosts_editable:
|
||||||
|
# Parse hosts (support both comma and newline separated)
|
||||||
|
hosts = self.ingress_hosts_editable.replace("\n", ",").split(",")
|
||||||
|
hosts = [h.strip() for h in hosts if h.strip()]
|
||||||
|
if hosts:
|
||||||
|
ingress["hosts"] = hosts
|
||||||
|
patch["spec"]["ingress"] = ingress
|
||||||
|
|
||||||
|
# Return None if no actual changes
|
||||||
|
return patch if patch["spec"] else None
|
||||||
|
|
||||||
|
|
||||||
|
class K8sSpecViewer(models.TransientModel):
|
||||||
|
"""Transient model for displaying JSON content in a dialog"""
|
||||||
|
|
||||||
|
_name = "k8s.spec.viewer"
|
||||||
|
_description = "Kubernetes Spec Viewer"
|
||||||
|
|
||||||
|
title = fields.Char(string="Title", readonly=True)
|
||||||
|
content = fields.Text(string="Content", readonly=True)
|
||||||
11
k8s_odoo_manager/security/ir.model.access.csv
Normal file
11
k8s_odoo_manager/security/ir.model.access.csv
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||||
|
access_k8s_cluster_user,k8s.cluster user,model_k8s_cluster,group_k8s_user,1,0,0,0
|
||||||
|
access_k8s_cluster_manager,k8s.cluster manager,model_k8s_cluster,group_k8s_manager,1,1,1,1
|
||||||
|
access_k8s_odoo_instance_user,k8s.odoo.instance user,model_k8s_odoo_instance,group_k8s_user,1,0,0,0
|
||||||
|
access_k8s_odoo_instance_manager,k8s.odoo.instance manager,model_k8s_odoo_instance,group_k8s_manager,1,1,1,1
|
||||||
|
access_k8s_spec_viewer_user,k8s.spec.viewer user,model_k8s_spec_viewer,group_k8s_user,1,1,1,1
|
||||||
|
access_k8s_spec_viewer_manager,k8s.spec.viewer manager,model_k8s_spec_viewer,group_k8s_manager,1,1,1,1
|
||||||
|
access_k8s_cluster_test_wizard_user,k8s.cluster.test.wizard user,model_k8s_cluster_test_wizard,group_k8s_user,1,1,1,1
|
||||||
|
access_k8s_cluster_test_wizard_manager,k8s.cluster.test.wizard manager,model_k8s_cluster_test_wizard,group_k8s_manager,1,1,1,1
|
||||||
|
access_k8s_sync_instances_wizard_user,k8s.sync.instances.wizard user,model_k8s_sync_instances_wizard,group_k8s_user,1,1,1,1
|
||||||
|
access_k8s_sync_instances_wizard_manager,k8s.sync.instances.wizard manager,model_k8s_sync_instances_wizard,group_k8s_manager,1,1,1,1
|
||||||
|
68
k8s_odoo_manager/security/k8s_security.xml
Normal file
68
k8s_odoo_manager/security/k8s_security.xml
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<!-- Security Category -->
|
||||||
|
<record id="module_category_kubernetes" model="ir.module.category">
|
||||||
|
<field name="name">Kubernetes</field>
|
||||||
|
<field name="description">Kubernetes cluster and instance management</field>
|
||||||
|
<field name="sequence">100</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Security Groups -->
|
||||||
|
<record id="group_k8s_user" model="res.groups">
|
||||||
|
<field name="name">K8s User</field>
|
||||||
|
<field name="category_id" ref="module_category_kubernetes"/>
|
||||||
|
<field name="comment">Can view Kubernetes clusters and instances</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="group_k8s_manager" model="res.groups">
|
||||||
|
<field name="name">K8s Manager</field>
|
||||||
|
<field name="category_id" ref="module_category_kubernetes"/>
|
||||||
|
<field name="implied_ids" eval="[(4, ref('group_k8s_user'))]"/>
|
||||||
|
<field name="comment">Can manage Kubernetes clusters and perform operations</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Record Rules -->
|
||||||
|
<record id="rule_k8s_cluster_user" model="ir.rule">
|
||||||
|
<field name="name">K8s Cluster: User Access</field>
|
||||||
|
<field name="model_id" ref="model_k8s_cluster"/>
|
||||||
|
<field name="domain_force">[(1, '=', 1)]</field>
|
||||||
|
<field name="groups" eval="[(4, ref('group_k8s_user'))]"/>
|
||||||
|
<field name="perm_read" eval="True"/>
|
||||||
|
<field name="perm_write" eval="False"/>
|
||||||
|
<field name="perm_create" eval="False"/>
|
||||||
|
<field name="perm_unlink" eval="False"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="rule_k8s_cluster_manager" model="ir.rule">
|
||||||
|
<field name="name">K8s Cluster: Manager Access</field>
|
||||||
|
<field name="model_id" ref="model_k8s_cluster"/>
|
||||||
|
<field name="domain_force">[(1, '=', 1)]</field>
|
||||||
|
<field name="groups" eval="[(4, ref('group_k8s_manager'))]"/>
|
||||||
|
<field name="perm_read" eval="True"/>
|
||||||
|
<field name="perm_write" eval="True"/>
|
||||||
|
<field name="perm_create" eval="True"/>
|
||||||
|
<field name="perm_unlink" eval="True"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="rule_k8s_instance_user" model="ir.rule">
|
||||||
|
<field name="name">K8s Instance: User Access</field>
|
||||||
|
<field name="model_id" ref="model_k8s_odoo_instance"/>
|
||||||
|
<field name="domain_force">[(1, '=', 1)]</field>
|
||||||
|
<field name="groups" eval="[(4, ref('group_k8s_user'))]"/>
|
||||||
|
<field name="perm_read" eval="True"/>
|
||||||
|
<field name="perm_write" eval="False"/>
|
||||||
|
<field name="perm_create" eval="False"/>
|
||||||
|
<field name="perm_unlink" eval="False"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="rule_k8s_instance_manager" model="ir.rule">
|
||||||
|
<field name="name">K8s Instance: Manager Access</field>
|
||||||
|
<field name="model_id" ref="model_k8s_odoo_instance"/>
|
||||||
|
<field name="domain_force">[(1, '=', 1)]</field>
|
||||||
|
<field name="groups" eval="[(4, ref('group_k8s_manager'))]"/>
|
||||||
|
<field name="perm_read" eval="True"/>
|
||||||
|
<field name="perm_write" eval="True"/>
|
||||||
|
<field name="perm_create" eval="True"/>
|
||||||
|
<field name="perm_unlink" eval="True"/>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
41
k8s_odoo_manager/static/description/icon.svg
Normal file
41
k8s_odoo_manager/static/description/icon.svg
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#667eea;stop-opacity:1" />
|
||||||
|
<stop offset="100%" style="stop-color:#764ba2;stop-opacity:1" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Background circle -->
|
||||||
|
<circle cx="64" cy="64" r="60" fill="url(#grad1)" stroke="#ffffff" stroke-width="2"/>
|
||||||
|
|
||||||
|
<!-- Kubernetes wheel/helm icon -->
|
||||||
|
<g transform="translate(64,64)">
|
||||||
|
<!-- Center circle -->
|
||||||
|
<circle cx="0" cy="0" r="8" fill="#ffffff"/>
|
||||||
|
|
||||||
|
<!-- Spokes -->
|
||||||
|
<g stroke="#ffffff" stroke-width="3" stroke-linecap="round">
|
||||||
|
<line x1="0" y1="-35" x2="0" y2="-12"/>
|
||||||
|
<line x1="30.31" y1="-17.5" x2="10.39" y2="-6"/>
|
||||||
|
<line x1="30.31" y1="17.5" x2="10.39" y2="6"/>
|
||||||
|
<line x1="0" y1="35" x2="0" y2="12"/>
|
||||||
|
<line x1="-30.31" y1="17.5" x2="-10.39" y2="6"/>
|
||||||
|
<line x1="-30.31" y1="-17.5" x2="-10.39" y2="-6"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Outer nodes -->
|
||||||
|
<g fill="#ffffff">
|
||||||
|
<circle cx="0" cy="-35" r="4"/>
|
||||||
|
<circle cx="30.31" cy="-17.5" r="4"/>
|
||||||
|
<circle cx="30.31" cy="17.5" r="4"/>
|
||||||
|
<circle cx="0" cy="35" r="4"/>
|
||||||
|
<circle cx="-30.31" cy="17.5" r="4"/>
|
||||||
|
<circle cx="-30.31" cy="-17.5" r="4"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Odoo text -->
|
||||||
|
<text x="64" y="110" text-anchor="middle" font-family="Arial, sans-serif" font-size="12" font-weight="bold" fill="#ffffff">K8s Odoo</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
111
k8s_odoo_manager/static/description/index.html
Normal file
111
k8s_odoo_manager/static/description/index.html
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Kubernetes Odoo Manager</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
padding: 20px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.feature {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 20px 0;
|
||||||
|
border-left: 4px solid #667eea;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
.feature h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
.screenshot {
|
||||||
|
text-align: center;
|
||||||
|
margin: 30px 0;
|
||||||
|
}
|
||||||
|
.screenshot img {
|
||||||
|
max-width: 100%;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 5px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.tech-stack {
|
||||||
|
background: #e8f4fd;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.tech-stack ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>🚀 Kubernetes Odoo Manager</h1>
|
||||||
|
<p>Manage your Odoo instances across multiple Kubernetes clusters from a single interface</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature">
|
||||||
|
<h3>🔗 Multi-Cluster Management</h3>
|
||||||
|
<p>Connect to multiple Kubernetes clusters and manage all your Odoo instances from one central location. Securely store kubeconfig files and test connections with a single click.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature">
|
||||||
|
<h3>📊 Real-time Monitoring</h3>
|
||||||
|
<p>Monitor the status of all your Odoo instances in real-time. See which instances are running, upgrading, or restoring. Get instant visibility into your entire Odoo infrastructure.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature">
|
||||||
|
<h3>🔄 Automatic Synchronization</h3>
|
||||||
|
<p>Automatically discover and synchronize OdooInstance custom resources from your Kubernetes clusters. Keep your management interface up-to-date with scheduled sync jobs.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature">
|
||||||
|
<h3>🎯 Centralized Dashboard</h3>
|
||||||
|
<p>Beautiful dashboard showing cluster health, instance counts, and recent activity. Quick access to all management functions with an intuitive user interface.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tech-stack">
|
||||||
|
<h3>Technical Features</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Secure kubeconfig storage with encryption</li>
|
||||||
|
<li>Kubernetes API integration using official Python client</li>
|
||||||
|
<li>Support for OdooInstance CRDs (bemade.org/v1)</li>
|
||||||
|
<li>Real-time status synchronization</li>
|
||||||
|
<li>Connection testing and health monitoring</li>
|
||||||
|
<li>Automated periodic sync jobs</li>
|
||||||
|
<li>Role-based access control</li>
|
||||||
|
<li>Responsive web interface</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature">
|
||||||
|
<h3>🔐 Security & Access Control</h3>
|
||||||
|
<p>Built-in security groups for users and managers. Kubeconfig data is securely stored and access is controlled through Odoo's permission system.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature">
|
||||||
|
<h3>🛠️ Easy Setup</h3>
|
||||||
|
<p>Simple installation and configuration. Just add your cluster kubeconfig files and start managing your Odoo instances immediately.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin-top: 40px; padding: 20px; background: #f8f9fa; border-radius: 10px;">
|
||||||
|
<h3>Ready to get started?</h3>
|
||||||
|
<p>Install the module and add your first Kubernetes cluster to begin managing your Odoo instances like a pro!</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
138
k8s_odoo_manager/static/src/css/k8s_manager.css
Normal file
138
k8s_odoo_manager/static/src/css/k8s_manager.css
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
/* Kubernetes Odoo Manager Styles */
|
||||||
|
|
||||||
|
.k8s_dashboard {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k8s_dashboard .card {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k8s_dashboard .card-header {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-bottom: 1px solid #dee2e6;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k8s_dashboard .card-body {
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k8s_stat_box {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 10px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k8s_stat_box h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 2.5em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k8s_stat_box p {
|
||||||
|
margin: 5px 0 0 0;
|
||||||
|
font-size: 1.1em;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k8s_cluster_status {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k8s_cluster_status.connected {
|
||||||
|
background-color: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
border: 1px solid #c3e6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k8s_cluster_status.error {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
border: 1px solid #f5c6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k8s_cluster_status.unknown {
|
||||||
|
background-color: #e2e3e5;
|
||||||
|
color: #383d41;
|
||||||
|
border: 1px solid #d6d8db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k8s_instance_phase {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k8s_instance_phase.running {
|
||||||
|
background-color: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k8s_instance_phase.upgrading {
|
||||||
|
background-color: #fff3cd;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k8s_instance_phase.restoring {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k8s_instance_phase.unknown {
|
||||||
|
background-color: #e2e3e5;
|
||||||
|
color: #383d41;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Kubeconfig field styling */
|
||||||
|
.o_field_ace {
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Connection test results */
|
||||||
|
.k8s_test_result {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k8s_test_result.success {
|
||||||
|
background-color: #d4edda;
|
||||||
|
border-color: #c3e6cb;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k8s_test_result.error {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
border-color: #f5c6cb;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.k8s_stat_box {
|
||||||
|
margin: 5px;
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k8s_stat_box h3 {
|
||||||
|
font-size: 2em;
|
||||||
|
}
|
||||||
|
}
|
||||||
154
k8s_odoo_manager/static/src/js/k8s_dashboard.js
Normal file
154
k8s_odoo_manager/static/src/js/k8s_dashboard.js
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
import { Component, useState, onWillStart } from "@odoo/owl";
|
||||||
|
import { registry } from "@web/core/registry";
|
||||||
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
|
||||||
|
class K8sDashboard extends Component {
|
||||||
|
setup() {
|
||||||
|
this.rpc = useService("rpc");
|
||||||
|
this.notification = useService("notification");
|
||||||
|
this.action = useService("action");
|
||||||
|
|
||||||
|
this.state = useState({
|
||||||
|
clusters: [],
|
||||||
|
instances: [],
|
||||||
|
stats: {
|
||||||
|
total_clusters: 0,
|
||||||
|
connected_clusters: 0,
|
||||||
|
total_instances: 0,
|
||||||
|
running_instances: 0,
|
||||||
|
},
|
||||||
|
loading: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
onWillStart(async () => {
|
||||||
|
await this.loadDashboardData();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadDashboardData() {
|
||||||
|
try {
|
||||||
|
this.state.loading = true;
|
||||||
|
|
||||||
|
// Load clusters
|
||||||
|
const clusters = await this.rpc("/web/dataset/call_kw", {
|
||||||
|
model: "k8s.cluster",
|
||||||
|
method: "search_read",
|
||||||
|
args: [[]],
|
||||||
|
kwargs: {
|
||||||
|
fields: ["name", "connection_status", "total_instances", "running_instances", "last_sync"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load recent instances
|
||||||
|
const instances = await this.rpc("/web/dataset/call_kw", {
|
||||||
|
model: "k8s.odoo.instance",
|
||||||
|
method: "search_read",
|
||||||
|
args: [[]],
|
||||||
|
kwargs: {
|
||||||
|
fields: ["name", "cluster_id", "namespace", "phase", "url", "last_updated"],
|
||||||
|
limit: 10,
|
||||||
|
order: "last_updated desc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate stats
|
||||||
|
const stats = {
|
||||||
|
total_clusters: clusters.length,
|
||||||
|
connected_clusters: clusters.filter(c => c.connection_status === 'connected').length,
|
||||||
|
total_instances: clusters.reduce((sum, c) => sum + c.total_instances, 0),
|
||||||
|
running_instances: clusters.reduce((sum, c) => sum + c.running_instances, 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.state.clusters = clusters;
|
||||||
|
this.state.instances = instances;
|
||||||
|
this.state.stats = stats;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
this.notification.add("Failed to load dashboard data", { type: "danger" });
|
||||||
|
console.error("Dashboard load error:", error);
|
||||||
|
} finally {
|
||||||
|
this.state.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshDashboard() {
|
||||||
|
await this.loadDashboardData();
|
||||||
|
this.notification.add("Dashboard refreshed", { type: "success" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncAllClusters() {
|
||||||
|
try {
|
||||||
|
await this.rpc("/web/dataset/call_kw", {
|
||||||
|
model: "k8s.cluster",
|
||||||
|
method: "search_read",
|
||||||
|
args: [[["active", "=", true]]],
|
||||||
|
kwargs: { fields: ["id"] },
|
||||||
|
}).then(async (clusters) => {
|
||||||
|
for (const cluster of clusters) {
|
||||||
|
await this.rpc("/web/dataset/call_kw", {
|
||||||
|
model: "k8s.cluster",
|
||||||
|
method: "sync_odoo_instances",
|
||||||
|
args: [cluster.id],
|
||||||
|
kwargs: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.notification.add("All clusters synced successfully", { type: "success" });
|
||||||
|
await this.loadDashboardData();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
this.notification.add("Failed to sync clusters", { type: "danger" });
|
||||||
|
console.error("Sync error:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
openClusters() {
|
||||||
|
this.action.doAction({
|
||||||
|
type: "ir.actions.act_window",
|
||||||
|
res_model: "k8s.cluster",
|
||||||
|
view_mode: "tree,form",
|
||||||
|
views: [[false, "list"], [false, "form"]],
|
||||||
|
name: "Kubernetes Clusters",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openInstances() {
|
||||||
|
this.action.doAction({
|
||||||
|
type: "ir.actions.act_window",
|
||||||
|
res_model: "k8s.odoo.instance",
|
||||||
|
view_mode: "tree,form",
|
||||||
|
views: [[false, "list"], [false, "form"]],
|
||||||
|
name: "Odoo Instances",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openInstance(instanceId) {
|
||||||
|
this.action.doAction({
|
||||||
|
type: "ir.actions.act_window",
|
||||||
|
res_model: "k8s.odoo.instance",
|
||||||
|
res_id: instanceId,
|
||||||
|
view_mode: "form",
|
||||||
|
views: [[false, "form"]],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getClusterStatusClass(status) {
|
||||||
|
return `k8s_cluster_status ${status}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getInstancePhaseClass(phase) {
|
||||||
|
return `k8s_instance_phase ${phase.toLowerCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDateTime(datetime) {
|
||||||
|
if (!datetime) return "Never";
|
||||||
|
return new Date(datetime).toLocaleString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
K8sDashboard.template = "k8s_odoo_manager.Dashboard";
|
||||||
|
|
||||||
|
registry.category("actions").add("k8s_dashboard", K8sDashboard);
|
||||||
126
k8s_odoo_manager/static/src/xml/k8s_dashboard.xml
Normal file
126
k8s_odoo_manager/static/src/xml/k8s_dashboard.xml
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
<t t-name="k8s_odoo_manager.Dashboard" owl="1">
|
||||||
|
<div class="k8s_dashboard">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2>Kubernetes Dashboard</h2>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-secondary me-2" t-on-click="refreshDashboard">
|
||||||
|
<i class="fa fa-refresh"/> Refresh
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary" t-on-click="syncAllClusters">
|
||||||
|
<i class="fa fa-sync"/> Sync All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div t-if="state.loading" class="text-center p-5">
|
||||||
|
<i class="fa fa-spinner fa-spin fa-3x"/>
|
||||||
|
<p class="mt-3">Loading dashboard...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dashboard Content -->
|
||||||
|
<div t-if="!state.loading">
|
||||||
|
<!-- Stats Cards -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="k8s_stat_box" t-on-click="openClusters">
|
||||||
|
<h3 t-esc="state.stats.total_clusters"/>
|
||||||
|
<p>Total Clusters</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="k8s_stat_box" t-on-click="openClusters">
|
||||||
|
<h3 t-esc="state.stats.connected_clusters"/>
|
||||||
|
<p>Connected Clusters</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="k8s_stat_box" t-on-click="openInstances">
|
||||||
|
<h3 t-esc="state.stats.total_instances"/>
|
||||||
|
<p>Total Instances</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="k8s_stat_box" t-on-click="openInstances">
|
||||||
|
<h3 t-esc="state.stats.running_instances"/>
|
||||||
|
<p>Running Instances</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Clusters and Instances -->
|
||||||
|
<div class="row">
|
||||||
|
<!-- Clusters Card -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span>Clusters</span>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" t-on-click="openClusters">
|
||||||
|
View All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div t-if="state.clusters.length === 0" class="text-muted text-center p-3">
|
||||||
|
No clusters configured
|
||||||
|
</div>
|
||||||
|
<div t-else="">
|
||||||
|
<div t-foreach="state.clusters" t-as="cluster" t-key="cluster.id" class="d-flex justify-content-between align-items-center mb-2 p-2 border-bottom">
|
||||||
|
<div>
|
||||||
|
<strong t-esc="cluster.name"/>
|
||||||
|
<div class="small text-muted">
|
||||||
|
<t t-esc="cluster.total_instances"/> instances
|
||||||
|
(<t t-esc="cluster.running_instances"/> running)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-end">
|
||||||
|
<span t-att-class="getClusterStatusClass(cluster.connection_status)" t-esc="cluster.connection_status"/>
|
||||||
|
<div class="small text-muted">
|
||||||
|
<t t-esc="formatDateTime(cluster.last_sync)"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Instances Card -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span>Recent Instances</span>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" t-on-click="openInstances">
|
||||||
|
View All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div t-if="state.instances.length === 0" class="text-muted text-center p-3">
|
||||||
|
No instances found
|
||||||
|
</div>
|
||||||
|
<div t-else="">
|
||||||
|
<div t-foreach="state.instances" t-as="instance" t-key="instance.id" class="d-flex justify-content-between align-items-center mb-2 p-2 border-bottom">
|
||||||
|
<div t-on-click="() => this.openInstance(instance.id)" style="cursor: pointer;">
|
||||||
|
<strong t-esc="instance.name"/>
|
||||||
|
<div class="small text-muted">
|
||||||
|
<t t-esc="instance.cluster_id[1]"/> / <t t-esc="instance.namespace"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-end">
|
||||||
|
<span t-att-class="getInstancePhaseClass(instance.phase)" t-esc="instance.phase"/>
|
||||||
|
<div class="small text-muted">
|
||||||
|
<t t-esc="formatDateTime(instance.last_updated)"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
125
k8s_odoo_manager/views/k8s_cluster_views.xml
Normal file
125
k8s_odoo_manager/views/k8s_cluster_views.xml
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<!-- Cluster List View -->
|
||||||
|
<record id="view_k8s_cluster_list" model="ir.ui.view">
|
||||||
|
<field name="name">k8s.cluster.list</field>
|
||||||
|
<field name="model">k8s.cluster</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="Kubernetes Clusters" decoration-muted="not active" decoration-danger="connection_status == 'error'">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="api_endpoint"/>
|
||||||
|
<field name="default_namespace"/>
|
||||||
|
<field name="connection_status" widget="badge" decoration-success="connection_status == 'connected'" decoration-danger="connection_status == 'error'"/>
|
||||||
|
<field name="total_instances"/>
|
||||||
|
<field name="running_instances"/>
|
||||||
|
<field name="last_sync"/>
|
||||||
|
<field name="active"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Cluster Form View -->
|
||||||
|
<record id="view_k8s_cluster_form" model="ir.ui.view">
|
||||||
|
<field name="name">k8s.cluster.form</field>
|
||||||
|
<field name="model">k8s.cluster</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Kubernetes Cluster">
|
||||||
|
<header>
|
||||||
|
<button name="action_test_connection" string="Test Connection" type="object" class="btn-primary"/>
|
||||||
|
<button name="action_sync_instances" string="Sync Instances" type="object" class="btn-secondary"/>
|
||||||
|
</header>
|
||||||
|
<sheet>
|
||||||
|
<div class="oe_button_box" name="button_box">
|
||||||
|
<button name="action_view_instances" type="object" class="oe_stat_button" icon="fa-server">
|
||||||
|
<field name="total_instances" widget="statinfo" string="Instances"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<widget name="web_ribbon" title="Inactive" bg_color="bg-danger" invisible="active"/>
|
||||||
|
|
||||||
|
<div class="oe_title">
|
||||||
|
<h1>
|
||||||
|
<field name="name" placeholder="Cluster Name"/>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="api_endpoint" placeholder="https://k8s.example.com:6443"/>
|
||||||
|
<field name="default_namespace"/>
|
||||||
|
<field name="active"/>
|
||||||
|
<field name="verify_ssl"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="connection_status" widget="badge" decoration-success="connection_status == 'connected'" decoration-danger="connection_status == 'error'"/>
|
||||||
|
<field name="last_sync"/>
|
||||||
|
<field name="total_instances"/>
|
||||||
|
<field name="running_instances"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<notebook>
|
||||||
|
<page string="Kubeconfig">
|
||||||
|
<field name="kubeconfig" widget="text" placeholder="Paste your kubeconfig YAML content here..."/>
|
||||||
|
</page>
|
||||||
|
<page string="Connection Error" invisible="not connection_error">
|
||||||
|
<field name="connection_error" readonly="1"/>
|
||||||
|
</page>
|
||||||
|
<page string="Instances">
|
||||||
|
<field name="instance_ids" readonly="1">
|
||||||
|
<list>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="namespace"/>
|
||||||
|
<field name="phase" widget="badge"/>
|
||||||
|
<field name="url" widget="url"/>
|
||||||
|
<field name="last_updated"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</page>
|
||||||
|
</notebook>
|
||||||
|
</sheet>
|
||||||
|
<chatter/>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Cluster Search View -->
|
||||||
|
<record id="view_k8s_cluster_search" model="ir.ui.view">
|
||||||
|
<field name="name">k8s.cluster.search</field>
|
||||||
|
<field name="model">k8s.cluster</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<search string="Search Clusters">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="api_endpoint"/>
|
||||||
|
<field name="default_namespace"/>
|
||||||
|
<separator/>
|
||||||
|
<filter string="Active" name="active" domain="[('active', '=', True)]"/>
|
||||||
|
<filter string="Inactive" name="inactive" domain="[('active', '=', False)]"/>
|
||||||
|
<separator/>
|
||||||
|
<filter string="Connected" name="connected" domain="[('connection_status', '=', 'connected')]"/>
|
||||||
|
<filter string="Connection Error" name="error" domain="[('connection_status', '=', 'error')]"/>
|
||||||
|
<separator/>
|
||||||
|
<group expand="0" string="Group By">
|
||||||
|
<filter string="Connection Status" name="group_connection_status" domain="[]" context="{'group_by': 'connection_status'}"/>
|
||||||
|
<filter string="Active" name="group_active" domain="[]" context="{'group_by': 'active'}"/>
|
||||||
|
</group>
|
||||||
|
</search>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Cluster Action -->
|
||||||
|
<record id="action_k8s_cluster" model="ir.actions.act_window">
|
||||||
|
<field name="name">Kubernetes Clusters</field>
|
||||||
|
<field name="res_model">k8s.cluster</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
<field name="help" type="html">
|
||||||
|
<p class="o_view_nocontent_smiling_face">
|
||||||
|
Create your first Kubernetes cluster connection!
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Add Kubernetes clusters to manage your Odoo instances remotely.
|
||||||
|
You'll need the kubeconfig file with appropriate permissions.
|
||||||
|
</p>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
25
k8s_odoo_manager/views/k8s_menu_views.xml
Normal file
25
k8s_odoo_manager/views/k8s_menu_views.xml
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<!-- Main Menu -->
|
||||||
|
<menuitem id="menu_k8s_main"
|
||||||
|
name="Kubernetes"
|
||||||
|
sequence="100"
|
||||||
|
groups="group_k8s_user"
|
||||||
|
web_icon="k8s_odoo_manager,static/description/icon.svg"/>
|
||||||
|
|
||||||
|
<!-- Clusters Menu -->
|
||||||
|
<menuitem id="menu_k8s_clusters"
|
||||||
|
name="Clusters"
|
||||||
|
parent="menu_k8s_main"
|
||||||
|
action="action_k8s_cluster"
|
||||||
|
sequence="10"
|
||||||
|
groups="group_k8s_user"/>
|
||||||
|
|
||||||
|
<!-- Instances Menu -->
|
||||||
|
<menuitem id="menu_k8s_instances"
|
||||||
|
name="Odoo Instances"
|
||||||
|
parent="menu_k8s_main"
|
||||||
|
action="action_k8s_odoo_instance"
|
||||||
|
sequence="20"
|
||||||
|
groups="group_k8s_user"/>
|
||||||
|
</odoo>
|
||||||
224
k8s_odoo_manager/views/k8s_odoo_instance_views.xml
Normal file
224
k8s_odoo_manager/views/k8s_odoo_instance_views.xml
Normal file
|
|
@ -0,0 +1,224 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<!-- Instance List View -->
|
||||||
|
<record id="view_k8s_odoo_instance_list" model="ir.ui.view">
|
||||||
|
<field name="name">k8s.odoo.instance.list</field>
|
||||||
|
<field name="model">k8s.odoo.instance</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="Odoo Instances" decoration-muted="phase == 'Pending'" decoration-success="phase == 'Running'" decoration-danger="phase == 'Failed'">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="cluster_id"/>
|
||||||
|
<field name="namespace"/>
|
||||||
|
<field name="current_image"/>
|
||||||
|
<field name="current_replicas"/>
|
||||||
|
<field name="ready_replicas"/>
|
||||||
|
<field name="phase" widget="badge"/>
|
||||||
|
<field name="ingress_url" widget="url"/>
|
||||||
|
<field name="last_updated"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
<record id="view_k8s_odoo_instance_form" model="ir.ui.view">
|
||||||
|
<field name="name">k8s.odoo.instance.form</field>
|
||||||
|
<field name="model">k8s.odoo.instance</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Odoo Instance" create="false">
|
||||||
|
<header>
|
||||||
|
<button name="action_open_url" string="Open Instance" type="object" class="btn-primary" invisible="not url"/>
|
||||||
|
<button name="action_reset_to_current" string="Reset to Current" type="object" class="btn-secondary" confirm="This will reset all your changes to current cluster values. Continue?"/>
|
||||||
|
<button name="action_sync_to_cluster" string="Sync to Cluster" type="object" class="btn-warning" confirm="This will apply your changes to the Kubernetes cluster. Continue?"/>
|
||||||
|
<field name="phase" widget="statusbar"/>
|
||||||
|
</header>
|
||||||
|
<sheet>
|
||||||
|
<div class="oe_title">
|
||||||
|
<h1>
|
||||||
|
<field name="name"/>
|
||||||
|
</h1>
|
||||||
|
<h2>
|
||||||
|
<field name="cluster_id" readonly="1"/> / <field name="namespace" readonly="1"/>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="cluster_id"/>
|
||||||
|
<field name="namespace"/>
|
||||||
|
<field name="phase" widget="badge"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="ingress_url" widget="url"/>
|
||||||
|
<field name="last_updated"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<notebook>
|
||||||
|
<page string="Configuration">
|
||||||
|
<group>
|
||||||
|
<group string="Application">
|
||||||
|
<label for="image_editable" string="Docker Image"/>
|
||||||
|
<div class="o_row">
|
||||||
|
<field name="image_editable" placeholder="e.g., odoo:17.0" class="oe_inline" style="width: 60%;"/>
|
||||||
|
<span class="text-muted" style="margin-left: 10px;">
|
||||||
|
Current: <field name="current_image" readonly="1" nolabel="1" class="oe_inline"/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label for="replicas_editable" string="Replicas"/>
|
||||||
|
<div class="o_row">
|
||||||
|
<field name="replicas_editable" class="oe_inline" style="width: 60%;"/>
|
||||||
|
<span class="text-muted" style="margin-left: 10px;">
|
||||||
|
Current: <field name="current_replicas" readonly="1" nolabel="1" class="oe_inline"/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<group string="Status">
|
||||||
|
<field name="ready_replicas" readonly="1"/>
|
||||||
|
<field name="available_replicas" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<group>
|
||||||
|
<group string="Resource Requests">
|
||||||
|
<label for="cpu_request" string="CPU Request"/>
|
||||||
|
<div class="o_row">
|
||||||
|
<field name="cpu_request" placeholder="e.g., 100m" class="oe_inline" style="width: 60%;"/>
|
||||||
|
<span class="text-muted" style="margin-left: 10px;">
|
||||||
|
Current: <field name="current_cpu_request" readonly="1" nolabel="1" class="oe_inline"/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label for="memory_request" string="Memory Request"/>
|
||||||
|
<div class="o_row">
|
||||||
|
<field name="memory_request" placeholder="e.g., 512Mi" class="oe_inline" style="width: 60%;"/>
|
||||||
|
<span class="text-muted" style="margin-left: 10px;">
|
||||||
|
Current: <field name="current_memory_request" readonly="1" nolabel="1" class="oe_inline"/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<group string="Resource Limits">
|
||||||
|
<label for="cpu_limit" string="CPU Limit"/>
|
||||||
|
<div class="o_row">
|
||||||
|
<field name="cpu_limit" placeholder="e.g., 1000m" class="oe_inline" style="width: 60%;"/>
|
||||||
|
<span class="text-muted" style="margin-left: 10px;">
|
||||||
|
Current: <field name="current_cpu_limit" readonly="1" nolabel="1" class="oe_inline"/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label for="memory_limit" string="Memory Limit"/>
|
||||||
|
<div class="o_row">
|
||||||
|
<field name="memory_limit" placeholder="e.g., 1Gi" class="oe_inline" style="width: 60%;"/>
|
||||||
|
<span class="text-muted" style="margin-left: 10px;">
|
||||||
|
Current: <field name="current_memory_limit" readonly="1" nolabel="1" class="oe_inline"/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<group>
|
||||||
|
<group string="Ingress Configuration">
|
||||||
|
<field name="ingress_enabled"/>
|
||||||
|
<span class="text-muted">
|
||||||
|
Current: <field name="current_ingress_enabled" readonly="1" nolabel="1" class="oe_inline"/>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<label for="ingress_hosts_editable" string="Ingress Hosts"/>
|
||||||
|
<div class="o_row">
|
||||||
|
<field name="ingress_hosts_editable" placeholder="host1.example.com, host2.example.com" class="oe_inline" style="width: 60%;"/>
|
||||||
|
<span class="text-muted" style="margin-left: 10px;">
|
||||||
|
Current: <field name="current_ingress_hosts" readonly="1" nolabel="1" class="oe_inline"/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<group string="Storage (Read-Only)">
|
||||||
|
<field name="filestore_enabled" readonly="1"/>
|
||||||
|
<field name="filestore_size" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</page>
|
||||||
|
|
||||||
|
<page string="Status Details">
|
||||||
|
<group>
|
||||||
|
<field name="conditions" widget="text" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
</page>
|
||||||
|
|
||||||
|
<page string="Raw Data">
|
||||||
|
<group>
|
||||||
|
<group string="Specification">
|
||||||
|
<button name="action_view_spec" string="View Full Spec" type="object" class="btn btn-link"/>
|
||||||
|
<field name="spec" widget="text" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
<group string="Status">
|
||||||
|
<button name="action_view_status" string="View Full Status" type="object" class="btn btn-link"/>
|
||||||
|
<field name="status" widget="text" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</page>
|
||||||
|
</notebook>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Instance Search View -->
|
||||||
|
<record id="view_k8s_odoo_instance_search" model="ir.ui.view">
|
||||||
|
<field name="name">k8s.odoo.instance.search</field>
|
||||||
|
<field name="model">k8s.odoo.instance</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<search string="Search Instances">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="cluster_id"/>
|
||||||
|
<field name="namespace"/>
|
||||||
|
<field name="current_image"/>
|
||||||
|
<field name="url"/>
|
||||||
|
<separator/>
|
||||||
|
<filter string="Running" name="running" domain="[('phase', '=', 'Running')]"/>
|
||||||
|
<filter string="Upgrading" name="upgrading" domain="[('phase', '=', 'Upgrading')]"/>
|
||||||
|
<filter string="Restoring" name="restoring" domain="[('phase', '=', 'Restoring')]"/>
|
||||||
|
<separator/>
|
||||||
|
<group expand="0" string="Group By">
|
||||||
|
<filter string="Cluster" name="group_cluster" domain="[]" context="{'group_by': 'cluster_id'}"/>
|
||||||
|
<filter string="Namespace" name="group_namespace" domain="[]" context="{'group_by': 'namespace'}"/>
|
||||||
|
<filter string="Phase" name="group_phase" domain="[]" context="{'group_by': 'phase'}"/>
|
||||||
|
</group>
|
||||||
|
</search>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Instance Action -->
|
||||||
|
<record id="action_k8s_odoo_instance" model="ir.actions.act_window">
|
||||||
|
<field name="name">Odoo Instances</field>
|
||||||
|
<field name="res_model">k8s.odoo.instance</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
<field name="help" type="html">
|
||||||
|
<p class="o_view_nocontent_smiling_face">
|
||||||
|
No Odoo instances found!
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Sync your clusters to discover Odoo instances running in Kubernetes.
|
||||||
|
</p>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Spec Viewer Views -->
|
||||||
|
<record id="view_k8s_spec_viewer_form" model="ir.ui.view">
|
||||||
|
<field name="name">k8s.spec.viewer.form</field>
|
||||||
|
<field name="model">k8s.spec.viewer</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Spec Viewer">
|
||||||
|
<sheet>
|
||||||
|
<div class="oe_title">
|
||||||
|
<h1>
|
||||||
|
<field name="title" readonly="1"/>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<field name="content" widget="text" readonly="1"/>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
2
k8s_odoo_manager/wizards/__init__.py
Normal file
2
k8s_odoo_manager/wizards/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
from . import k8s_cluster_test_wizard
|
||||||
|
from . import k8s_sync_instances_wizard
|
||||||
82
k8s_odoo_manager/wizards/k8s_cluster_test_wizard.py
Normal file
82
k8s_odoo_manager/wizards/k8s_cluster_test_wizard.py
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import logging
|
||||||
|
from odoo import api, fields, models, _
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class K8sClusterTestWizard(models.TransientModel):
|
||||||
|
_name = 'k8s.cluster.test.wizard'
|
||||||
|
_description = 'Test Kubernetes Cluster Connection'
|
||||||
|
|
||||||
|
cluster_id = fields.Many2one(
|
||||||
|
'k8s.cluster',
|
||||||
|
string='Cluster',
|
||||||
|
required=True,
|
||||||
|
default=lambda self: self.env.context.get('active_id')
|
||||||
|
)
|
||||||
|
|
||||||
|
test_result = fields.Text(
|
||||||
|
string='Test Result',
|
||||||
|
readonly=True
|
||||||
|
)
|
||||||
|
|
||||||
|
state = fields.Selection([
|
||||||
|
('draft', 'Ready to Test'),
|
||||||
|
('testing', 'Testing...'),
|
||||||
|
('done', 'Test Complete'),
|
||||||
|
], default='draft')
|
||||||
|
|
||||||
|
def action_test_connection(self):
|
||||||
|
"""Test the cluster connection"""
|
||||||
|
self.ensure_one()
|
||||||
|
|
||||||
|
if not self.cluster_id:
|
||||||
|
raise UserError(_('Please select a cluster to test'))
|
||||||
|
|
||||||
|
self.state = 'testing'
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Test the connection
|
||||||
|
result = self.cluster_id.test_connection()
|
||||||
|
|
||||||
|
# Extract the message from the notification result
|
||||||
|
if isinstance(result, dict) and 'params' in result:
|
||||||
|
message = result['params'].get('message', 'Test completed')
|
||||||
|
test_type = result['params'].get('type', 'info')
|
||||||
|
|
||||||
|
if test_type == 'success':
|
||||||
|
self.test_result = f"✓ SUCCESS: {message}"
|
||||||
|
else:
|
||||||
|
self.test_result = f"✗ ERROR: {message}"
|
||||||
|
else:
|
||||||
|
self.test_result = "Test completed successfully"
|
||||||
|
|
||||||
|
self.state = 'done'
|
||||||
|
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'res_model': 'k8s.cluster.test.wizard',
|
||||||
|
'res_id': self.id,
|
||||||
|
'view_mode': 'form',
|
||||||
|
'target': 'new',
|
||||||
|
'context': self.env.context,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(f"Connection test failed: {e}")
|
||||||
|
self.test_result = f"✗ ERROR: {str(e)}"
|
||||||
|
self.state = 'done'
|
||||||
|
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'res_model': 'k8s.cluster.test.wizard',
|
||||||
|
'res_id': self.id,
|
||||||
|
'view_mode': 'form',
|
||||||
|
'target': 'new',
|
||||||
|
'context': self.env.context,
|
||||||
|
}
|
||||||
|
|
||||||
|
def action_close(self):
|
||||||
|
"""Close the wizard"""
|
||||||
|
return {'type': 'ir.actions.act_window_close'}
|
||||||
47
k8s_odoo_manager/wizards/k8s_cluster_test_wizard_views.xml
Normal file
47
k8s_odoo_manager/wizards/k8s_cluster_test_wizard_views.xml
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<!-- Cluster Test Wizard Form -->
|
||||||
|
<record id="view_k8s_cluster_test_wizard_form" model="ir.ui.view">
|
||||||
|
<field name="name">k8s.cluster.test.wizard.form</field>
|
||||||
|
<field name="model">k8s.cluster.test.wizard</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Test Cluster Connection">
|
||||||
|
<sheet>
|
||||||
|
<div class="oe_title">
|
||||||
|
<h1>Test Kubernetes Cluster Connection</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<group>
|
||||||
|
<field name="cluster_id" options="{'no_create': True}" readonly="state != 'draft'"/>
|
||||||
|
<field name="state" invisible="1"/>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<div invisible="state != 'done'">
|
||||||
|
<h3>Test Result:</h3>
|
||||||
|
<field name="test_result" readonly="1" widget="text"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div invisible="state != 'testing'">
|
||||||
|
<div class="text-center">
|
||||||
|
<i class="fa fa-spinner fa-spin fa-2x" title="Loading"/>
|
||||||
|
<p>Testing connection...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sheet>
|
||||||
|
<footer>
|
||||||
|
<button string="Test Connection" name="action_test_connection" type="object" class="btn-primary" invisible="state != 'draft'"/>
|
||||||
|
<button string="Close" name="action_close" type="object" class="btn-secondary" invisible="state == 'testing'"/>
|
||||||
|
<button string="Cancel" class="btn-secondary" special="cancel" invisible="state != 'testing'"/>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Cluster Test Wizard Action -->
|
||||||
|
<record id="action_k8s_cluster_test_wizard" model="ir.actions.act_window">
|
||||||
|
<field name="name">Test Cluster Connection</field>
|
||||||
|
<field name="res_model">k8s.cluster.test.wizard</field>
|
||||||
|
<field name="view_mode">form</field>
|
||||||
|
<field name="target">new</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
132
k8s_odoo_manager/wizards/k8s_sync_instances_wizard.py
Normal file
132
k8s_odoo_manager/wizards/k8s_sync_instances_wizard.py
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
import logging
|
||||||
|
from odoo import api, fields, models, _
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class K8sSyncInstancesWizard(models.TransientModel):
|
||||||
|
_name = 'k8s.sync.instances.wizard'
|
||||||
|
_description = 'Sync Odoo Instances from Kubernetes'
|
||||||
|
|
||||||
|
cluster_ids = fields.Many2many(
|
||||||
|
'k8s.cluster',
|
||||||
|
string='Clusters to Sync',
|
||||||
|
default=lambda self: self._default_cluster_ids()
|
||||||
|
)
|
||||||
|
|
||||||
|
sync_all = fields.Boolean(
|
||||||
|
string='Sync All Active Clusters',
|
||||||
|
default=True,
|
||||||
|
help='If checked, will sync all active clusters regardless of selection above'
|
||||||
|
)
|
||||||
|
|
||||||
|
sync_result = fields.Text(
|
||||||
|
string='Sync Result',
|
||||||
|
readonly=True
|
||||||
|
)
|
||||||
|
|
||||||
|
state = fields.Selection([
|
||||||
|
('draft', 'Ready to Sync'),
|
||||||
|
('syncing', 'Syncing...'),
|
||||||
|
('done', 'Sync Complete'),
|
||||||
|
], default='draft')
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _default_cluster_ids(self):
|
||||||
|
"""Default to active clusters"""
|
||||||
|
active_ids = self.env.context.get('active_ids', [])
|
||||||
|
if active_ids and self.env.context.get('active_model') == 'k8s.cluster':
|
||||||
|
return [(6, 0, active_ids)]
|
||||||
|
else:
|
||||||
|
# Return all active clusters
|
||||||
|
clusters = self.env['k8s.cluster'].search([('active', '=', True)])
|
||||||
|
return [(6, 0, clusters.ids)]
|
||||||
|
|
||||||
|
def action_sync_instances(self):
|
||||||
|
"""Sync instances from selected clusters"""
|
||||||
|
self.ensure_one()
|
||||||
|
|
||||||
|
self.state = 'syncing'
|
||||||
|
|
||||||
|
# Determine which clusters to sync
|
||||||
|
if self.sync_all:
|
||||||
|
clusters = self.env['k8s.cluster'].search([('active', '=', True)])
|
||||||
|
else:
|
||||||
|
clusters = self.cluster_ids.filtered('active')
|
||||||
|
|
||||||
|
if not clusters:
|
||||||
|
raise UserError(_('No active clusters selected for synchronization'))
|
||||||
|
|
||||||
|
results = []
|
||||||
|
total_synced = 0
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
for cluster in clusters:
|
||||||
|
try:
|
||||||
|
_logger.info(f"Syncing instances from cluster: {cluster.name}")
|
||||||
|
|
||||||
|
# Count instances before sync
|
||||||
|
before_count = len(cluster.instance_ids)
|
||||||
|
|
||||||
|
# Perform sync
|
||||||
|
result = cluster.sync_odoo_instances()
|
||||||
|
|
||||||
|
# Count instances after sync
|
||||||
|
after_count = len(cluster.instance_ids)
|
||||||
|
synced_count = after_count # This is the total count, not delta
|
||||||
|
|
||||||
|
results.append(f"✓ {cluster.name}: {synced_count} instances")
|
||||||
|
total_synced += synced_count
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"✗ {cluster.name}: {str(e)}"
|
||||||
|
results.append(error_msg)
|
||||||
|
errors.append(error_msg)
|
||||||
|
_logger.error(f"Sync failed for cluster {cluster.name}: {e}")
|
||||||
|
|
||||||
|
# Prepare result message
|
||||||
|
if errors:
|
||||||
|
self.sync_result = f"Sync completed with errors:\n\n" + "\n".join(results)
|
||||||
|
if len(errors) == len(clusters):
|
||||||
|
self.sync_result += f"\n\n⚠️ All clusters failed to sync!"
|
||||||
|
else:
|
||||||
|
self.sync_result += f"\n\n✓ Total instances synced: {total_synced}"
|
||||||
|
else:
|
||||||
|
self.sync_result = f"✓ Sync completed successfully!\n\n" + "\n".join(results)
|
||||||
|
self.sync_result += f"\n\n✓ Total instances synced: {total_synced}"
|
||||||
|
|
||||||
|
self.state = 'done'
|
||||||
|
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'res_model': 'k8s.sync.instances.wizard',
|
||||||
|
'res_id': self.id,
|
||||||
|
'view_mode': 'form',
|
||||||
|
'target': 'new',
|
||||||
|
'context': self.env.context,
|
||||||
|
}
|
||||||
|
|
||||||
|
def action_close(self):
|
||||||
|
"""Close the wizard"""
|
||||||
|
return {'type': 'ir.actions.act_window_close'}
|
||||||
|
|
||||||
|
def action_view_instances(self):
|
||||||
|
"""View the synced instances"""
|
||||||
|
self.ensure_one()
|
||||||
|
|
||||||
|
if self.sync_all:
|
||||||
|
clusters = self.env['k8s.cluster'].search([('active', '=', True)])
|
||||||
|
else:
|
||||||
|
clusters = self.cluster_ids.filtered('active')
|
||||||
|
|
||||||
|
domain = [('cluster_id', 'in', clusters.ids)]
|
||||||
|
|
||||||
|
return {
|
||||||
|
'name': _('Synced Odoo Instances'),
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'res_model': 'k8s.odoo.instance',
|
||||||
|
'view_mode': 'list,form',
|
||||||
|
'domain': domain,
|
||||||
|
'context': {'search_default_group_cluster': 1},
|
||||||
|
}
|
||||||
49
k8s_odoo_manager/wizards/k8s_sync_instances_wizard_views.xml
Normal file
49
k8s_odoo_manager/wizards/k8s_sync_instances_wizard_views.xml
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<!-- Sync Instances Wizard Form -->
|
||||||
|
<record id="view_k8s_sync_instances_wizard_form" model="ir.ui.view">
|
||||||
|
<field name="name">k8s.sync.instances.wizard.form</field>
|
||||||
|
<field name="model">k8s.sync.instances.wizard</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Sync Odoo Instances">
|
||||||
|
<sheet>
|
||||||
|
<div class="oe_title">
|
||||||
|
<h1>Sync Odoo Instances from Kubernetes</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<group invisible="state != 'draft'">
|
||||||
|
<field name="sync_all"/>
|
||||||
|
<field name="cluster_ids" widget="many2many_tags" invisible="sync_all" required="not sync_all"/>
|
||||||
|
<field name="state" invisible="1"/>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<div invisible="state != 'done'">
|
||||||
|
<h3>Sync Results:</h3>
|
||||||
|
<field name="sync_result" readonly="1" widget="text"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div invisible="state != 'syncing'">
|
||||||
|
<div class="text-center">
|
||||||
|
<i class="fa fa-spinner fa-spin fa-2x" title="Loading"/>
|
||||||
|
<p>Synchronizing instances from clusters...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sheet>
|
||||||
|
<footer>
|
||||||
|
<button string="Start Sync" name="action_sync_instances" type="object" class="btn-primary" invisible="state != 'draft'"/>
|
||||||
|
<button string="View Instances" name="action_view_instances" type="object" class="btn-secondary" invisible="state != 'done'"/>
|
||||||
|
<button string="Close" name="action_close" type="object" class="btn-secondary" invisible="state == 'syncing'"/>
|
||||||
|
<button string="Cancel" class="btn-secondary" special="cancel" invisible="state != 'syncing'"/>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Sync Instances Wizard Action -->
|
||||||
|
<record id="action_k8s_sync_instances_wizard" model="ir.actions.act_window">
|
||||||
|
<field name="name">Sync Odoo Instances</field>
|
||||||
|
<field name="res_model">k8s.sync.instances.wizard</field>
|
||||||
|
<field name="view_mode">form</field>
|
||||||
|
<field name="target">new</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
# Stock Inventory Adjustment Security
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This module adds granular security controls for inventory adjustments in Odoo. It allows you to separate the ability to **count inventory** from the ability to **apply adjustments**.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- **New Security Group**: "Inventory: Manual Adjustments"
|
|
||||||
- **Separation of Duties**:
|
|
||||||
- Regular inventory users can view stock and enter counted quantities
|
|
||||||
- Only privileged users can apply the adjustments to modify actual stock levels
|
|
||||||
- **No Impact on Automatic Operations**: Stock moves from pickings, manufacturing orders, and other automatic operations work normally
|
|
||||||
|
|
||||||
## Use Case
|
|
||||||
|
|
||||||
Perfect for organizations that want to:
|
|
||||||
- Allow warehouse staff to participate in inventory counts
|
|
||||||
- Restrict who can actually modify stock levels
|
|
||||||
- Maintain audit trails by limiting adjustment privileges
|
|
||||||
- Implement proper inventory control procedures
|
|
||||||
|
|
||||||
## How It Works
|
|
||||||
|
|
||||||
### Technical Implementation
|
|
||||||
|
|
||||||
The module overrides the `write()` method on `stock.quant` to check:
|
|
||||||
1. If the operation is in "inventory mode" (manual adjustment context)
|
|
||||||
2. If the `quantity` field is being modified
|
|
||||||
3. If the user has the required security group
|
|
||||||
|
|
||||||
The check uses Odoo's built-in `_is_inventory_mode()` method which returns `True` only when:
|
|
||||||
- The `inventory_mode` context flag is set (manual adjustments)
|
|
||||||
- The user has the `stock.group_stock_user` group
|
|
||||||
|
|
||||||
### User Experience
|
|
||||||
|
|
||||||
**Regular Inventory User:**
|
|
||||||
- Can navigate to Inventory > Inventory Adjustments
|
|
||||||
- Can view current stock quantities
|
|
||||||
- Can enter counted quantities in the `inventory_quantity` field
|
|
||||||
- **Cannot** click "Apply" to commit the changes
|
|
||||||
- Receives clear error message when attempting to apply
|
|
||||||
|
|
||||||
**Privileged User (with Manual Adjustments group):**
|
|
||||||
- Can do everything a regular user can do
|
|
||||||
- **Can** click "Apply" to commit inventory adjustments
|
|
||||||
- Can modify actual stock quantities
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
1. Install the module
|
|
||||||
2. Go to Settings > Users & Companies > Users
|
|
||||||
3. Edit a user who should be able to apply inventory adjustments
|
|
||||||
4. Add them to the "Inventory: Manual Adjustments" group
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
The module includes comprehensive test coverage:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run all tests
|
|
||||||
odoo-bin -d your_database -i stock_inventory_adjustment_security --test-enable --stop-after-init
|
|
||||||
|
|
||||||
# Run specific test class
|
|
||||||
odoo-bin -d your_database --test-tags stock_inventory_adjustment_security
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test Cases
|
|
||||||
|
|
||||||
- ✅ Regular users can set inventory_quantity (count)
|
|
||||||
- ✅ Regular users cannot apply adjustments
|
|
||||||
- ✅ Privileged users can apply adjustments
|
|
||||||
- ✅ Automatic operations (pickings, etc.) are not affected
|
|
||||||
- ✅ Inventory mode detection works correctly
|
|
||||||
- ✅ Multiple quant adjustments work correctly
|
|
||||||
- ✅ Non-inventory mode writes are not restricted
|
|
||||||
|
|
||||||
## Compatibility
|
|
||||||
|
|
||||||
- Odoo 18.0
|
|
||||||
- Depends on: `stock`
|
|
||||||
|
|
||||||
## Translations
|
|
||||||
|
|
||||||
- English (en)
|
|
||||||
- French (fr)
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
LGPL-3
|
|
||||||
|
|
||||||
## Author
|
|
||||||
|
|
||||||
Bemade Inc.
|
|
||||||
https://bemade.org
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
from . import models
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
{
|
|
||||||
"name": "Stock Inventory Adjustment Security",
|
|
||||||
"author": "Bemade Inc.",
|
|
||||||
"version": "18.0.1.0.0",
|
|
||||||
"category": "Inventory/Inventory",
|
|
||||||
"summary": "Restrict manual inventory adjustments to privileged users",
|
|
||||||
"description": """
|
|
||||||
Stock Inventory Adjustment Security
|
|
||||||
====================================
|
|
||||||
|
|
||||||
This module adds an additional security layer for inventory adjustments:
|
|
||||||
|
|
||||||
* Creates a new security group "Inventory: Manual Adjustments"
|
|
||||||
* Regular inventory users can view and count inventory (set inventory_quantity)
|
|
||||||
* Only users in the privileged group can apply adjustments (modify actual quantity)
|
|
||||||
* Automatic stock operations (pickings, manufacturing, etc.) are not affected
|
|
||||||
|
|
||||||
This allows inventory staff to participate in counts while restricting who can
|
|
||||||
actually apply the adjustments and modify stock levels.
|
|
||||||
""",
|
|
||||||
"website": "https://www.bemade.org",
|
|
||||||
"depends": ["stock"],
|
|
||||||
"data": [
|
|
||||||
"security/security.xml",
|
|
||||||
],
|
|
||||||
"installable": True,
|
|
||||||
"auto_install": False,
|
|
||||||
"license": "LGPL-3",
|
|
||||||
"application": False,
|
|
||||||
}
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
# Translation of Odoo Server.
|
|
||||||
# This file contains the translation of the following modules:
|
|
||||||
# * stock_inventory_adjustment_security
|
|
||||||
#
|
|
||||||
msgid ""
|
|
||||||
msgstr ""
|
|
||||||
"Project-Id-Version: Odoo Server 18.0\n"
|
|
||||||
"Report-Msgid-Bugs-To: \n"
|
|
||||||
"POT-Creation-Date: 2025-10-02 21:15:00+0000\n"
|
|
||||||
"PO-Revision-Date: 2025-10-02 21:15:00+0000\n"
|
|
||||||
"Last-Translator: \n"
|
|
||||||
"Language-Team: \n"
|
|
||||||
"Language: fr\n"
|
|
||||||
"MIME-Version: 1.0\n"
|
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
|
||||||
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
|
||||||
|
|
||||||
#. module: stock_inventory_adjustment_security
|
|
||||||
#: code:addons/stock_inventory_adjustment_security/models/stock_quant.py:0
|
|
||||||
#, python-format
|
|
||||||
msgid ""
|
|
||||||
"You don't have permission to apply inventory adjustments. You can count "
|
|
||||||
"inventory, but only authorized users can apply the changes. Please contact "
|
|
||||||
"your inventory manager."
|
|
||||||
msgstr ""
|
|
||||||
"Vous n'avez pas la permission d'appliquer des ajustements d'inventaire. "
|
|
||||||
"Vous pouvez compter l'inventaire, mais seuls les utilisateurs autorisés "
|
|
||||||
"peuvent appliquer les modifications. Veuillez contacter votre gestionnaire "
|
|
||||||
"d'inventaire."
|
|
||||||
|
|
||||||
#. module: stock_inventory_adjustment_security
|
|
||||||
#: model:res.groups,name:stock_inventory_adjustment_security.group_inventory_manual_adjustments
|
|
||||||
msgid "Manual Adjustments"
|
|
||||||
msgstr "Ajustements manuels"
|
|
||||||
|
|
||||||
#. module: stock_inventory_adjustment_security
|
|
||||||
#: model:res.groups,comment:stock_inventory_adjustment_security.group_inventory_manual_adjustments
|
|
||||||
msgid ""
|
|
||||||
"Users in this group can apply manual inventory adjustments.\n"
|
|
||||||
"Regular inventory users can count inventory but cannot apply the adjustments."
|
|
||||||
msgstr ""
|
|
||||||
"Les utilisateurs de ce groupe peuvent appliquer des ajustements d'inventaire manuels.\n"
|
|
||||||
"Les utilisateurs d'inventaire réguliers peuvent compter l'inventaire mais ne peuvent pas appliquer les ajustements."
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
# Translation of Odoo Server.
|
|
||||||
# This file contains the translation of the following modules:
|
|
||||||
# * stock_inventory_adjustment_security
|
|
||||||
#
|
|
||||||
msgid ""
|
|
||||||
msgstr ""
|
|
||||||
"Project-Id-Version: Odoo Server 18.0\n"
|
|
||||||
"Report-Msgid-Bugs-To: \n"
|
|
||||||
"POT-Creation-Date: 2025-10-02 21:15:00+0000\n"
|
|
||||||
"PO-Revision-Date: 2025-10-02 21:15:00+0000\n"
|
|
||||||
"Last-Translator: \n"
|
|
||||||
"Language-Team: \n"
|
|
||||||
"MIME-Version: 1.0\n"
|
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
|
||||||
"Plural-Forms: \n"
|
|
||||||
|
|
||||||
#. module: stock_inventory_adjustment_security
|
|
||||||
#: code:addons/stock_inventory_adjustment_security/models/stock_quant.py:0
|
|
||||||
#, python-format
|
|
||||||
msgid ""
|
|
||||||
"You don't have permission to apply inventory adjustments. You can count "
|
|
||||||
"inventory, but only authorized users can apply the changes. Please contact "
|
|
||||||
"your inventory manager."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#. module: stock_inventory_adjustment_security
|
|
||||||
#: model:res.groups,name:stock_inventory_adjustment_security.group_inventory_manual_adjustments
|
|
||||||
msgid "Manual Adjustments"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#. module: stock_inventory_adjustment_security
|
|
||||||
#: model:res.groups,comment:stock_inventory_adjustment_security.group_inventory_manual_adjustments
|
|
||||||
msgid ""
|
|
||||||
"Users in this group can apply manual inventory adjustments.\n"
|
|
||||||
"Regular inventory users can count inventory but cannot apply the adjustments."
|
|
||||||
msgstr ""
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
from . import stock_quant
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
from odoo import models, _, api
|
|
||||||
from odoo.exceptions import AccessError
|
|
||||||
|
|
||||||
|
|
||||||
class StockQuant(models.Model):
|
|
||||||
_inherit = "stock.quant"
|
|
||||||
|
|
||||||
def _check_inventory_adjustment_permission(self):
|
|
||||||
"""
|
|
||||||
Check if the current user has permission to make inventory adjustments.
|
|
||||||
|
|
||||||
Raises AccessError if the user lacks the required group.
|
|
||||||
"""
|
|
||||||
if not self.env.user.has_group(
|
|
||||||
"stock_inventory_adjustment_security.group_inventory_manual_adjustments"
|
|
||||||
):
|
|
||||||
raise AccessError(
|
|
||||||
_(
|
|
||||||
"You don't have permission to apply inventory adjustments. "
|
|
||||||
"You can count inventory, but only authorized users can apply the changes. "
|
|
||||||
"Please contact your inventory manager."
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def action_apply_inventory(self):
|
|
||||||
"""
|
|
||||||
Override action_apply_inventory to restrict who can apply adjustments.
|
|
||||||
|
|
||||||
This is the main entry point for applying manual inventory adjustments.
|
|
||||||
Regular users can count, but only privileged users can apply.
|
|
||||||
"""
|
|
||||||
self._check_inventory_adjustment_permission()
|
|
||||||
return super().action_apply_inventory()
|
|
||||||
|
|
||||||
@api.model_create_multi
|
|
||||||
def create(self, vals_list):
|
|
||||||
"""
|
|
||||||
Override create to restrict quant creation in inventory mode.
|
|
||||||
|
|
||||||
Regular users should not be able to create new quants in inventory mode
|
|
||||||
as this is another way to manipulate inventory levels.
|
|
||||||
"""
|
|
||||||
if self._is_inventory_mode():
|
|
||||||
self._check_inventory_adjustment_permission()
|
|
||||||
|
|
||||||
return super().create(vals_list)
|
|
||||||
|
|
||||||
def write(self, vals):
|
|
||||||
"""
|
|
||||||
Override write to restrict quantity changes in inventory mode.
|
|
||||||
|
|
||||||
This catches direct quantity modifications during inventory adjustments.
|
|
||||||
Automatic operations (pickings, manufacturing, etc.) don't set
|
|
||||||
inventory_mode context, so they are unaffected.
|
|
||||||
"""
|
|
||||||
if self._is_inventory_mode() and "quantity" in vals:
|
|
||||||
self._check_inventory_adjustment_permission()
|
|
||||||
|
|
||||||
return super().write(vals)
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<odoo>
|
|
||||||
<record id="group_inventory_manual_adjustments" model="res.groups">
|
|
||||||
<field name="name">Manual Adjustments</field>
|
|
||||||
<field name="category_id" ref="base.module_category_inventory_inventory"/>
|
|
||||||
<field name="implied_ids" eval="[(4, ref('stock.group_stock_user'))]"/>
|
|
||||||
<field name="comment">
|
|
||||||
Users in this group can apply manual inventory adjustments.
|
|
||||||
Regular inventory users can count inventory but cannot apply the adjustments.
|
|
||||||
</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<!-- Make stock manager imply manual adjustments -->
|
|
||||||
<record id="stock.group_stock_manager" model="res.groups">
|
|
||||||
<field name="implied_ids" eval="[(4, ref('group_inventory_manual_adjustments'))]"/>
|
|
||||||
</record>
|
|
||||||
</odoo>
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
from . import test_inventory_adjustment_security
|
|
||||||
|
|
@ -1,326 +0,0 @@
|
||||||
from odoo.tests.common import TransactionCase
|
|
||||||
from odoo.exceptions import AccessError
|
|
||||||
|
|
||||||
|
|
||||||
class TestInventoryAdjustmentSecurity(TransactionCase):
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def setUpClass(cls):
|
|
||||||
super().setUpClass()
|
|
||||||
|
|
||||||
# Create test product
|
|
||||||
cls.product = cls.env["product.product"].create(
|
|
||||||
{
|
|
||||||
"name": "Test Product",
|
|
||||||
"type": "consu",
|
|
||||||
"is_storable": True,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get stock location
|
|
||||||
cls.stock_location = cls.env.ref("stock.warehouse0").lot_stock_id
|
|
||||||
|
|
||||||
# Create initial quant with some quantity
|
|
||||||
cls.quant = (
|
|
||||||
cls.env["stock.quant"]
|
|
||||||
.sudo()
|
|
||||||
.create(
|
|
||||||
{
|
|
||||||
"product_id": cls.product.id,
|
|
||||||
"location_id": cls.stock_location.id,
|
|
||||||
"quantity": 100.0,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create test users
|
|
||||||
# Regular inventory user (can count but not apply)
|
|
||||||
cls.inventory_user = cls.env["res.users"].create(
|
|
||||||
{
|
|
||||||
"name": "Inventory Counter",
|
|
||||||
"login": "inventory_counter",
|
|
||||||
"email": "counter@test.com",
|
|
||||||
"groups_id": [(6, 0, [cls.env.ref("stock.group_stock_user").id])],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Privileged user (can count and apply)
|
|
||||||
cls.privileged_user = cls.env["res.users"].create(
|
|
||||||
{
|
|
||||||
"name": "Inventory Manager",
|
|
||||||
"login": "inventory_manager",
|
|
||||||
"email": "manager@test.com",
|
|
||||||
"groups_id": [
|
|
||||||
(
|
|
||||||
6,
|
|
||||||
0,
|
|
||||||
[
|
|
||||||
cls.env.ref("stock.group_stock_user").id,
|
|
||||||
cls.env.ref(
|
|
||||||
"stock_inventory_adjustment_security.group_inventory_manual_adjustments"
|
|
||||||
).id,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_regular_user_can_set_inventory_quantity(self):
|
|
||||||
"""Test that regular users can set inventory_quantity (count)"""
|
|
||||||
quant = self.quant.with_user(self.inventory_user).with_context(
|
|
||||||
inventory_mode=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# Should be able to set inventory_quantity
|
|
||||||
quant.write({"inventory_quantity": 90.0})
|
|
||||||
self.assertEqual(quant.inventory_quantity, 90.0)
|
|
||||||
self.assertEqual(quant.inventory_diff_quantity, -10.0)
|
|
||||||
|
|
||||||
def test_regular_user_cannot_apply_adjustment(self):
|
|
||||||
"""Test that regular users cannot apply adjustments (modify quantity)"""
|
|
||||||
quant = self.quant.with_user(self.inventory_user).with_context(inventory_mode=True)
|
|
||||||
|
|
||||||
# Set inventory quantity first
|
|
||||||
quant.write({"inventory_quantity": 90.0})
|
|
||||||
|
|
||||||
# Trying to apply (which writes to quantity field) should fail
|
|
||||||
with self.assertRaises(AccessError):
|
|
||||||
quant.write({"quantity": 90.0})
|
|
||||||
|
|
||||||
def test_regular_user_cannot_create_quants_in_inventory_mode(self):
|
|
||||||
"""Test that regular users cannot create new quants in inventory mode"""
|
|
||||||
# Create a new product
|
|
||||||
new_product = self.env["product.product"].create(
|
|
||||||
{
|
|
||||||
"name": "New Product",
|
|
||||||
"type": "consu",
|
|
||||||
"is_storable": True,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Trying to create a quant in inventory mode should fail
|
|
||||||
with self.assertRaises(AccessError):
|
|
||||||
self.env["stock.quant"].with_user(self.inventory_user).with_context(
|
|
||||||
inventory_mode=True
|
|
||||||
).create(
|
|
||||||
{
|
|
||||||
"product_id": new_product.id,
|
|
||||||
"location_id": self.stock_location.id,
|
|
||||||
"quantity": 50.0,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_regular_user_cannot_call_apply_inventory(self):
|
|
||||||
"""Test that regular users cannot call action_apply_inventory"""
|
|
||||||
quant = self.quant.with_user(self.inventory_user).with_context(
|
|
||||||
inventory_mode=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# Set inventory quantity
|
|
||||||
quant.write({"inventory_quantity": 90.0})
|
|
||||||
|
|
||||||
# Trying to apply inventory should fail
|
|
||||||
with self.assertRaises(AccessError):
|
|
||||||
quant.action_apply_inventory()
|
|
||||||
|
|
||||||
def test_privileged_user_can_apply_adjustment(self):
|
|
||||||
"""Test that privileged users can apply adjustments"""
|
|
||||||
quant = self.quant.with_user(self.privileged_user).with_context(
|
|
||||||
inventory_mode=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# Set inventory quantity
|
|
||||||
quant.write({"inventory_quantity": 90.0})
|
|
||||||
|
|
||||||
# Should be able to apply the adjustment
|
|
||||||
quant.action_apply_inventory()
|
|
||||||
|
|
||||||
# Quantity should be updated
|
|
||||||
self.assertEqual(quant.quantity, 90.0)
|
|
||||||
self.assertEqual(quant.inventory_quantity_set, False)
|
|
||||||
|
|
||||||
def test_privileged_user_can_create_quants_in_inventory_mode(self):
|
|
||||||
"""Test that privileged users can create new quants in inventory mode"""
|
|
||||||
# Create a new product
|
|
||||||
new_product = self.env["product.product"].create(
|
|
||||||
{
|
|
||||||
"name": "Privileged Product",
|
|
||||||
"type": "consu",
|
|
||||||
"is_storable": True,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Privileged user should be able to create a quant in inventory mode
|
|
||||||
quant = self.env["stock.quant"].with_user(self.privileged_user).with_context(
|
|
||||||
inventory_mode=True
|
|
||||||
).create(
|
|
||||||
{
|
|
||||||
"product_id": new_product.id,
|
|
||||||
"location_id": self.stock_location.id,
|
|
||||||
"quantity": 50.0,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(quant.quantity, 50.0)
|
|
||||||
self.assertEqual(quant.product_id, new_product)
|
|
||||||
|
|
||||||
def test_automatic_operations_not_affected(self):
|
|
||||||
"""Test that automatic stock operations work normally"""
|
|
||||||
# Create a stock move (simulating a picking or manufacturing operation)
|
|
||||||
# These operations don't set inventory_mode context
|
|
||||||
move = self.env["stock.move"].create(
|
|
||||||
{
|
|
||||||
"name": "Test Move",
|
|
||||||
"product_id": self.product.id,
|
|
||||||
"product_uom_qty": 10.0,
|
|
||||||
"product_uom": self.product.uom_id.id,
|
|
||||||
"location_id": self.env.ref("stock.stock_location_suppliers").id,
|
|
||||||
"location_dest_id": self.stock_location.id,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
move._action_confirm()
|
|
||||||
move._action_assign()
|
|
||||||
# Set quantity on the move itself
|
|
||||||
move.quantity = 10.0
|
|
||||||
move.picked = True
|
|
||||||
move._action_done()
|
|
||||||
|
|
||||||
# Quant quantity should be updated automatically
|
|
||||||
# Find the quant for this product in stock location
|
|
||||||
quant = self.env["stock.quant"].search(
|
|
||||||
[
|
|
||||||
("product_id", "=", self.product.id),
|
|
||||||
("location_id", "=", self.stock_location.id),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
self.assertEqual(quant.quantity, 110.0)
|
|
||||||
|
|
||||||
def test_regular_user_can_view_quantities(self):
|
|
||||||
"""Test that regular users can view all quantity fields"""
|
|
||||||
quant = self.quant.with_user(self.inventory_user)
|
|
||||||
|
|
||||||
# Should be able to read all fields
|
|
||||||
self.assertEqual(quant.quantity, 100.0)
|
|
||||||
self.assertEqual(quant.inventory_quantity, 0.0)
|
|
||||||
|
|
||||||
# Should be able to read in inventory mode too
|
|
||||||
quant_inv_mode = quant.with_context(inventory_mode=True)
|
|
||||||
self.assertEqual(quant_inv_mode.quantity, 100.0)
|
|
||||||
|
|
||||||
def test_non_inventory_mode_writes_allowed(self):
|
|
||||||
"""Test that quantity writes outside inventory mode are not restricted"""
|
|
||||||
# Even regular users can write quantity when not in inventory_mode
|
|
||||||
# (though they typically wouldn't have access to do this via UI)
|
|
||||||
quant = self.quant.sudo()
|
|
||||||
|
|
||||||
# Without inventory_mode context, write should work
|
|
||||||
quant.write({"quantity": 95.0})
|
|
||||||
|
|
||||||
self.assertEqual(quant.quantity, 95.0)
|
|
||||||
|
|
||||||
def test_inventory_mode_detection(self):
|
|
||||||
"""Test that _is_inventory_mode() works correctly"""
|
|
||||||
quant = self.quant.with_user(self.inventory_user)
|
|
||||||
|
|
||||||
# Without inventory_mode context
|
|
||||||
self.assertFalse(quant._is_inventory_mode())
|
|
||||||
|
|
||||||
# With inventory_mode context
|
|
||||||
quant_inv = quant.with_context(inventory_mode=True)
|
|
||||||
self.assertTrue(quant_inv._is_inventory_mode())
|
|
||||||
|
|
||||||
# User without stock_user group shouldn't trigger inventory mode
|
|
||||||
basic_user = self.env["res.users"].create(
|
|
||||||
{
|
|
||||||
"name": "Basic User",
|
|
||||||
"login": "basic_user",
|
|
||||||
"email": "basic@test.com",
|
|
||||||
"groups_id": [(6, 0, [self.env.ref("base.group_user").id])],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
quant_basic = self.quant.with_user(basic_user).with_context(inventory_mode=True)
|
|
||||||
self.assertFalse(quant_basic._is_inventory_mode())
|
|
||||||
|
|
||||||
def test_multiple_quants_adjustment(self):
|
|
||||||
"""Test applying adjustments to multiple quants at once"""
|
|
||||||
# Create another product to avoid quant merging
|
|
||||||
product2 = self.env["product.product"].create(
|
|
||||||
{
|
|
||||||
"name": "Test Product 2",
|
|
||||||
"type": "consu",
|
|
||||||
"is_storable": True,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create another quant with different product
|
|
||||||
quant2 = (
|
|
||||||
self.env["stock.quant"]
|
|
||||||
.sudo()
|
|
||||||
.create(
|
|
||||||
{
|
|
||||||
"product_id": product2.id,
|
|
||||||
"location_id": self.stock_location.id,
|
|
||||||
"quantity": 50.0,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Set inventory quantities on both quants
|
|
||||||
self.quant.with_user(self.privileged_user).with_context(
|
|
||||||
inventory_mode=True
|
|
||||||
).write({"inventory_quantity": 80.0})
|
|
||||||
quant2.with_user(self.privileged_user).with_context(inventory_mode=True).write(
|
|
||||||
{"inventory_quantity": 40.0}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Apply all at once
|
|
||||||
quants = (
|
|
||||||
(self.quant | quant2)
|
|
||||||
.with_user(self.privileged_user)
|
|
||||||
.with_context(inventory_mode=True)
|
|
||||||
)
|
|
||||||
quants.action_apply_inventory()
|
|
||||||
|
|
||||||
self.assertEqual(self.quant.quantity, 80.0)
|
|
||||||
self.assertEqual(quant2.quantity, 40.0)
|
|
||||||
|
|
||||||
def test_regular_user_multiple_quants_blocked(self):
|
|
||||||
"""Test that regular users cannot apply multiple quants"""
|
|
||||||
# Create another product to avoid quant merging
|
|
||||||
product2 = self.env["product.product"].create(
|
|
||||||
{
|
|
||||||
"name": "Test Product 3",
|
|
||||||
"type": "consu",
|
|
||||||
"is_storable": True,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
quant2 = (
|
|
||||||
self.env["stock.quant"]
|
|
||||||
.sudo()
|
|
||||||
.create(
|
|
||||||
{
|
|
||||||
"product_id": product2.id,
|
|
||||||
"location_id": self.stock_location.id,
|
|
||||||
"quantity": 50.0,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Set inventory quantities
|
|
||||||
self.quant.with_user(self.inventory_user).with_context(
|
|
||||||
inventory_mode=True
|
|
||||||
).write({"inventory_quantity": 80.0})
|
|
||||||
quant2.with_user(self.inventory_user).with_context(inventory_mode=True).write(
|
|
||||||
{"inventory_quantity": 40.0}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Trying to apply should fail
|
|
||||||
quants = (
|
|
||||||
(self.quant | quant2)
|
|
||||||
.with_user(self.inventory_user)
|
|
||||||
.with_context(inventory_mode=True)
|
|
||||||
)
|
|
||||||
with self.assertRaises(AccessError):
|
|
||||||
quants.action_apply_inventory()
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
# Test module for CalDAV sync appointments integration
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
{
|
|
||||||
'name': 'Test CalDAV Sync Appointments Integration',
|
|
||||||
'version': '18.0.1.0.0',
|
|
||||||
'category': 'Hidden/Tests',
|
|
||||||
'summary': 'Test module for CalDAV sync integration with appointments',
|
|
||||||
'description': """
|
|
||||||
Test CalDAV Sync Appointments Integration
|
|
||||||
=========================================
|
|
||||||
|
|
||||||
This module contains tests for the integration between CalDAV sync and the
|
|
||||||
Appointments app. It verifies that appointment events are properly handled
|
|
||||||
during CalDAV synchronization operations.
|
|
||||||
|
|
||||||
This is a test-only module and should not be installed in production.
|
|
||||||
""",
|
|
||||||
'author': 'Bemade Inc.',
|
|
||||||
'website': 'https://www.bemade.org',
|
|
||||||
'depends': [
|
|
||||||
'caldav_sync',
|
|
||||||
'appointment',
|
|
||||||
],
|
|
||||||
'installable': True,
|
|
||||||
'auto_install': False,
|
|
||||||
'application': False,
|
|
||||||
# Mark as test module
|
|
||||||
'post_init_hook': None,
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
from . import test_appointment_caldav_integration
|
|
||||||
|
|
@ -1,183 +0,0 @@
|
||||||
"""Test CalDAV sync integration with appointment events."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from unittest.mock import patch, MagicMock
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from contextlib import contextmanager
|
|
||||||
|
|
||||||
from odoo.tests.common import TransactionCase, tagged
|
|
||||||
from odoo import Command
|
|
||||||
from odoo.addons.caldav_sync.tests.common import CaldavTestCommon
|
|
||||||
from odoo.tools import mute_logger
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def _patch_caldav_for_appointments(user):
|
|
||||||
"""Patch CalDAV operations for appointment testing, capturing real iCalendar events."""
|
|
||||||
with mute_logger("odoo.addons.caldav_sync.models.res_users"), patch(
|
|
||||||
"caldav.DAVClient"
|
|
||||||
) as MockDAVClient:
|
|
||||||
mock_client = MockDAVClient.return_value
|
|
||||||
mock_calendar = MagicMock()
|
|
||||||
mock_client.calendar.return_value = mock_calendar
|
|
||||||
|
|
||||||
# Storage for created CalDAV events and global delete tracking
|
|
||||||
created_events = {} # uid -> caldav_event
|
|
||||||
global_delete_calls = [] # Track all delete calls
|
|
||||||
|
|
||||||
def save_event_side_effect(**ical_data):
|
|
||||||
"""Let the real CalDAV event creation happen, then capture and store it."""
|
|
||||||
# Create a mock CalDAV event that we can track delete() calls on
|
|
||||||
mock_caldav_event = MagicMock()
|
|
||||||
|
|
||||||
# Set up the vobject_instance structure that CalDAV sync expects
|
|
||||||
uid = ical_data.get("uid", f"test-uid-{len(created_events)}")
|
|
||||||
mock_caldav_event.vobject_instance.vevent.uid.value = uid
|
|
||||||
|
|
||||||
# Ensure delete method is properly trackable and records calls globally
|
|
||||||
def delete_side_effect():
|
|
||||||
global_delete_calls.append(uid)
|
|
||||||
|
|
||||||
mock_caldav_event.delete = MagicMock(side_effect=delete_side_effect)
|
|
||||||
|
|
||||||
# Set up icalendar_component structure using the real iCalendar data
|
|
||||||
mock_ical_component = MagicMock()
|
|
||||||
|
|
||||||
def ical_component_get(key):
|
|
||||||
if key == "dtstart":
|
|
||||||
mock_dtstart = MagicMock()
|
|
||||||
dtstart_value = ical_data.get("dtstart")
|
|
||||||
|
|
||||||
# Handle vDatetime objects from iCalendar library
|
|
||||||
if hasattr(dtstart_value, "dt"):
|
|
||||||
# It's already a vDatetime, extract the actual datetime
|
|
||||||
actual_dt = dtstart_value.dt
|
|
||||||
else:
|
|
||||||
# It's a regular datetime
|
|
||||||
actual_dt = dtstart_value
|
|
||||||
|
|
||||||
# Create a mock that has tzinfo like a regular datetime
|
|
||||||
mock_dt = MagicMock()
|
|
||||||
mock_dt.tzinfo = getattr(actual_dt, "tzinfo", None)
|
|
||||||
mock_dt.__eq__ = lambda self, other: actual_dt == other
|
|
||||||
|
|
||||||
mock_dtstart.dt = mock_dt
|
|
||||||
return mock_dtstart
|
|
||||||
return MagicMock()
|
|
||||||
|
|
||||||
mock_ical_component.get.side_effect = ical_component_get
|
|
||||||
mock_caldav_event.icalendar_component = mock_ical_component
|
|
||||||
|
|
||||||
# Store the event by UID for later retrieval
|
|
||||||
created_events[uid] = mock_caldav_event
|
|
||||||
|
|
||||||
return mock_caldav_event
|
|
||||||
|
|
||||||
def event_by_uid_side_effect(uid):
|
|
||||||
"""Return the previously created CalDAV event by UID."""
|
|
||||||
if uid in created_events:
|
|
||||||
# Always return the same mock object for the same UID
|
|
||||||
return created_events[uid]
|
|
||||||
# Raise NotFoundError if event doesn't exist (like real CalDAV)
|
|
||||||
from caldav.lib.error import NotFoundError
|
|
||||||
|
|
||||||
raise NotFoundError("Event not found")
|
|
||||||
|
|
||||||
# Set up the mock methods
|
|
||||||
mock_calendar.events.return_value = []
|
|
||||||
mock_calendar.save_event.side_effect = save_event_side_effect
|
|
||||||
mock_calendar.event_by_uid.side_effect = event_by_uid_side_effect
|
|
||||||
|
|
||||||
# Enable CalDAV for the user
|
|
||||||
user.write({"is_caldav_enabled": True})
|
|
||||||
|
|
||||||
# Return both the mock calendar and the global delete tracking
|
|
||||||
mock_calendar.global_delete_calls = global_delete_calls
|
|
||||||
yield mock_calendar
|
|
||||||
|
|
||||||
|
|
||||||
@tagged("post_install", "-at_install")
|
|
||||||
class TestAppointmentCalDAVIntegration(TransactionCase, CaldavTestCommon):
|
|
||||||
"""Test CalDAV sync behavior with appointment events."""
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def setUpClass(cls):
|
|
||||||
super().setUpClass()
|
|
||||||
|
|
||||||
# Create test user with CalDAV enabled using the common method
|
|
||||||
cls.test_user = cls._generate_user(
|
|
||||||
"test_caldav_user",
|
|
||||||
caldav_username="testuser",
|
|
||||||
caldav_password="testpass",
|
|
||||||
caldav_url="https://test.caldav.server/calendar/testuser",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create test partner
|
|
||||||
cls.test_partner = cls.env["res.partner"].create(
|
|
||||||
{
|
|
||||||
"name": "Test Partner",
|
|
||||||
"email": "partner@example.com",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create appointment type
|
|
||||||
cls.appointment_type = cls.env["appointment.type"].create(
|
|
||||||
{
|
|
||||||
"category": "custom",
|
|
||||||
"appointment_duration": 1.0,
|
|
||||||
"staff_user_ids": [Command.link(cls.test_user.id)],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_appointment_booking_syncs_to_caldav(self):
|
|
||||||
"""Test that appointments created through the booking interface sync to CalDAV.
|
|
||||||
|
|
||||||
BUG: When a public user books an appointment through the booking interface,
|
|
||||||
the event is created in Odoo but NOT synced to the CalDAV server. This causes
|
|
||||||
the event to be deleted on the next polling round since CalDAV sync thinks it
|
|
||||||
was deleted from the server.
|
|
||||||
"""
|
|
||||||
|
|
||||||
with _patch_caldav_for_appointments(self.test_user) as mock_calendar:
|
|
||||||
# Simulate the appointment booking flow - appointments are created with
|
|
||||||
# the staff user but initiated by a public/portal user
|
|
||||||
# The key is that we need to simulate how the appointment controller creates events
|
|
||||||
|
|
||||||
# Create appointment event as it would be created during booking
|
|
||||||
appointment_event = (
|
|
||||||
self.env["calendar.event"]
|
|
||||||
.with_user(self.test_user)
|
|
||||||
.create(
|
|
||||||
{
|
|
||||||
"name": f"{self.appointment_type.name} with {self.test_user.name}",
|
|
||||||
"start": datetime.now() + timedelta(days=1),
|
|
||||||
"stop": datetime.now() + timedelta(days=1, hours=1),
|
|
||||||
"user_id": self.test_user.id,
|
|
||||||
"partner_ids": [(4, self.test_partner.id)],
|
|
||||||
"appointment_type_id": self.appointment_type.id,
|
|
||||||
"appointment_status": "booked",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Debug output
|
|
||||||
print(f"\nDEBUG Appointment Booking:")
|
|
||||||
print(f" save_event called: {mock_calendar.save_event.called}")
|
|
||||||
print(f" save_event call_count: {mock_calendar.save_event.call_count}")
|
|
||||||
print(f" caldav_uid: {appointment_event.caldav_uid}")
|
|
||||||
print(f" user_id: {appointment_event.user_id.name}")
|
|
||||||
print(f" is_caldav_enabled: {appointment_event._is_caldav_enabled()}")
|
|
||||||
|
|
||||||
# THE BUG: Appointment events should be synced to CalDAV on creation
|
|
||||||
# This will FAIL if the bug exists
|
|
||||||
self.assertTrue(
|
|
||||||
mock_calendar.save_event.called,
|
|
||||||
"BUG: Appointment event was NOT synced to CalDAV server on creation! "
|
|
||||||
"This will cause it to be deleted on next polling round.",
|
|
||||||
)
|
|
||||||
self.assertTrue(
|
|
||||||
appointment_event.caldav_uid,
|
|
||||||
"BUG: Appointment event has no caldav_uid! Event will be deleted on next sync.",
|
|
||||||
)
|
|
||||||
Loading…
Reference in a new issue