diff --git a/interactive_discuss_ai/__init__.py b/interactive_discuss_ai/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/interactive_discuss_ai/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/interactive_discuss_ai/__manifest__.py b/interactive_discuss_ai/__manifest__.py new file mode 100644 index 0000000..71b0fa2 --- /dev/null +++ b/interactive_discuss_ai/__manifest__.py @@ -0,0 +1,46 @@ +{ + 'name': 'Interactive AI Voice Assistant', + 'version': '17.0.1.0.0', + 'category': 'Productivity/Communication', + 'summary': 'Natural Language Voice Interaction for Odoo using Open WebUI', + 'sequence': 1, + 'author': 'Benoît Vézina', + 'website': 'https://bemade.org', + 'maintainer': 'it@bemade.org', + 'license': 'LGPL-3', + 'description': """ +Interactive AI Voice Assistant for Odoo +===================================== + +This module enables natural language voice interaction with Odoo directly in the Discuss interface: +- Voice input processing using Open WebUI models +- Interactive AI chat assistant +- Smart context-aware responses +- Draft mode for new records creation +""", + 'depends': [ + 'base', + 'web', + 'mail', + ], + 'data': [ + 'security/ir.model.access.csv', + 'views/ai_assistant_views.xml', + 'views/ai_config_views.xml', + ], + 'assets': { + 'web.assets_backend': [ + 'interactive_discuss_ai/static/src/js/ai_assistant_chat.js', + 'interactive_discuss_ai/static/src/xml/ai_assistant_chat.xml', + ], + }, + 'demo': [], + 'installable': True, + 'application': True, + 'auto_install': False, + 'external_dependencies': { + 'python': [ + 'requests', + ], + }, +} diff --git a/interactive_discuss_ai/models/__init__.py b/interactive_discuss_ai/models/__init__.py new file mode 100644 index 0000000..c0b431d --- /dev/null +++ b/interactive_discuss_ai/models/__init__.py @@ -0,0 +1,2 @@ +from . import ai_assistant +from . import ai_config diff --git a/interactive_discuss_ai/models/ai_assistant.py b/interactive_discuss_ai/models/ai_assistant.py new file mode 100644 index 0000000..81479f7 --- /dev/null +++ b/interactive_discuss_ai/models/ai_assistant.py @@ -0,0 +1,101 @@ +from odoo import models, fields, api, _ +from odoo.exceptions import UserError + +class AIAssistantChannel(models.Model): + _name = 'ai.assistant.channel' + _description = 'AI Assistant Channel' + _inherit = ['mail.thread'] + + name = fields.Char(default="Assistant AI", readonly=True, tracking=True) + company_id = fields.Many2one( + 'res.company', + string='Company', + required=True, + default=lambda self: self.env.company, + tracking=True + ) + active = fields.Boolean(default=True, tracking=True) + user_id = fields.Many2one( + 'res.users', + string='User', + required=True, + default=lambda self: self.env.user, + tracking=True + ) + message_ids = fields.One2many( + 'mail.message', + 'res_id', + string='Messages', + domain=lambda self: [('model', '=', self._name)], + tracking=True + ) + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + for record in records: + # Create a dedicated discussion channel for the assistant + channel = self.env['mail.channel'].create({ + 'name': _('Assistant AI - %s', record.company_id.name), + 'channel_type': 'chat', + 'channel_partner_ids': [(4, record.user_id.partner_id.id)], + }) + return records + + def _get_conversation_history(self, limit=10): + """Get recent conversation history""" + self.ensure_one() + messages = self.env['mail.message'].search([ + ('model', '=', self._name), + ('res_id', '=', self.id) + ], limit=limit, order='id desc') + + history = [] + for msg in reversed(messages): + role = "assistant" if msg.author_id == self.user_id.partner_id else "user" + history.append({ + "role": role, + "content": msg.body + }) + return history + + def process_voice_message(self, voice_input): + """Process voice input and respond""" + self.ensure_one() + if not voice_input: + raise UserError(_('No voice input provided')) + + # Get conversation history + conversation_history = self._get_conversation_history() + + # Use AI service to generate response + ai_service = self.env['ai.service'].with_company(self.company_id) + response = ai_service.generate_response(voice_input, conversation_history) + + # Post response as message + self.message_post( + body=response, + message_type='comment', + subtype_xmlid='mail.mt_comment' + ) + return True + + @api.model + def get_assistant_for_user(self, user_id=None): + """Get or create an assistant for the user in their company""" + if not user_id: + user_id = self.env.user.id + + assistant = self.search([ + ('user_id', '=', user_id), + ('company_id', '=', self.env.company.id), + ('active', '=', True) + ], limit=1) + + if not assistant: + assistant = self.create({ + 'user_id': user_id, + 'company_id': self.env.company.id + }) + + return assistant diff --git a/interactive_discuss_ai/models/ai_config.py b/interactive_discuss_ai/models/ai_config.py new file mode 100644 index 0000000..35300a3 --- /dev/null +++ b/interactive_discuss_ai/models/ai_config.py @@ -0,0 +1,61 @@ +from odoo import models, fields, api +from odoo.exceptions import UserError + +class AIConfig(models.Model): + _name = 'ai.config' + _description = 'AI Configuration' + _inherit = ['mail.thread'] + + name = fields.Char(string='Name', required=True, default='Default Config', tracking=True) + company_id = fields.Many2one( + 'res.company', + string='Company', + required=True, + default=lambda self: self.env.company, + tracking=True + ) + openwebui_url = fields.Char( + string='Open WebUI URL', + required=True, + default='http://localhost:8080', + tracking=True + ) + model_name = fields.Char( + string='Model Name', + required=True, + help='Nom du modèle à utiliser dans Open WebUI', + tracking=True + ) + system_prompt = fields.Text( + string='System Prompt', + default="""Tu es un assistant Odoo expert qui aide les utilisateurs à effectuer des actions +dans le système. Analyse leurs demandes et propose des actions concrètes.""", + tracking=True + ) + temperature = fields.Float( + string='Temperature', + default=0.7, + help='Contrôle la créativité des réponses (0.0 - 1.0)', + tracking=True + ) + max_tokens = fields.Integer( + string='Max Tokens', + default=2000, + tracking=True + ) + active = fields.Boolean(default=True, tracking=True) + + _sql_constraints = [ + ('company_uniq', 'unique(company_id, active)', + 'Une seule configuration active par compagnie est autorisée!') + ] + + @api.model + def get_default_config(self): + """Récupère la configuration active pour la compagnie actuelle""" + config = self.search([ + ('active', '=', True), + ('company_id', '=', self.env.company.id) + ], limit=1) + if not config: + raise UserError('Aucune configuration AI active trouvée pour votre compagnie') diff --git a/interactive_discuss_ai/models/ai_service.py b/interactive_discuss_ai/models/ai_service.py new file mode 100644 index 0000000..8aad09f --- /dev/null +++ b/interactive_discuss_ai/models/ai_service.py @@ -0,0 +1,62 @@ +import requests +import json +from odoo import models, tools +from odoo.exceptions import UserError + +class AIService(models.AbstractModel): + _name = 'ai.service' + _description = 'AI Service for Open WebUI Integration' + + def _get_headers(self): + return { + 'Content-Type': 'application/json', + } + + def _call_openwebui_api(self, endpoint, payload): + """Appelle l'API Open WebUI avec la configuration de la compagnie actuelle""" + config = self.env['ai.config'].with_company(self.env.company).get_default_config() + url = f"{config.openwebui_url}/api/v1/{endpoint}" + + try: + response = requests.post( + url, + headers=self._get_headers(), + json=payload, + timeout=30 + ) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + raise UserError(f"Erreur de communication avec Open WebUI: {str(e)}") + + def generate_response(self, user_input, conversation_history=None): + """Génère une réponse en utilisant le modèle Open WebUI de la compagnie""" + config = self.env['ai.config'].with_company(self.env.company).get_default_config() + + messages = [] + if config.system_prompt: + messages.append({ + "role": "system", + "content": config.system_prompt + }) + + # Ajouter l'historique de conversation si disponible + if conversation_history: + messages.extend(conversation_history) + + # Ajouter l'entrée utilisateur actuelle + messages.append({ + "role": "user", + "content": user_input + }) + + payload = { + "model": config.model_name, + "messages": messages, + "temperature": config.temperature, + "max_tokens": config.max_tokens, + "stream": False + } + + response = self._call_openwebui_api('chat/completions', payload) + return response.get('choices', [{}])[0].get('message', {}).get('content', '') diff --git a/interactive_discuss_ai/security/ir.model.access.csv b/interactive_discuss_ai/security/ir.model.access.csv new file mode 100644 index 0000000..6f90eed --- /dev/null +++ b/interactive_discuss_ai/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_ai_assistant_channel_user,ai.assistant.channel.user,interactive_discuss_ai.model_ai_assistant_channel,base.group_user,1,1,1,1 +access_ai_config_user,ai.config.user,interactive_discuss_ai.model_ai_config,base.group_user,1,1,1,1 diff --git a/interactive_discuss_ai/static/src/js/ai_assistant_chat.js b/interactive_discuss_ai/static/src/js/ai_assistant_chat.js new file mode 100644 index 0000000..f5a40df --- /dev/null +++ b/interactive_discuss_ai/static/src/js/ai_assistant_chat.js @@ -0,0 +1,52 @@ +/** @odoo-module **/ + +import { registerPatch } from '@mail/model/model_core'; +import { attr } from '@mail/model/model_field'; +import { clear } from '@mail/model/model_field_command'; + +registerPatch({ + name: 'mail.composer', + fields: { + isVoiceRecording: attr({ + default: false, + }), + }, + recordMethods: { + async onClickVoiceRecord() { + if (!this.isVoiceRecording) { + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + this.mediaRecorder = new MediaRecorder(stream); + const audioChunks = []; + + this.mediaRecorder.addEventListener('dataavailable', (event) => { + audioChunks.push(event.data); + }); + + this.mediaRecorder.addEventListener('stop', async () => { + const audioBlob = new Blob(audioChunks); + // Convert to base64 and send to backend + const reader = new FileReader(); + reader.readAsDataURL(audioBlob); + reader.onloadend = async () => { + const base64data = reader.result; + await this.env.services.rpc({ + model: 'ai.assistant.channel', + method: 'process_voice_message', + args: [this.thread.id, base64data], + }); + }; + }); + + this.mediaRecorder.start(); + this.update({ isVoiceRecording: true }); + } catch (err) { + console.error('Error accessing microphone:', err); + } + } else { + this.mediaRecorder.stop(); + this.update({ isVoiceRecording: false }); + } + }, + }, +}); diff --git a/interactive_discuss_ai/static/src/xml/ai_assistant_chat.xml b/interactive_discuss_ai/static/src/xml/ai_assistant_chat.xml new file mode 100644 index 0000000..27f34d0 --- /dev/null +++ b/interactive_discuss_ai/static/src/xml/ai_assistant_chat.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/interactive_discuss_ai/views/ai_assistant_views.xml b/interactive_discuss_ai/views/ai_assistant_views.xml new file mode 100644 index 0000000..fda73d0 --- /dev/null +++ b/interactive_discuss_ai/views/ai_assistant_views.xml @@ -0,0 +1,86 @@ + + + + + ai.assistant.channel.form + ai.assistant.channel + +
+ +
+ +
+ + + + + + + +
+
+ + +
+
+
+
+ + + + ai.assistant.channel.tree + ai.assistant.channel + + + + + + + + + + + + ai.assistant.channel.search + ai.assistant.channel + + + + + + + + + + + + + + AI Assistant Channels + ai.assistant.channel + tree,form + + +

+ Create your first AI Assistant Channel! +

+

+ AI Assistant Channels allow you to interact with the AI assistant through messages. +

+
+
+ + + + + +
diff --git a/interactive_discuss_ai/views/ai_config_views.xml b/interactive_discuss_ai/views/ai_config_views.xml new file mode 100644 index 0000000..24b7906 --- /dev/null +++ b/interactive_discuss_ai/views/ai_config_views.xml @@ -0,0 +1,83 @@ + + + + ai.config.form + ai.config + +
+ +
+ +
+ + + + + + + + + + + + + + + + +
+
+ + +
+
+
+
+ + + ai.config.tree + ai.config + + + + + + + + + + + + + ai.config.search + ai.config + + + + + + + + + + + + + + + + Configuration AI + ai.config + tree,form + + {'search_default_active': 1} + + + +
diff --git a/odoo_proxmox_manager/README.md b/odoo_proxmox_manager/README.md new file mode 100644 index 0000000..d68937c --- /dev/null +++ b/odoo_proxmox_manager/README.md @@ -0,0 +1,90 @@ +# Odoo Proxmox Manager + +This module allows you to manage your Proxmox servers and virtual machines directly from Odoo 17.0. + +## Features + +- Manage multiple Proxmox servers and clusters +- Monitor server status and resources +- View and manage virtual machines +- Start, stop, restart, suspend, and resume VMs +- Group servers into clusters for better organization +- Multi-company support +- Role-based access control + +## Installation + +### Prerequisites + +1. Odoo 17.0 +2. Python package requirements: + ``` + proxmoxer + requests + ``` + +### Steps + +1. Clone this repository into your Odoo addons directory +2. Install the required Python packages: + ```bash + pip install proxmoxer requests + ``` +3. Update your Odoo addons list +4. Install the module through Odoo's Apps menu + +## Configuration + +1. Create an API Token in your Proxmox server: + - Log in to your Proxmox web interface + - Go to Datacenter -> Permissions -> API Tokens + - Create a new token and note down the Token ID and Secret + +2. In Odoo: + - Go to the Proxmox menu + - Create a new cluster (optional) + - Create a new server: + - Enter the server hostname + - Enter your API token information + - Test the connection + +## Usage + +### Managing Servers + +1. Navigate to Proxmox -> Servers +2. Create or select a server +3. Click "Sync VMs" to synchronize virtual machines +4. View server status and resource information + +### Managing Virtual Machines + +1. Navigate to Proxmox -> Virtual Machines +2. View all VMs across your servers +3. Use the action buttons to: + - Start VMs + - Stop VMs + - Restart VMs + - Suspend VMs + - Resume VMs + +### Managing Clusters + +1. Navigate to Proxmox -> Clusters +2. Create a new cluster +3. Add servers to the cluster +4. Use "Sync All Servers" to update all servers in the cluster + +## Security + +The module includes two user groups: +- Proxmox User: Can view servers and VMs +- Proxmox Manager: Can manage servers and VMs + +## Support + +For bugs or feature requests, please create an issue in the repository. + +## License + +LGPL-3 diff --git a/odoo_proxmox_manager/__init__.py b/odoo_proxmox_manager/__init__.py new file mode 100644 index 0000000..48d9904 --- /dev/null +++ b/odoo_proxmox_manager/__init__.py @@ -0,0 +1,3 @@ +from . import models +from . import controllers +from . import wizard diff --git a/odoo_proxmox_manager/__manifest__.py b/odoo_proxmox_manager/__manifest__.py new file mode 100644 index 0000000..9b6ac8a --- /dev/null +++ b/odoo_proxmox_manager/__manifest__.py @@ -0,0 +1,35 @@ +{ + 'name': 'Proxmox Manager', + 'version': '17.0.1.0.0', + 'category': 'Administration', + 'summary': 'Manage Proxmox servers and clusters from Odoo', + 'sequence': 1, + 'author': 'DurPro', + 'website': 'https://www.durpro.com', + 'license': 'LGPL-3', + 'icon': '/odoo_proxmox_manager/static/description/icon.png', + 'depends': [ + 'base', + 'web', + 'mail' + ], + 'data': [ + 'security/proxmox_security.xml', + 'security/ir.model.access.csv', + 'views/proxmox_server_views.xml', + 'views/proxmox_cluster_views.xml', + 'views/proxmox_vm_views.xml', + 'views/proxmox_menus.xml', + ], + 'assets': { + 'web.assets_backend': [ + 'odoo_proxmox_manager/static/src/components/**/*', + 'odoo_proxmox_manager/static/src/js/dashboard_view.js', + 'odoo_proxmox_manager/static/src/scss/dashboard.scss', + 'odoo_proxmox_manager/static/src/xml/dashboard.xml', + ], + }, + 'application': True, + 'installable': True, + 'auto_install': False +} diff --git a/odoo_proxmox_manager/controllers/__init__.py b/odoo_proxmox_manager/controllers/__init__.py new file mode 100644 index 0000000..12a7e52 --- /dev/null +++ b/odoo_proxmox_manager/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/odoo_proxmox_manager/controllers/main.py b/odoo_proxmox_manager/controllers/main.py new file mode 100644 index 0000000..8d259ac --- /dev/null +++ b/odoo_proxmox_manager/controllers/main.py @@ -0,0 +1,9 @@ +from odoo import http +from odoo.http import request + +class ProxmoxController(http.Controller): + + @http.route('/proxmox/dashboard/data', type='json', auth='user') + def get_dashboard_data(self): + """Get dashboard data for the Proxmox overview""" + return request.env['proxmox.server'].get_dashboard_data() diff --git a/odoo_proxmox_manager/models/__init__.py b/odoo_proxmox_manager/models/__init__.py new file mode 100644 index 0000000..ac89025 --- /dev/null +++ b/odoo_proxmox_manager/models/__init__.py @@ -0,0 +1,3 @@ +from . import proxmox_server +from . import proxmox_cluster +from . import proxmox_vm diff --git a/odoo_proxmox_manager/models/proxmox_cluster.py b/odoo_proxmox_manager/models/proxmox_cluster.py new file mode 100644 index 0000000..95af066 --- /dev/null +++ b/odoo_proxmox_manager/models/proxmox_cluster.py @@ -0,0 +1,37 @@ +from odoo import models, fields, api + +class ProxmoxCluster(models.Model): + _name = 'proxmox.cluster' + _description = 'Proxmox Cluster' + _order = 'name' + _inherit = ['mail.thread'] + + name = fields.Char(string='Name', required=True, tracking=True) + description = fields.Text(string='Description', tracking=True) + company_id = fields.Many2one('res.company', string='Company', required=True, default=lambda self: self.env.company) + server_ids = fields.One2many('proxmox.server', 'cluster_id', string='Servers') + server_count = fields.Integer(string='Server Count', compute='_compute_server_count') + total_vms = fields.Integer(string='Total VMs', compute='_compute_total_vms') + active_servers = fields.Integer(string='Active Servers', compute='_compute_active_servers') + + @api.depends('server_ids') + def _compute_server_count(self): + for cluster in self: + cluster.server_count = len(cluster.server_ids) + + @api.depends('server_ids.vm_ids') + def _compute_total_vms(self): + for cluster in self: + cluster.total_vms = sum(server.vm_count for server in cluster.server_ids) + + @api.depends('server_ids.state') + def _compute_active_servers(self): + for cluster in self: + cluster.active_servers = len(cluster.server_ids.filtered(lambda s: s.state == 'online')) + + def action_sync_all_servers(self): + """Synchronize all servers in the cluster""" + self.ensure_one() + for server in self.server_ids: + server.action_sync_vms() + return True diff --git a/odoo_proxmox_manager/models/proxmox_server.py b/odoo_proxmox_manager/models/proxmox_server.py new file mode 100644 index 0000000..462be95 --- /dev/null +++ b/odoo_proxmox_manager/models/proxmox_server.py @@ -0,0 +1,164 @@ +from odoo import models, fields, api +from proxmoxer import ProxmoxAPI +import requests +import logging + +_logger = logging.getLogger(__name__) + +class ProxmoxServer(models.Model): + _name = 'proxmox.server' + _description = 'Proxmox Server' + _order = 'name' + _inherit = ['mail.thread'] + + name = fields.Char(string='Name', required=True, tracking=True) + hostname = fields.Char(string='Hostname', required=True, tracking=True) + port = fields.Integer(string='Port', default=8006, tracking=True) + cluster_id = fields.Many2one('proxmox.cluster', string='Cluster', tracking=True) + username = fields.Char(string='Username', required=True, tracking=True) + token_name = fields.Char(string='API Token Name', tracking=True) + token_value = fields.Char(string='API Token Value', tracking=True) + verify_ssl = fields.Boolean(string='Verify SSL', default=True, tracking=True) + company_id = fields.Many2one('res.company', string='Company', required=True, default=lambda self: self.env.company) + state = fields.Selection([ + ('offline', 'Offline'), + ('online', 'Online') + ], string='Status', default='offline', compute='_compute_state', store=True, tracking=True) + vm_ids = fields.One2many('proxmox.vm', 'server_id', string='Virtual Machines') + vm_count = fields.Integer(string='VM Count', compute='_compute_vm_count') + node_info = fields.Text(string='Node Information', compute='_compute_node_info') + + def _get_proxmox_connection(self): + try: + if not self.token_name or not self.token_value: + raise ValueError("API Token is required") + + return ProxmoxAPI( + self.hostname, + port=self.port, + user=self.username, + token_name=self.token_name, + token_value=self.token_value, + verify_ssl=self.verify_ssl + ) + except Exception as e: + _logger.error(f"Failed to connect to Proxmox server {self.name}: {str(e)}") + return None + + @api.depends('hostname', 'port', 'token_name', 'token_value') + def _compute_state(self): + for server in self: + try: + proxmox = server._get_proxmox_connection() + if proxmox: + # Test connection by getting version info + proxmox.version.get() + server.state = 'online' + else: + server.state = 'offline' + except: + server.state = 'offline' + + @api.depends('vm_ids') + def _compute_vm_count(self): + for server in self: + server.vm_count = len(server.vm_ids) + + def _compute_node_info(self): + for server in self: + try: + proxmox = server._get_proxmox_connection() + if proxmox: + node_info = proxmox.nodes(server.hostname).status.get() + server.node_info = str(node_info) + else: + server.node_info = "Unable to connect to server" + except Exception as e: + server.node_info = f"Error getting node info: {str(e)}" + + def action_sync_vms(self): + """Synchronize VMs from Proxmox server""" + self.ensure_one() + proxmox = self._get_proxmox_connection() + if not proxmox: + return False + + try: + # Get all VMs from the server + vms = proxmox.nodes(self.hostname).qemu.get() + + # Update or create VM records + for vm in vms: + vm_vals = { + 'name': vm.get('name'), + 'vmid': vm.get('vmid'), + 'status': vm.get('status'), + 'server_id': self.id, + } + existing_vm = self.env['proxmox.vm'].search([ + ('server_id', '=', self.id), + ('vmid', '=', vm.get('vmid')) + ]) + if existing_vm: + existing_vm.write(vm_vals) + else: + self.env['proxmox.vm'].create(vm_vals) + + return True + except Exception as e: + _logger.error(f"Failed to sync VMs for server {self.name}: {str(e)}") + return False + + @api.model + def get_dashboard_data(self): + """Get dashboard data for the Proxmox overview""" + servers = self.search([]) + clusters = self.env['proxmox.cluster'].search([]) + vms = self.env['proxmox.vm'].search([]) + + server_data = [] + for server in servers: + try: + proxmox = server._get_proxmox_connection() + if proxmox: + node_info = proxmox.nodes(server.hostname).status.get() + memory_total = node_info.get('memory', {}).get('total', 0) + memory_used = node_info.get('memory', {}).get('used', 0) + memory_usage = round((memory_used / memory_total) * 100, 2) if memory_total else 0 + + cpu_usage = round(node_info.get('cpu', 0) * 100, 2) + + server_data.append({ + 'id': server.id, + 'name': server.name, + 'state': server.state, + 'vm_count': server.vm_count, + 'memory_usage': memory_usage, + 'cpu_usage': cpu_usage, + }) + else: + server_data.append({ + 'id': server.id, + 'name': server.name, + 'state': 'offline', + 'vm_count': server.vm_count, + 'memory_usage': 0, + 'cpu_usage': 0, + }) + except Exception as e: + _logger.error(f"Error getting data for server {server.name}: {str(e)}") + server_data.append({ + 'id': server.id, + 'name': server.name, + 'state': 'offline', + 'vm_count': server.vm_count, + 'memory_usage': 0, + 'cpu_usage': 0, + }) + + return { + 'server_count': len(servers), + 'cluster_count': len(clusters), + 'vm_count': len(vms), + 'servers': server_data, + } diff --git a/odoo_proxmox_manager/models/proxmox_vm.py b/odoo_proxmox_manager/models/proxmox_vm.py new file mode 100644 index 0000000..c03f1ca --- /dev/null +++ b/odoo_proxmox_manager/models/proxmox_vm.py @@ -0,0 +1,87 @@ +from odoo import models, fields, api + +class ProxmoxVM(models.Model): + _name = 'proxmox.vm' + _description = 'Proxmox Virtual Machine' + _order = 'name' + _inherit = ['mail.thread'] + + name = fields.Char(string='Name', required=True, tracking=True) + vmid = fields.Integer(string='VM ID', required=True, tracking=True) + server_id = fields.Many2one('proxmox.server', string='Server', required=True, tracking=True) + company_id = fields.Many2one('res.company', string='Company', required=True, + related='server_id.company_id', store=True, readonly=True) + status = fields.Selection([ + ('running', 'Running'), + ('stopped', 'Stopped'), + ('suspended', 'Suspended') + ], string='Status', default='stopped', tracking=True) + memory = fields.Integer(string='Memory (MB)') + cpus = fields.Integer(string='vCPUs') + disk_size = fields.Float(string='Disk Size (GB)') + ip_address = fields.Char(string='IP Address') + + def action_start(self): + """Start the virtual machine""" + self.ensure_one() + proxmox = self.server_id._get_proxmox_connection() + if proxmox: + try: + proxmox.nodes(self.server_id.hostname).qemu(self.vmid).status.start.post() + self.status = 'running' + return True + except Exception as e: + return False + return False + + def action_stop(self): + """Stop the virtual machine""" + self.ensure_one() + proxmox = self.server_id._get_proxmox_connection() + if proxmox: + try: + proxmox.nodes(self.server_id.hostname).qemu(self.vmid).status.stop.post() + self.status = 'stopped' + return True + except Exception as e: + return False + return False + + def action_restart(self): + """Restart the virtual machine""" + self.ensure_one() + proxmox = self.server_id._get_proxmox_connection() + if proxmox: + try: + proxmox.nodes(self.server_id.hostname).qemu(self.vmid).status.reset.post() + self.status = 'running' + return True + except Exception as e: + return False + return False + + def action_suspend(self): + """Suspend the virtual machine""" + self.ensure_one() + proxmox = self.server_id._get_proxmox_connection() + if proxmox: + try: + proxmox.nodes(self.server_id.hostname).qemu(self.vmid).status.suspend.post() + self.status = 'suspended' + return True + except Exception as e: + return False + return False + + def action_resume(self): + """Resume the virtual machine""" + self.ensure_one() + proxmox = self.server_id._get_proxmox_connection() + if proxmox: + try: + proxmox.nodes(self.server_id.hostname).qemu(self.vmid).status.resume.post() + self.status = 'running' + return True + except Exception as e: + return False + return False diff --git a/odoo_proxmox_manager/requirements.txt b/odoo_proxmox_manager/requirements.txt new file mode 100644 index 0000000..237b094 --- /dev/null +++ b/odoo_proxmox_manager/requirements.txt @@ -0,0 +1,2 @@ +proxmoxer>=1.3.1 +requests>=2.31.0 diff --git a/odoo_proxmox_manager/security/ir.model.access.csv b/odoo_proxmox_manager/security/ir.model.access.csv new file mode 100644 index 0000000..2dac00a --- /dev/null +++ b/odoo_proxmox_manager/security/ir.model.access.csv @@ -0,0 +1,9 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_proxmox_server_user,proxmox.server.user,model_proxmox_server,group_proxmox_user,1,0,0,0 +access_proxmox_server_manager,proxmox.server.manager,model_proxmox_server,group_proxmox_manager,1,1,1,1 +access_proxmox_cluster_user,proxmox.cluster.user,model_proxmox_cluster,group_proxmox_user,1,0,0,0 +access_proxmox_cluster_manager,proxmox.cluster.manager,model_proxmox_cluster,group_proxmox_manager,1,1,1,1 +access_proxmox_vm_user,proxmox.vm.user,model_proxmox_vm,group_proxmox_user,1,1,0,0 +access_proxmox_vm_manager,proxmox.vm.manager,model_proxmox_vm,group_proxmox_manager,1,1,1,1 +access_proxmox_server_wizard,proxmox.server.wizard,model_proxmox_server_wizard,base.group_user,1,1,1,0 +access_proxmox_server_wizard_node,proxmox.server.wizard.node,model_proxmox_server_wizard_node,base.group_user,1,1,1,0 diff --git a/odoo_proxmox_manager/security/proxmox_security.xml b/odoo_proxmox_manager/security/proxmox_security.xml new file mode 100644 index 0000000..e362a72 --- /dev/null +++ b/odoo_proxmox_manager/security/proxmox_security.xml @@ -0,0 +1,39 @@ + + + + + Proxmox Management + Manage Proxmox servers and virtual machines + 20 + + + + User + + + + + + Manager + + + + + + + + + Proxmox Server Multi-Company + + + ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] + + + + Proxmox Cluster Multi-Company + + + ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] + + + diff --git a/odoo_proxmox_manager/static/description/icon.png b/odoo_proxmox_manager/static/description/icon.png new file mode 100644 index 0000000..a31ce86 Binary files /dev/null and b/odoo_proxmox_manager/static/description/icon.png differ diff --git a/odoo_proxmox_manager/static/src/components/proxmox_server_list/proxmox_server_list.js b/odoo_proxmox_manager/static/src/components/proxmox_server_list/proxmox_server_list.js new file mode 100644 index 0000000..b4abd5e --- /dev/null +++ b/odoo_proxmox_manager/static/src/components/proxmox_server_list/proxmox_server_list.js @@ -0,0 +1,32 @@ +/** @odoo-module **/ + +import { ListController } from "@web/views/list/list_controller"; +import { registry } from "@web/core/registry"; +import { listView } from "@web/views/list/list_view"; + +class ProxmoxServerListController extends ListController { + setup() { + super.setup(); + } + + async onAddServer() { + await this.env.services.action.doAction({ + type: 'ir.actions.act_window', + res_model: 'proxmox.server.wizard', + view_mode: 'form', + view_type: 'form', + views: [[false, 'form']], + target: 'new', + context: {}, + }); + } +} + +ProxmoxServerListController.template = 'odoo_proxmox_manager.ProxmoxServerListView.Buttons'; + +export const proxmoxServerList = { + ...listView, + Controller: ProxmoxServerListController, +}; + +registry.category("views").add("proxmox_server_list", proxmoxServerList); diff --git a/odoo_proxmox_manager/static/src/components/proxmox_server_list/proxmox_server_list.xml b/odoo_proxmox_manager/static/src/components/proxmox_server_list/proxmox_server_list.xml new file mode 100644 index 0000000..a1392c2 --- /dev/null +++ b/odoo_proxmox_manager/static/src/components/proxmox_server_list/proxmox_server_list.xml @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/odoo_proxmox_manager/static/src/js/dashboard_view.js b/odoo_proxmox_manager/static/src/js/dashboard_view.js new file mode 100644 index 0000000..53f2743 --- /dev/null +++ b/odoo_proxmox_manager/static/src/js/dashboard_view.js @@ -0,0 +1,51 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { Layout } from "@web/search/layout"; +import { useService } from "@web/core/utils/hooks"; +import { Component, onWillStart, useRef } from "@odoo/owl"; + +class ProxmoxDashboardController extends Component { + setup() { + this.actionService = useService("action"); + this.orm = useService("orm"); + this.serverData = {}; + this.chartRefs = {}; + + onWillStart(async () => { + await this.fetchData(); + }); + } + + async fetchData() { + const serverData = await this.orm.call( + 'proxmox.server', + 'get_dashboard_data', + [] + ); + this.serverData = serverData; + this.render(); + } + + openServers() { + this.actionService.doAction('odoo_proxmox_manager.action_proxmox_server'); + } + + openClusters() { + this.actionService.doAction('odoo_proxmox_manager.action_proxmox_cluster'); + } + + openVMs() { + this.actionService.doAction('odoo_proxmox_manager.action_proxmox_vm'); + } +} + +ProxmoxDashboardController.template = 'odoo_proxmox_manager.DashboardView'; +ProxmoxDashboardController.components = { Layout }; + +registry.category("actions").add("proxmox_dashboard_client_action", { + type: "ir.actions.client", + tag: "proxmox_dashboard", + target: "main", + Component: ProxmoxDashboardController, +}); diff --git a/odoo_proxmox_manager/static/src/js/proxmox_server_list.js b/odoo_proxmox_manager/static/src/js/proxmox_server_list.js new file mode 100644 index 0000000..962c3fc --- /dev/null +++ b/odoo_proxmox_manager/static/src/js/proxmox_server_list.js @@ -0,0 +1,52 @@ +/** @odoo-module **/ + +import { ListController } from "@web/views/list/list_controller"; +import { registry } from "@web/core/registry"; +import { listView } from "@web/views/list/list_view"; + +export class ProxmoxServerListController extends ListController { + setup() { + super.setup(); + this.state = {}; + } + + /** + * @override + */ + async getLocalState() { + const state = await super.getLocalState(); + return { ...state, ...this.state }; + } + + /** + * @override + */ + async setLocalState(state) { + if (state) { + this.state = { ...this.state, ...state }; + await super.setLocalState(state); + } + } + + async onAddServer() { + await this.env.services.action.doAction({ + type: 'ir.actions.act_window', + res_model: 'proxmox.server.wizard', + view_mode: 'form', + views: [[false, 'form']], + target: 'new', + context: {}, + }, { + onClose: () => this.actionService.restore(), + }); + } +} + +ProxmoxServerListController.template = 'odoo_proxmox_manager.ProxmoxServerListView'; + +export const ProxmoxServerListView = { + ...listView, + Controller: ProxmoxServerListController, +}; + +registry.category("views").add("proxmox_server_list", ProxmoxServerListView); diff --git a/odoo_proxmox_manager/static/src/scss/dashboard.scss b/odoo_proxmox_manager/static/src/scss/dashboard.scss new file mode 100644 index 0000000..39b5d35 --- /dev/null +++ b/odoo_proxmox_manager/static/src/scss/dashboard.scss @@ -0,0 +1,113 @@ +.proxmox-dashboard { + padding: 1rem; + + .dashboard-header { + margin-bottom: 2rem; + h1 { + color: #2b2b2b; + font-size: 2rem; + } + } + + .dashboard-stats { + display: flex; + gap: 1.5rem; + margin-bottom: 2rem; + + .stat-card { + flex: 1; + background: white; + border-radius: 8px; + padding: 1.5rem; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + cursor: pointer; + transition: transform 0.2s; + + &:hover { + transform: translateY(-2px); + } + + .stat-value { + font-size: 2.5rem; + font-weight: bold; + color: #2b2b2b; + margin-bottom: 0.5rem; + } + + .stat-label { + color: #666; + font-size: 1rem; + } + } + } + + .dashboard-servers { + h2 { + color: #2b2b2b; + margin-bottom: 1rem; + } + + .server-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1rem; + + .server-card { + background: white; + border-radius: 8px; + padding: 1rem; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + + .server-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + + .server-name { + font-weight: bold; + font-size: 1.1rem; + } + + .server-status { + padding: 0.25rem 0.75rem; + border-radius: 12px; + font-size: 0.9rem; + + &.status-online { + background: #e6f4ea; + color: #1e8e3e; + } + + &.status-offline { + background: #fce8e6; + color: #d93025; + } + } + } + + .server-stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.5rem; + + .stat { + text-align: center; + + .label { + display: block; + color: #666; + font-size: 0.9rem; + margin-bottom: 0.25rem; + } + + .value { + font-weight: bold; + color: #2b2b2b; + } + } + } + } + } + } +} diff --git a/odoo_proxmox_manager/static/src/xml/dashboard.xml b/odoo_proxmox_manager/static/src/xml/dashboard.xml new file mode 100644 index 0000000..8a3227d --- /dev/null +++ b/odoo_proxmox_manager/static/src/xml/dashboard.xml @@ -0,0 +1,57 @@ + + + + +
+
+

Proxmox Dashboard

+
+ +
+
+
+
Servers
+
+
+
+
Clusters
+
+
+
+
Virtual Machines
+
+
+ +
+

Server Status

+
+ +
+
+ + + + +
+
+
+ VMs: + +
+
+ Memory: + % +
+
+ CPU: + % +
+
+
+
+
+
+
+
+
+
diff --git a/odoo_proxmox_manager/static/src/xml/proxmox_server_list.xml b/odoo_proxmox_manager/static/src/xml/proxmox_server_list.xml new file mode 100644 index 0000000..e9be587 --- /dev/null +++ b/odoo_proxmox_manager/static/src/xml/proxmox_server_list.xml @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/odoo_proxmox_manager/views/proxmox_cluster_views.xml b/odoo_proxmox_manager/views/proxmox_cluster_views.xml new file mode 100644 index 0000000..bc1b658 --- /dev/null +++ b/odoo_proxmox_manager/views/proxmox_cluster_views.xml @@ -0,0 +1,89 @@ + + + + + + proxmox.cluster.tree + proxmox.cluster + + + + + + + + + + + + + proxmox.cluster.form + proxmox.cluster + +
+
+
+ +
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + proxmox.cluster.search + proxmox.cluster + + + + + + + + + + + Proxmox Clusters + ir.actions.act_window + proxmox.cluster + tree,form + + {'form_view_ref': 'odoo_proxmox_manager.view_proxmox_cluster_form'} + [] + 80 + +

+ Create your first Proxmox cluster! +

+
+
+
+
diff --git a/odoo_proxmox_manager/views/proxmox_menus.xml b/odoo_proxmox_manager/views/proxmox_menus.xml new file mode 100644 index 0000000..5e64d77 --- /dev/null +++ b/odoo_proxmox_manager/views/proxmox_menus.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + diff --git a/odoo_proxmox_manager/views/proxmox_server_views.xml b/odoo_proxmox_manager/views/proxmox_server_views.xml new file mode 100644 index 0000000..ef42013 --- /dev/null +++ b/odoo_proxmox_manager/views/proxmox_server_views.xml @@ -0,0 +1,108 @@ + + + + + + proxmox.server.tree + proxmox.server + + + + + + + + + + + + + + proxmox.server.form + proxmox.server + +
+
+
+ +
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + proxmox.server.search + proxmox.server + + + + + + + + + + + + + + + + + + + Proxmox Servers + ir.actions.act_window + proxmox.server + tree,form + + {'search_default_cluster': 1, 'form_view_ref': 'odoo_proxmox_manager.view_proxmox_server_form'} + [] + 80 + +

+ Create your first Proxmox server! +

+
+
+
+
diff --git a/odoo_proxmox_manager/views/proxmox_vm_views.xml b/odoo_proxmox_manager/views/proxmox_vm_views.xml new file mode 100644 index 0000000..157eee2 --- /dev/null +++ b/odoo_proxmox_manager/views/proxmox_vm_views.xml @@ -0,0 +1,107 @@ + + + + + + proxmox.vm.tree + proxmox.vm + + + + + + + + + + + + + + + + + proxmox.vm.form + proxmox.vm + +
+
+
+ +
+

+ +

+
+ + + + + + + + + + + + +
+
+
+
+ + + + proxmox.vm.search + proxmox.vm + + + + + + + + + + + + + + + + + + + + Virtual Machines + ir.actions.act_window + proxmox.vm + tree,form + + +

+ No virtual machines found! +

+

+ Virtual machines will be synchronized from your Proxmox servers. +

+
+
+
+
diff --git a/odoo_proxmox_manager/wizard/__init__.py b/odoo_proxmox_manager/wizard/__init__.py new file mode 100644 index 0000000..0f6a8a9 --- /dev/null +++ b/odoo_proxmox_manager/wizard/__init__.py @@ -0,0 +1 @@ +from . import proxmox_server_wizard diff --git a/odoo_proxmox_manager/wizard/proxmox_server_wizard.py b/odoo_proxmox_manager/wizard/proxmox_server_wizard.py new file mode 100644 index 0000000..420b24b --- /dev/null +++ b/odoo_proxmox_manager/wizard/proxmox_server_wizard.py @@ -0,0 +1,98 @@ +from odoo import api, fields, models +import requests +import urllib3 +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +class ProxmoxServerWizard(models.TransientModel): + _name = 'proxmox.server.wizard' + _description = 'Assistant de configuration serveur Proxmox' + + name = fields.Char(string='Nom', required=True) + hostname = fields.Char(string='Nom d\'hôte/IP', required=True) + port = fields.Integer(string='Port', default=443) + username = fields.Char(string='Nom d\'utilisateur', required=True) + password = fields.Char(string='Mot de passe', required=True) + verify_ssl = fields.Boolean(string='Vérifier SSL', default=False) + + def _get_proxmox_connection(self): + """Établit une connexion à l'API Proxmox""" + base_url = f"https://{self.hostname}:{self.port}/api2/json" + try: + auth_response = requests.post( + f"{base_url}/access/ticket", + verify=self.verify_ssl, + data={ + "username": self.username, + "password": self.password + } + ) + auth_response.raise_for_status() + auth_data = auth_response.json()['data'] + + headers = { + 'CSRFPreventionToken': auth_data['CSRFPreventionToken'], + 'Cookie': f"PVEAuthCookie={auth_data['ticket']}" + } + return base_url, headers + except Exception as e: + raise ValueError(f"Erreur de connexion: {str(e)}") + + def action_test_connection(self): + """Teste la connexion et crée le serveur et le cluster si la connexion réussit""" + try: + base_url, headers = self._get_proxmox_connection() + + # Vérifier si c'est un cluster + cluster_response = requests.get( + f"{base_url}/cluster/status", + headers=headers, + verify=self.verify_ssl + ) + cluster_response.raise_for_status() + cluster_data = cluster_response.json()['data'] + + is_cluster = any(node['type'] == 'cluster' for node in cluster_data) + cluster = False + + if is_cluster: + cluster_name = next((node['name'] for node in cluster_data if node['type'] == 'cluster'), 'Cluster Proxmox') + cluster = self.env['proxmox.cluster'].create({ + 'name': cluster_name, + }) + + # Créer le serveur principal + main_server = self.env['proxmox.server'].create({ + 'name': self.name, + 'hostname': self.hostname, + 'port': self.port, + 'username': self.username, + 'password': self.password, + 'cluster_id': cluster.id if cluster else False, + }) + + # Créer les autres nœuds si c'est un cluster + if is_cluster: + nodes = [node for node in cluster_data if node['type'] == 'node' and node['name'] != self.hostname] + for node in nodes: + self.env['proxmox.server'].create({ + 'name': node['name'], + 'hostname': node['name'], + 'port': self.port, + 'username': self.username, + 'password': self.password, + 'cluster_id': cluster.id, + }) + + return { + 'type': 'ir.actions.act_window', + 'res_model': 'proxmox.server', + 'res_id': main_server.id, + 'view_mode': 'form', + 'view_type': 'form', + 'views': [(False, 'form')], + 'target': 'current', + 'flags': {'mode': 'readonly'}, + } + + except Exception as e: + raise ValueError(f"Erreur lors du test de connexion: {str(e)}") diff --git a/odoo_proxmox_manager/wizard/proxmox_server_wizard_views.xml b/odoo_proxmox_manager/wizard/proxmox_server_wizard_views.xml new file mode 100644 index 0000000..636e9dd --- /dev/null +++ b/odoo_proxmox_manager/wizard/proxmox_server_wizard_views.xml @@ -0,0 +1,67 @@ + + + + proxmox.server.wizard.form + proxmox.server.wizard + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+ + + Ajouter un serveur Proxmox + ir.actions.act_window + proxmox.server.wizard + form + new + + + +
diff --git a/project_task_date_deadline_cascade_update/__init__.py b/project_task_date_deadline_cascade_update/__init__.py new file mode 100644 index 0000000..cde864b --- /dev/null +++ b/project_task_date_deadline_cascade_update/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import models diff --git a/project_task_date_deadline_cascade_update/__manifest__.py b/project_task_date_deadline_cascade_update/__manifest__.py new file mode 100644 index 0000000..f69aaf4 --- /dev/null +++ b/project_task_date_deadline_cascade_update/__manifest__.py @@ -0,0 +1,19 @@ +{ + 'name': 'Project Task Date Deadline Cascade Update', + 'version': '17.0.0.1', + 'category': 'Project', + 'summary': 'Automatically update child task deadlines when parent task deadline changes.', + 'description': """ + This module ensures that when a parent task's deadline is updated, all child tasks' deadlines are automatically updated to match. + """, + 'author': 'Your Name', + 'license': 'AGPL-3', + 'website': 'https://www.yourwebsite.com', + 'depends': ['project'], + 'data': [ + 'views/update_deadline_wizard_view.xml', + 'wizard/update_deadline_wizard.xml', + ], + 'installable': True, + 'auto_install': False, +} diff --git a/project_task_date_deadline_cascade_update/models/__init__.py b/project_task_date_deadline_cascade_update/models/__init__.py new file mode 100644 index 0000000..d44ba0c --- /dev/null +++ b/project_task_date_deadline_cascade_update/models/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import project_task diff --git a/project_task_date_deadline_cascade_update/models/project_task.py b/project_task_date_deadline_cascade_update/models/project_task.py new file mode 100644 index 0000000..0c6c545 --- /dev/null +++ b/project_task_date_deadline_cascade_update/models/project_task.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- + +from odoo import api, fields, models +from odoo.exceptions import UserError +from odoo import _ + +class ProjectTask(models.Model): + _inherit = 'project.task' + + @api.model + def write(self, vals): + res = super(ProjectTask, self).write(vals) + if 'date_deadline' in vals: + for task in self: + if task.child_ids: + return { + 'name': _('Update Deadline'), + 'type': 'ir.actions.act_window', + 'res_model': 'update.deadline.wizard', + 'view_mode': 'form', + 'target': 'new', + 'context': {'active_ids': self.ids}, + } + return res diff --git a/project_task_date_deadline_cascade_update/views/update_deadline_wizard_view.xml b/project_task_date_deadline_cascade_update/views/update_deadline_wizard_view.xml new file mode 100644 index 0000000..8c6b039 --- /dev/null +++ b/project_task_date_deadline_cascade_update/views/update_deadline_wizard_view.xml @@ -0,0 +1,26 @@ + + + + update.deadline.wizard.form + update.deadline.wizard + +
+ + + +
+
+
+
+
+ + + Update Deadline + update.deadline.wizard + form + + new + +
diff --git a/project_task_date_deadline_cascade_update/wizard/__init__.py b/project_task_date_deadline_cascade_update/wizard/__init__.py new file mode 100644 index 0000000..0e22159 --- /dev/null +++ b/project_task_date_deadline_cascade_update/wizard/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import update_deadline_wizard diff --git a/project_task_date_deadline_cascade_update/wizard/update_deadline_wizard.py b/project_task_date_deadline_cascade_update/wizard/update_deadline_wizard.py new file mode 100644 index 0000000..be76fd2 --- /dev/null +++ b/project_task_date_deadline_cascade_update/wizard/update_deadline_wizard.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- + +from odoo import api, fields, models + + +class UpdateDeadlineWizard(models.TransientModel): + _name = 'update.deadline.wizard' + _description = 'Update Deadline Wizard' + + apply_to_children = fields.Boolean(string='Apply to Child Tasks', default=False) + + def update_deadline(self): + context = dict(self._context or {}) + active_ids = context.get('active_ids', []) + tasks = self.env['project.task'].browse(active_ids) + for task in tasks: + if task.child_ids and self.apply_to_children: + task.child_ids.write({'date_deadline': task.date_deadline})