Compare commits

..

5 commits

43 changed files with 840 additions and 2803 deletions

View file

@ -1,31 +0,0 @@
# 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

View file

@ -1,31 +0,0 @@
# 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

View file

@ -1,93 +0,0 @@
# 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

View file

@ -1,93 +0,0 @@
# 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

View file

@ -1,93 +0,0 @@
# 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

View file

@ -1,93 +0,0 @@
# 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

View file

@ -223,7 +223,9 @@ class CalendarEvent(models.Model):
{"caldav_uid": caldav_uid}
)
except Exception as e:
_logger.error(f"Failed to sync event to CalDAV server: {e}")
_logger.error(
f"Failed to sync event to CalDAV server: {e}", exc_info=True
)
def write(self, vals):
res = super(CalendarEvent, self.with_context(caldav_no_sync=True)).write(vals)
@ -333,8 +335,9 @@ class CalendarEvent(models.Model):
) -> Optional[int]:
ical_instance = caldav_event.icalendar_instance
for index, component in enumerate(ical_instance.subcomponents):
if component.get("name") == "VEVENT" and (
rec_id := component.get("recurrence-id")
if (
component.get("name") == "VEVENT"
and (rec_id := component.get("recurrence-id"))
and rec_id.dt == self._get_ical_recurrence_id()
):
return index
@ -347,7 +350,6 @@ class CalendarEvent(models.Model):
calendar = client.calendar(url=user.caldav_calendar_url)
try:
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:
index = self._get_subcomponent_index_for_recurrence(
caldav_event
@ -488,7 +490,12 @@ class CalendarEvent(models.Model):
def _add_event_dates(self, event_data: Dict) -> None:
"""Add pertinent dates to event data, based on self."""
tz = self.event_tz or self.env.user.tz
# Determine timezone: prefer event_tz, then user tz, finally UTC
# 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_data["last-modified"] = vDatetime(
utc.localize(self.write_date).astimezone(event_tz)

View file

@ -1,22 +0,0 @@
# 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

View file

@ -1,2 +0,0 @@
from . import models
from . import wizards

View file

@ -1,63 +0,0 @@
{
'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',
],
},
}

View file

@ -1,47 +0,0 @@
<?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>

View file

@ -1,2 +0,0 @@
from . import k8s_cluster
from . import k8s_odoo_instance

View file

@ -1,373 +0,0 @@
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),
}
)

View file

@ -1,520 +0,0 @@
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)

View file

@ -1,11 +0,0 @@
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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_k8s_cluster_user k8s.cluster user model_k8s_cluster group_k8s_user 1 0 0 0
3 access_k8s_cluster_manager k8s.cluster manager model_k8s_cluster group_k8s_manager 1 1 1 1
4 access_k8s_odoo_instance_user k8s.odoo.instance user model_k8s_odoo_instance group_k8s_user 1 0 0 0
5 access_k8s_odoo_instance_manager k8s.odoo.instance manager model_k8s_odoo_instance group_k8s_manager 1 1 1 1
6 access_k8s_spec_viewer_user k8s.spec.viewer user model_k8s_spec_viewer group_k8s_user 1 1 1 1
7 access_k8s_spec_viewer_manager k8s.spec.viewer manager model_k8s_spec_viewer group_k8s_manager 1 1 1 1
8 access_k8s_cluster_test_wizard_user k8s.cluster.test.wizard user model_k8s_cluster_test_wizard group_k8s_user 1 1 1 1
9 access_k8s_cluster_test_wizard_manager k8s.cluster.test.wizard manager model_k8s_cluster_test_wizard group_k8s_manager 1 1 1 1
10 access_k8s_sync_instances_wizard_user k8s.sync.instances.wizard user model_k8s_sync_instances_wizard group_k8s_user 1 1 1 1
11 access_k8s_sync_instances_wizard_manager k8s.sync.instances.wizard manager model_k8s_sync_instances_wizard group_k8s_manager 1 1 1 1

View file

@ -1,68 +0,0 @@
<?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>

View file

@ -1,41 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -1,111 +0,0 @@
<!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>

View file

@ -1,138 +0,0 @@
/* 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;
}
}

View file

@ -1,154 +0,0 @@
/** @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);

View file

@ -1,126 +0,0 @@
<?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>

View file

@ -1,125 +0,0 @@
<?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>

View file

@ -1,25 +0,0 @@
<?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>

View file

@ -1,224 +0,0 @@
<?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>

View file

@ -1,2 +0,0 @@
from . import k8s_cluster_test_wizard
from . import k8s_sync_instances_wizard

View file

@ -1,82 +0,0 @@
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'}

View file

@ -1,47 +0,0 @@
<?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>

View file

@ -1,132 +0,0 @@
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},
}

View file

@ -1,49 +0,0 @@
<?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>

View file

@ -0,0 +1,96 @@
# 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

View file

@ -0,0 +1 @@
from . import models

View file

@ -0,0 +1,30 @@
{
"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,
}

View file

@ -0,0 +1,44 @@
# 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."

View file

@ -0,0 +1,37 @@
# 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 ""

View file

@ -0,0 +1 @@
from . import stock_quant

View file

@ -0,0 +1,61 @@
# -*- 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)

View file

@ -0,0 +1,17 @@
<?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>

View file

@ -0,0 +1 @@
from . import test_inventory_adjustment_security

View file

@ -0,0 +1,326 @@
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()

View file

@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
# Test module for CalDAV sync appointments integration

View file

@ -0,0 +1,28 @@
# -*- 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,
}

View file

@ -0,0 +1 @@
from . import test_appointment_caldav_integration

View file

@ -0,0 +1,183 @@
"""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.",
)