From d4e377f5c673adb09d0f6a3a96c2cc4db88ee5c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20V=C3=A9zina?= Date: Sat, 22 Jul 2023 07:25:40 -0400 Subject: [PATCH] merge work from odoo 15.0 --- bemade_helpdesk_mailcow_blacklist/__init__.py | 1 + .../__manifest__.py | 37 ++++ .../data/helpdesk_stages.xml | 10 + .../demo/demo.xml | 27 +++ .../models/__init__.py | 1 + .../models/helpdesk_ticket.py | 27 +++ .../models/res_partner.py | 11 ++ .../security/ir.model.access.csv | 2 + .../views/helpdesk_ticket_views.xml | 13 ++ bemade_mailcow_integration/__init__.py | 3 + bemade_mailcow_integration/__manifest__.py | 46 +++++ .../controllers/controllers.py | 13 ++ bemade_mailcow_integration/demo/demo.xml | 30 +++ bemade_mailcow_integration/models/__init__.py | 9 + .../models/mail_alias.py | 29 +++ bemade_mailcow_integration/models/mailcow.py | 62 ++++++ .../models/mailcow_alias.py | 108 ++++++++++ .../models/mailcow_blacklist.py | 101 ++++++++++ .../models/mailcow_mailbox.py | 143 ++++++++++++++ .../models/res_config_settings.py | 20 ++ .../models/res_users.py | 15 ++ .../security/ir.model.access.csv | 7 + .../static/description/icon.png | Bin 0 -> 24034 bytes .../static/description/logomailcow.svg | 187 ++++++++++++++++++ .../static/src/js/mailcow.js | 89 +++++++++ .../static/src/xml/mailcow_templates.xml | 28 +++ bemade_mailcow_integration/tests/__init__.py | 1 + .../tests/test_mailcow.py | 12 ++ .../views/mailcow_alias_views.xml | 76 +++++++ .../views/mailcow_blacklist_views.xml | 66 +++++++ .../views/mailcow_mailbox_views.xml | 82 ++++++++ .../views/res_config_settings_views.xml | 40 ++++ .../views/res_users_views.xml | 19 ++ .../views/templates.xml | 24 +++ bemade_mailcow_integration/views/views.xml | 60 ++++++ bemade_time_off_follower/__init__.py | 1 + bemade_time_off_follower/__manifest__.py | 22 +++ bemade_time_off_follower/models/__init__.py | 2 + bemade_time_off_follower/models/hr_leave.py | 10 + .../models/mail_thread.py | 39 ++++ .../views/hr_leave_views.xml | 13 ++ bemade_user_password_bundle/__init__.py | 3 + bemade_user_password_bundle/__manifest__.py | 25 +++ .../models/__init__.py | 4 + .../models/hr_employee.py | 20 ++ .../models/password_bundle.py | 30 +++ 46 files changed, 1568 insertions(+) create mode 100644 bemade_helpdesk_mailcow_blacklist/__init__.py create mode 100644 bemade_helpdesk_mailcow_blacklist/__manifest__.py create mode 100644 bemade_helpdesk_mailcow_blacklist/data/helpdesk_stages.xml create mode 100644 bemade_helpdesk_mailcow_blacklist/demo/demo.xml create mode 100644 bemade_helpdesk_mailcow_blacklist/models/__init__.py create mode 100644 bemade_helpdesk_mailcow_blacklist/models/helpdesk_ticket.py create mode 100644 bemade_helpdesk_mailcow_blacklist/models/res_partner.py create mode 100644 bemade_helpdesk_mailcow_blacklist/security/ir.model.access.csv create mode 100644 bemade_helpdesk_mailcow_blacklist/views/helpdesk_ticket_views.xml create mode 100644 bemade_mailcow_integration/__init__.py create mode 100644 bemade_mailcow_integration/__manifest__.py create mode 100644 bemade_mailcow_integration/controllers/controllers.py create mode 100644 bemade_mailcow_integration/demo/demo.xml create mode 100644 bemade_mailcow_integration/models/__init__.py create mode 100644 bemade_mailcow_integration/models/mail_alias.py create mode 100644 bemade_mailcow_integration/models/mailcow.py create mode 100644 bemade_mailcow_integration/models/mailcow_alias.py create mode 100644 bemade_mailcow_integration/models/mailcow_blacklist.py create mode 100644 bemade_mailcow_integration/models/mailcow_mailbox.py create mode 100644 bemade_mailcow_integration/models/res_config_settings.py create mode 100644 bemade_mailcow_integration/models/res_users.py create mode 100644 bemade_mailcow_integration/security/ir.model.access.csv create mode 100644 bemade_mailcow_integration/static/description/icon.png create mode 100644 bemade_mailcow_integration/static/description/logomailcow.svg create mode 100644 bemade_mailcow_integration/static/src/js/mailcow.js create mode 100644 bemade_mailcow_integration/static/src/xml/mailcow_templates.xml create mode 100644 bemade_mailcow_integration/tests/__init__.py create mode 100644 bemade_mailcow_integration/tests/test_mailcow.py create mode 100644 bemade_mailcow_integration/views/mailcow_alias_views.xml create mode 100644 bemade_mailcow_integration/views/mailcow_blacklist_views.xml create mode 100644 bemade_mailcow_integration/views/mailcow_mailbox_views.xml create mode 100644 bemade_mailcow_integration/views/res_config_settings_views.xml create mode 100644 bemade_mailcow_integration/views/res_users_views.xml create mode 100644 bemade_mailcow_integration/views/templates.xml create mode 100644 bemade_mailcow_integration/views/views.xml create mode 100644 bemade_time_off_follower/__init__.py create mode 100644 bemade_time_off_follower/__manifest__.py create mode 100644 bemade_time_off_follower/models/__init__.py create mode 100644 bemade_time_off_follower/models/hr_leave.py create mode 100644 bemade_time_off_follower/models/mail_thread.py create mode 100644 bemade_time_off_follower/views/hr_leave_views.xml create mode 100644 bemade_user_password_bundle/__init__.py create mode 100644 bemade_user_password_bundle/__manifest__.py create mode 100644 bemade_user_password_bundle/models/__init__.py create mode 100644 bemade_user_password_bundle/models/hr_employee.py create mode 100644 bemade_user_password_bundle/models/password_bundle.py diff --git a/bemade_helpdesk_mailcow_blacklist/__init__.py b/bemade_helpdesk_mailcow_blacklist/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/bemade_helpdesk_mailcow_blacklist/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/bemade_helpdesk_mailcow_blacklist/__manifest__.py b/bemade_helpdesk_mailcow_blacklist/__manifest__.py new file mode 100644 index 0000000..9aad068 --- /dev/null +++ b/bemade_helpdesk_mailcow_blacklist/__manifest__.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +{ + 'name': 'Helpdesk Mailcow Blacklist', + 'version': '1.0.0', + 'category': 'Administration', + 'summary': 'Module for adding blacklist functionality to the helpdesk.', + 'description': """ + Helpdesk Mailcow Blacklist + + This module extends the functionality of the Helpdesk and Mailcow Blacklist modules by adding an action to blacklist the sender of a Helpdesk ticket. + + Main Features: + - Adds a button on Helpdesk tickets to blacklist the email sender. + - This button is only visible when an email is associated with the ticket. + - Blacklisting an email sender automatically moves the Helpdesk ticket to a new 'Spam' stage, which is marked as closed. + - The 'Spam' stage is automatically created by this module. + """, + 'sequence': 10, + 'license': 'GPL-3', + 'author': 'Bemade', + 'website': 'https://www.bemade.org', + 'depends': [ + 'helpdesk', + 'bemade_mailcow_integration' + ], + 'data': [ + # 'security/ir.model.access.csv', + 'data/helpdesk_stages.xml', + 'views/helpdesk_ticket_views.xml', + ], + 'demo': [ + 'demo/demo.xml' + ], + 'installable': True, + 'application': False, + 'auto_install': False +} diff --git a/bemade_helpdesk_mailcow_blacklist/data/helpdesk_stages.xml b/bemade_helpdesk_mailcow_blacklist/data/helpdesk_stages.xml new file mode 100644 index 0000000..3ff0278 --- /dev/null +++ b/bemade_helpdesk_mailcow_blacklist/data/helpdesk_stages.xml @@ -0,0 +1,10 @@ + + + + + Spam + 50 + True + + + diff --git a/bemade_helpdesk_mailcow_blacklist/demo/demo.xml b/bemade_helpdesk_mailcow_blacklist/demo/demo.xml new file mode 100644 index 0000000..b8f2a97 --- /dev/null +++ b/bemade_helpdesk_mailcow_blacklist/demo/demo.xml @@ -0,0 +1,27 @@ + + + + + + Demo User + demo_user + demo_user + demo_user@example.com + + + + + Demo Helpdesk Team + + + + + Demo Helpdesk Ticket + + + demo_sender@example.com + + This is a demo ticket for testing the blacklist feature. + + + diff --git a/bemade_helpdesk_mailcow_blacklist/models/__init__.py b/bemade_helpdesk_mailcow_blacklist/models/__init__.py new file mode 100644 index 0000000..deff245 --- /dev/null +++ b/bemade_helpdesk_mailcow_blacklist/models/__init__.py @@ -0,0 +1 @@ +from . import helpdesk_ticket \ No newline at end of file diff --git a/bemade_helpdesk_mailcow_blacklist/models/helpdesk_ticket.py b/bemade_helpdesk_mailcow_blacklist/models/helpdesk_ticket.py new file mode 100644 index 0000000..c98ae98 --- /dev/null +++ b/bemade_helpdesk_mailcow_blacklist/models/helpdesk_ticket.py @@ -0,0 +1,27 @@ +from odoo import api, fields, models +from odoo.exceptions import UserError +import re + +class HelpdeskTicket(models.Model): + _inherit = 'helpdesk.ticket' + + def action_add_blacklist(self): + self.ensure_one() + email_regex = r'<([^<>]+)>' + + email_to_blacklist = re.findall(email_regex, self.email)[0] + + # Create a new blacklist record + blacklist = self.env['mail.mailcow.blacklist'].create({ + 'email': email_to_blacklist, + }) + + # Assign 'spam' as a closed stage + spam_stage = self.env.ref('bemade_helpdesk_mailcow_blacklist.helpdesk_stage_spam') + + if spam_stage: + self.stage_id = spam_stage.id + else: + raise UserError(_('The Spam stage does not exist.')) + + return True diff --git a/bemade_helpdesk_mailcow_blacklist/models/res_partner.py b/bemade_helpdesk_mailcow_blacklist/models/res_partner.py new file mode 100644 index 0000000..b11a071 --- /dev/null +++ b/bemade_helpdesk_mailcow_blacklist/models/res_partner.py @@ -0,0 +1,11 @@ +from odoo import models, api + +class ResPartner(models.Model): + _inherit = 'res.partner' + + @api.multi + def send_validation_email(self): + for partner in self: + if not partner.email_validated: + # Assuming you have a method called send_validation_email that sends the validation email. + partner.send_validation_email() diff --git a/bemade_helpdesk_mailcow_blacklist/security/ir.model.access.csv b/bemade_helpdesk_mailcow_blacklist/security/ir.model.access.csv new file mode 100644 index 0000000..4d035d4 --- /dev/null +++ b/bemade_helpdesk_mailcow_blacklist/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_bemade_helpdesk_mailcow_blacklist,access_bemade_helpdesk_mailcow_blacklist,model_bemade_helpdesk_mailcow_blacklist,base.group_user,1,1,1,1 diff --git a/bemade_helpdesk_mailcow_blacklist/views/helpdesk_ticket_views.xml b/bemade_helpdesk_mailcow_blacklist/views/helpdesk_ticket_views.xml new file mode 100644 index 0000000..cc02fe6 --- /dev/null +++ b/bemade_helpdesk_mailcow_blacklist/views/helpdesk_ticket_views.xml @@ -0,0 +1,13 @@ + + + + helpdesk.ticket.form.mailcow + helpdesk.ticket + + +
+
+
+
+
diff --git a/bemade_mailcow_integration/__init__.py b/bemade_mailcow_integration/__init__.py new file mode 100644 index 0000000..cde864b --- /dev/null +++ b/bemade_mailcow_integration/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import models diff --git a/bemade_mailcow_integration/__manifest__.py b/bemade_mailcow_integration/__manifest__.py new file mode 100644 index 0000000..14c39bb --- /dev/null +++ b/bemade_mailcow_integration/__manifest__.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +{ + 'name': 'Mailcow Integration', + 'version': '1.0.0', + 'category': 'Administration', + 'summary': 'Module for integrating Mailcow email server with Odoo.', + 'description': """ + Mailcow Integration + + This module integrates the Mailcow email server with Odoo, providing a seamless email communication solution for your Odoo instance. It allows for syncing of mailboxes and email aliases from Mailcow to Odoo and vice versa. + + Main Features: + Synchronize Mailcow mailboxes with Odoo users. + Synchronize Mailcow email aliases with Odoo. + Configuration of Mailcow API credentials in Odoo settings. + Automatically create and manage mailboxes and aliases in Mailcow when they are created in Odoo. + """, + 'sequence': 10, + 'license': 'GPL-3', + 'author': 'Bemade', + 'website': 'https://www.bemade.org', + 'depends': [ + 'hr', + 'mail', + 'bemade_user_password_bundle' + ], + 'data': [ + 'security/ir.model.access.csv', + 'views/res_config_settings_views.xml', + 'views/mailcow_mailbox_views.xml', + 'views/mailcow_alias_views.xml', + 'views/mailcow_blacklist_views.xml', + 'views/res_users_views.xml', + ], + "assets": { + "web.assets_backend": [ + "bemade_mailcow_integration/static/src/js/mailcow.js", + ], + "web.assets_qweb": [ + "bemade_mailcow_integration/static/src/xml/mailcow_templates.xml", + ], + }, + 'installable': True, + 'application': False, + 'auto_install': False +} diff --git a/bemade_mailcow_integration/controllers/controllers.py b/bemade_mailcow_integration/controllers/controllers.py new file mode 100644 index 0000000..bd69c50 --- /dev/null +++ b/bemade_mailcow_integration/controllers/controllers.py @@ -0,0 +1,13 @@ +from odoo import http +from odoo.http import request + +class EmailValidationController(http.Controller): + + @http.route('/email_validation//', type='http', auth='public', website=True) + def email_validation(self, partner_id, token): + partner = request.env['res.partner'].sudo().browse(partner_id) + if partner and partner.validation_token == token: + partner.sudo().write({'email_validated': True}) + return "Email validated!" + else: + return "Invalid validation link." diff --git a/bemade_mailcow_integration/demo/demo.xml b/bemade_mailcow_integration/demo/demo.xml new file mode 100644 index 0000000..60b6c36 --- /dev/null +++ b/bemade_mailcow_integration/demo/demo.xml @@ -0,0 +1,30 @@ + + + + + \ No newline at end of file diff --git a/bemade_mailcow_integration/models/__init__.py b/bemade_mailcow_integration/models/__init__.py new file mode 100644 index 0000000..0d900ad --- /dev/null +++ b/bemade_mailcow_integration/models/__init__.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- + +from . import mail_alias +from . import mailcow +from . import mailcow_alias +from . import mailcow_blacklist +from . import mailcow_mailbox +from . import res_config_settings +from . import res_users diff --git a/bemade_mailcow_integration/models/mail_alias.py b/bemade_mailcow_integration/models/mail_alias.py new file mode 100644 index 0000000..66a1a98 --- /dev/null +++ b/bemade_mailcow_integration/models/mail_alias.py @@ -0,0 +1,29 @@ +from odoo import models, fields, api, _ +from odoo.exceptions import ValidationError + +class MailAlias(models.Model): + _inherit = 'mail.alias' + + mailcow_id = fields.One2many('mail.mailcow.alias', 'alias_id') + + @api.model + def create(self, vals): + alias = super(MailAlias, self).create(vals) + + alias_domain = self.env["ir.config_parameter"].sudo().get_param("mail.catchall.domain"), + catchall_alias = self.env["ir.config_parameter"].sudo().get_param("mail.catchall.alias"), + + alias_domain = alias_domain[0] + catchall_alias = catchall_alias[0] + + if alias_domain: + mailcow_alias = self.env['mail.mailcow.alias'].search([('address', '=', alias.alias_name + '@' + alias_domain)]) + if mailcow_alias: + mailcow_alias.write({'active': True}) + else: + self.env['mail.mailcow.alias'].create({ + 'address': alias.alias_name + '@' + alias_domain, + 'goto': catchall_alias + '@' + alias_domain, + 'alias_id': alias.id, + }) + return alias diff --git a/bemade_mailcow_integration/models/mailcow.py b/bemade_mailcow_integration/models/mailcow.py new file mode 100644 index 0000000..2b9b9d8 --- /dev/null +++ b/bemade_mailcow_integration/models/mailcow.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- + +from odoo import models, fields, api, _ +import requests +import logging +from odoo.exceptions import ValidationError + + +_logger = logging.getLogger(__name__) + +class MailMailcow(models.AbstractModel): + _name = 'mail.mailcow' + _description = 'Mailcow API' + + @property + def get_credentials(self): + params = self.env['ir.config_parameter'].sudo() + + base_url = params.get_param('mailcow.base_url') + api_key = params.get_param('mailcow.api_key') + + if not base_url or not api_key: + _logger.error('No API key or base URL is set in the system parameters') + raise ValidationError(_("No API key or base URL is set in the system parameters. Please set one and try again.")) + # return False + else: + return { + 'base_url': base_url, + 'api_key': api_key + } + + def api_request(self, endpoint, method='GET', data=None): + creds = self.get_credentials + if not creds: + return False + url = creds['base_url'] + endpoint + headers = { + 'accept': 'application/json', + 'X-API-Key': creds['api_key'], + 'Content-Type': 'application/json', + } + + try: + if method == 'GET': + response = requests.get(url, headers=headers) + elif method == 'POST': + response = requests.post(url, headers=headers, json=data) + elif method == 'DELETE': + response = requests.delete(url, headers=headers) + elif method == 'PUT': + response = requests.put(url, headers=headers, json=data) + + response.raise_for_status() + + except requests.exceptions.HTTPError as error: + _logger.error('HTTP error occurred: %s', error) + return False + except Exception as error: + _logger.error('An error occurred: %s', error) + return False + + return response.json() diff --git a/bemade_mailcow_integration/models/mailcow_alias.py b/bemade_mailcow_integration/models/mailcow_alias.py new file mode 100644 index 0000000..560b11b --- /dev/null +++ b/bemade_mailcow_integration/models/mailcow_alias.py @@ -0,0 +1,108 @@ +from odoo import models, fields, api, _ +from odoo.exceptions import ValidationError +import logging + +_logger = logging.getLogger(__name__) + + +class MailcowAlias(models.Model): + _name = 'mail.mailcow.alias' + _description = 'Mailcow Alias' + _inherit = ['mail.mailcow', 'mail.thread', 'mail.activity.mixin'] + _rec_name = 'address' + + address = fields.Char(string='Alias Address', required=True, tracking=True) + goto = fields.Char(string='Alias Destination', required=True, tracking=True) + active = fields.Boolean(string='Active', default=True, tracking=True) + catchall = fields.Boolean(string='Catchall', default=False, tracking=True) + alias_id = fields.Many2one(comodel_name='mail.alias', string='Alias') + create_date_mailcow = fields.Datetime(string='Created on Mailcow', readonly=True) + modify_date_mailcow = fields.Datetime(string='Modified on Mailcow', readonly=True) + mc_id = fields.Integer(string='Mailcow ID', readonly=True) + + _sql_constraints = [ + ('address_unique', 'UNIQUE(address)', 'The alias address must be unique!'), + ] + + @api.model + def create(self, vals): + alias = super(MailcowAlias, self).create(vals) + + if 'mc_id' not in vals: + data = { + "active": bool(alias.active), + "address": alias.address, + "catchall": bool(alias.catchall), + "goto": alias.goto, + "private_comment": f"Created by {self.env.user.name} on {fields.Datetime.now()}", + "public_comment": "Alias created in Odoo" + } + result = self.env['mail.mailcow'].api_request('/api/v1/add/alias', 'POST', data) + if not result: + #pass + raise ValidationError("Failed to create alias on Mailcow server.") + + return alias + + def unlink(self): + for record in self: + endpoint = f"api/v1/delete/alias/{record.mc_id}" + result = self.env['mail.mailcow'].api_request(endpoint, 'POST') + if not result: + raise ValidationError(_("Failed to delete alias on Mailcow server.")) + return super(MailcowAlias, self).unlink() + + def write(self, vals): + for record in self: + data = { + "attr": { + "active": str(int(vals.get('active', record.active))), + "address": vals.get('address', record.address), + "goto": vals.get('goto', record.goto), + "private_comment": f"Modified by {self.env.user.name} on {fields.Datetime.now()}", + "public_comment": f"Alias modified in Odoo. Changes: {vals}", + }, + "items": [record.mc_id] + } + result = self.env['mail.mailcow'].api_request('/api/v1/edit/alias', 'POST', data) + if not result: + raise ValidationError(_("Failed to update alias on Mailcow server.")) + + return super(MailcowAlias, self).write(vals) + + @api.model + def sync_aliases(self): + """ + Synchronize the list of aliases from Mailcow server with Odoo. + For each alias fetched from Mailcow server, it tries to find a matching record + in Odoo. If it doesn't exist, it creates a new record. + """ + endpoint = '/api/v1/get/alias/all' + mailcow_aliases = self.api_request(endpoint) + + if not mailcow_aliases: + return + + for mc_alias in mailcow_aliases: + domain = mc_alias['domain'] + + alias_domain = self.env['ir.config_parameter'].sudo().get_param('mail.catchall.domain') + if domain == alias_domain: + alias = self.search([('mc_id', '=', mc_alias['id'])], limit=1) + if not alias: + self.create({ + 'address': mc_alias['address'], + 'active': bool(mc_alias['active']), + 'goto': mc_alias['goto'], + 'mc_id': mc_alias['id'], + 'create_date_mailcow': mc_alias['created'], + 'modify_date_mailcow': mc_alias['modified'], + }) + else: + alias.write({ + 'address': mc_alias['address'], + 'active': bool(mc_alias['active']), + 'goto': mc_alias['goto'], + 'create_date_mailcow': mc_alias['created'], + 'modify_date_mailcow': mc_alias['modified'], + }) diff --git a/bemade_mailcow_integration/models/mailcow_blacklist.py b/bemade_mailcow_integration/models/mailcow_blacklist.py new file mode 100644 index 0000000..3e3ba81 --- /dev/null +++ b/bemade_mailcow_integration/models/mailcow_blacklist.py @@ -0,0 +1,101 @@ +# -*- coding: utf-8 -*- + +from odoo import models, fields, api +import logging +import json + +_logger = logging.getLogger(__name__) + +class MailcowBlacklist(models.Model): + _name = 'mail.mailcow.blacklist' + _description = 'Mailcow Blacklist' + _inherit = ['mail.mailcow', 'mail.thread', 'mail.activity.mixin'] + + email = fields.Char(string='Email', required=True, tracking=True) + mc_id = fields.Integer(string='Mailcow ID', required=True, tracking=True) + + @api.model + def create(self, vals): + """ + Overridden create method to add the new blacklist entry to the Mailcow server. + """ + domain = self.env['ir.config_parameter'].sudo().get_param('mail.catchall.domain') + + endpoint_add = '/api/v1/add/domain-policy' + endpoint_get_bl = f"/api/v1/get/policy_bl_domain/{domain}" + data = { + 'domain': domain, + 'object_from': vals['email'], + 'object_list': 'bl' + } + + self.api_request(endpoint_add, 'POST', data) + _logger.info(f'Added {vals["email"]} to Mailcow blacklist') + + bl_emails = self.api_request(endpoint_get_bl, 'GET', None) + + mc_id = [d['prefid'] for d in bl_emails if d['value'] == vals['email']] + vals['mc_id'] = mc_id[0] + res = super().create(vals) + return res + + def write(self, vals): + """ + Overridden write method to update the blacklist entry on the Mailcow server. + """ + old_email = self.email + res = super().write(vals) + if 'email' in vals: + delete_endpoint = '/api/v1/delete/domain-policy' + add_endpoint = '/api/v1/add/domain-policy' + delete_data = { + 'items': [old_email] + } + add_data = { + 'items': [vals['email']] + } + self.api_request(delete_endpoint, 'POST', delete_data) + self.api_request(add_endpoint, 'POST', add_data) + _logger.info(f'Updated {old_email} to {vals["email"]} in Mailcow blacklist') + return res + + def unlink(self): + """ + Overridden unlink method to remove the blacklist entry from the Mailcow server. + """ + endpoint = '/api/v1/delete/domain-policy' + for record in self: + data = json.dumps(record.mc_id) + self.api_request(endpoint, 'POST', data) + _logger.info(f'Removed {record.email} from Mailcow blacklist') + return super().unlink() + + @api.model + def sync_blacklist(self): + """ + Function to sync Mailcow blacklist with Odoo's blacklist. It fetches the list from Mailcow + and updates Odoo's blacklist accordingly. + + Returns: + bool: True if the sync is successful, False otherwise + """ + params = self.env['ir.config_parameter'].sudo() + domain = params.get_param('mail.catchall.domain') + endpoint = f'/api/v1/get/policy_bl_domain/{domain}' + try: + response = self.api_request(endpoint, 'GET') + if response: + for item in response: + existing = self.search([('mc_id', '=', item['prefid'])], limit=1) + if existing: + if existing.email != item['value']: + existing.write({'email': item['valuw']}) + else: + self.create({ + 'email': item['value'], + 'mc_id': item['prefid'], + }) + return True + except Exception as e: + _logger.error('An error occurred while syncing blacklist: %s', str(e)) + return False diff --git a/bemade_mailcow_integration/models/mailcow_mailbox.py b/bemade_mailcow_integration/models/mailcow_mailbox.py new file mode 100644 index 0000000..920db47 --- /dev/null +++ b/bemade_mailcow_integration/models/mailcow_mailbox.py @@ -0,0 +1,143 @@ +from odoo import models, fields, api, _ +from odoo.exceptions import ValidationError +import logging +import secrets +import string + +_logger = logging.getLogger(__name__) + +class MailcowMailbox(models.Model): + _name = 'mail.mailcow.mailbox' + _inherit = ['mail.mailcow', 'mail.thread', 'mail.activity.mixin'] + _description = 'Mailcow Mailbox' + + def _default_domain(self): + return self.env["ir.config_parameter"].sudo().get_param("mail.catchall.domain") + + name = fields.Char(tracking=True) + address = fields.Char(compute='_compute_address', store=True, readonly=True, tracking=True) + local_part = fields.Char(required=True, tracking=True) + domain = fields.Char(required=True, tracking=True, default=_default_domain) + active = fields.Boolean(default=True, tracking=True) + user_id = fields.Many2one('res.users', ondelete='cascade', tracking=True) + password = fields.Char(readonly=True, tracking=True) + + @api.depends('local_part', 'domain') + def _compute_address(self): + for record in self: + record.address = f"{record.local_part}@{record.domain}" + + @api.model + def sync_mailboxes(self): + """ + Synchronize Mailcow mailboxes with Odoo + """ + endpoint = '/api/v1/get/mailbox/all' + data = self.api_request(endpoint) + domain = self.env['ir.config_parameter'].sudo().get_param('mail.catchall.domain') + if data: + for item in data: + if item['domain'] == domain: + mailbox = self.env['mail.mailcow.mailbox'].search([('address', '=', f"{item['local_part']}@{item['domain']}")]) + if not mailbox: + self.create({ + 'name': item['name'], + 'local_part': item['local_part'], + 'domain': item['domain'], + 'active': item['active'], + }) + + @api.model + def create(self, vals): + """ + Override the create function to create a Mailcow mailbox whenever a user is created in Odoo. + """ + password = secrets.token_hex(16) + vals['password'] = password + + # Check if email exists on Mailcow + endpoint = f"/api/v1/get/mailbox/{vals['local_part']}@{vals['domain']}" + response = self.api_request(endpoint) + + if not response: + # If email does not exist on Mailcow, create it + endpoint = '/api/v1/add/mailbox' + data = { + 'local_part': vals['local_part'], + 'domain': vals['domain'], + 'name': vals['name'], + 'password': password, + 'password2': password, + 'quota': "3072", + 'active': "1", + 'force_pw_update': "0", + 'tls_enforce_in': "0", + 'tls_enforce_out': "0", + } + self.api_request(endpoint, method='POST', data=data) + _logger.info(f"Mailbox {vals['local_part']}@{vals['domain']} has been created on Mailcow server") + + return super().create(vals) + + def write(self, vals): + """ + Override the write function to update a Mailcow mailbox whenever a user is updated in Odoo. + """ + if 'active' in vals or 'local_part' in vals or 'domain' in vals: + endpoint = f'/api/v1/edit/mailbox/{self.address}' + data = { + 'items': [self.address], + 'attr': { + 'active': '1' if vals.get('active', self.active) else '0', + 'local_part': vals.get('local_part', self.local_part), + 'domain': vals.get('domain', self.domain), + } + } + self.api_request(endpoint, method='POST', data=data) + _logger.info(f'Mailbox {self.address} has been updated on Mailcow server') + + return super().write(vals) + + def unlink(self): + """ + Override the unlink function to delete a Mailcow mailbox whenever a user is deleted in Odoo. + """ + + for mailbox in self: + data = { + 'username': mailbox.address + } + + endpoint = f'/api/v1/delete/mailbox' + mailbox.api_request(endpoint, method='POST', data=data) + _logger.info(f'Mailbox {mailbox.address} has been deleted on Mailcow server') + + return super().unlink() + + def create_mailbox_for_user(self, user): + """ + Function to create a Mailcow mailbox for a new Odoo user. + + Parameters: + user (res.users): The newly created Odoo user + + Returns: + mail.mailcow.mailbox: The newly created Mailcow mailbox + """ + data = { + 'local_part': user.login.split('@')[0], + 'domain': user.login.split('@')[1], + 'name': user.name, + 'password': user.password, + } + endpoint = '/api/v1/add/mailbox' + self.api_request(endpoint, method='POST', data=data) + _logger.info(f'Mailbox for user {user.login} has been created on Mailcow server') + + pw_bundle = seld.env['password.bundle'].search([('name', '=', user.name)]) + self.env['password.key'].create_mailbox_for_user(res) + + return self.create({ + 'address': user.login, + 'user_id': user.id, + }) \ No newline at end of file diff --git a/bemade_mailcow_integration/models/res_config_settings.py b/bemade_mailcow_integration/models/res_config_settings.py new file mode 100644 index 0000000..07d285a --- /dev/null +++ b/bemade_mailcow_integration/models/res_config_settings.py @@ -0,0 +1,20 @@ +from odoo import fields, models + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + mailcow_base_url = fields.Char( + string="Mailcow Base URL", + help="URL for the Mailcow server API", + config_parameter='mailcow.base_url', + ) + mailcow_api_key = fields.Char( + string="Mailcow API Key", + help="API key for the Mailcow server", + config_parameter='mailcow.api_key', + ) + + mailcow_sync_alias = fields.Boolean( + string='Sync Aliases with Odoo', + help='Auto create Aliases in Mailcow from Odoo', + config_parameter='mailcow.sync_alias') \ No newline at end of file diff --git a/bemade_mailcow_integration/models/res_users.py b/bemade_mailcow_integration/models/res_users.py new file mode 100644 index 0000000..884fdbc --- /dev/null +++ b/bemade_mailcow_integration/models/res_users.py @@ -0,0 +1,15 @@ +from odoo import models, fields, api + +class ResUsers(models.Model): + _inherit = 'res.users' + + mailcow_mailbox = fields.Boolean(string='Mailcow Mailbox', default=False) + + @api.model + def create(self, vals): + res = super(ResUsers, self).create(vals) + + if vals.get('mailcow_mailbox', false): + self.env['mail.mailcow.mailbox'].create_mailbox_for_user(res) + + return res diff --git a/bemade_mailcow_integration/security/ir.model.access.csv b/bemade_mailcow_integration/security/ir.model.access.csv new file mode 100644 index 0000000..4ab848e --- /dev/null +++ b/bemade_mailcow_integration/security/ir.model.access.csv @@ -0,0 +1,7 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_mail_mailcow_alias_all,access_mail_mailcow_alias,model_mail_mailcow_alias,base.group_user,1,0,0,0 +access_mail_mailcow_alias_admin,access_mail_mailcow_alias,model_mail_mailcow_alias,base.group_system,1,1,1,1 +access_mail_mailcow_mailbox_all,access_mail_mailcow_mailbox,model_mail_mailcow_mailbox,base.group_user,1,0,0,0 +access_mail_mailcow_mailbox_admin,access_mail_mailcow_mailbox,model_mail_mailcow_mailbox,base.group_system,1,1,1,1 +access_mail_mailcow_blacklist_all,access_mail_mailcow_blacklist,model_mail_mailcow_blacklist,base.group_user,1,0,0,0 +access_mail_mailcow_blacklist_admin,access_mail_mailcow_blacklist,model_mail_mailcow_blacklist,base.group_system,1,1,1,1 diff --git a/bemade_mailcow_integration/static/description/icon.png b/bemade_mailcow_integration/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..4c79dc846cf269e0b71c9865eed3d12fd410b4d6 GIT binary patch literal 24034 zcmXtA1yCGIw_bel#cgqdyA#|cI0V<=4#5_83GN=;CAdSd;1=A11r6>l|K3;is%C0- zr>47)_vt?0%r{kKS#%U)6aWB#E-xpg4gkPlLQf_jBJ@r#HX<4Hg6u4(>k0s14E%S( z3_F#ZLvIqfN$a?2I9a-RntrtaczSxW+Bn*|nwdIVusVIU%03e!1^_4k@>1fOkerk6 zo)GXt`+JYom{yH9TMNzo2I^p#WiKxc3|c6z95CSz&C4u9J2D3!jKrCeBZ_bWD!jzu z!jPMNWncGN1ItQ-_oj%T>(n`>u8hU_#M#7&!s_((1)$SvN?>)}>%edG8o!@VGK<@0 z);jFd9pY@UJeQC>YCQD+FKInUv3xQu>uf)@QWm#MB0ibTZ1K0~rVF`bS|x+xFj;7; zzf>$j6G1dSa78IM2aXgr+os%StJzyWx<9V05xPU-ze@_cV^X}5@WtF9+0o>77;^@9 z6825QT#Rl^SqF1?x;#_f@8gDmh=KvwBBYKleAZFM_Tc^^ zkBZrS87T_K&htkU$JlC}W2Gw7i5I{?W?cZl4^dN`uAO3jh4RIxOZVL#l zm%qjk3q_z3ncmy9hX#GOoA;2xfp&xge*%t8UnYDQ4(!I4&j)v zA}8aMdH=SPl)brEJ8e=$qP>W>|&6KZoJOCe$O=~hl(kj-_QRmvrj(o z9m(^Q=gH9;cdJ4$n&S`A{cVcYaa*>UJd_|9*)i<)9IQWZ=QB6Ssw{5oD0^M2qw2YpEf9Q z<3c(FR?M<)z_=rk1A1zxu>{h|yn?K_q?b`t)^!ml^Ex2$6DBz?MBYW>w-F80;S+*U z{|VDslN(m1qK`Q*s2fA&)H60y6yXxw9mDZP4Uj?js;2t&cYeDpI#hj_XlQwa{5@eX z_Z*(5cGMs7i>r9Ey=}=&C{%_kqmw9}6AUx&ylDPo7oPHjdWWD-p%mD;UC~WOT~nZ@ zvd&1GUy)$S2+l(e>5(QU^a3{&j=F2!GNvAzyJnST+Sf z{}K1%6hHC!kZf(^r{(0F0_PoS28Gau{xgeyFXNO+s1nSF9r`|g*cdofgwws9Cm^gf zf^$mXNm0xhTwR#_pR|Qm;jKBl^Gh+|W;^5yyT(PcP^2AFI)pG_9FEvTI#!mz2hDP{ zQh2d)axgk^CvBf7ztvtVx*-k9dChCuq#e!yY=$ghDYgkG91&6-ECqD_&|(K8^qI1Q z5&KO0>tCbt)@*LN^Q$f(`qG zXdbgxb5J}3aXQtbG_li}AnXnd%0V?@1tX}#3v#YZ!+X9QU#&ULv1Id` zZr7hp9n)b;uzoSIg5k#zOJfzBi*N*82?1ZzTPvSQ8#RTFDqgu@9OvyshStO%(VT;o z2u%A3pa15u842BP#&CdUkH={Mzt4M+Da+nCh==IQnS&VoouiOKKiu33t8g;jj*rFm zt~T4Xho#|s5*zs(Q$H@2zz8>aRRqI3R;cctk-4LR1qJZIShvP+yH?Kl~`Uw1ztp&`V@m~ysaa`>yp=!gv}ItRneU`P_0 zCEy__^aO-6frrJnye{b8dyykC!+r33;@v0<5n_Clqg&;UzDBi zGK&8Y-gYQqkEv_TWHzX&WzaCQATuQhMDDh`$d};dpCP)lsZvGJO`{yYsv`h>qqq=) z7Z0QWy4Zb)0>euf8X380Q<`ySR26t8`=CtTQI&nE{YWoA>mbLDd~+-K(+4B6j+EK}%G@;W z=ljk|%#`F6IWAqQ#6huKJWY$_0t8C7@eeN157zRP<2#5I_62$WK+!RW&t zmu=RRABshzr$~}}(8z*6?nGZgCZBXB27cr|KI?eHADiJLQmwpQ-3F@7x6^B`Q@|_6 z|D+i}l5jLKv4iX{H1qx16UH35QrAtpU$t+!mr~1FzVBcjzj%Cg_7MUihS&rZB#Mat zy@PCF!gJeLgNIk3qp+l?x6;VBR<-YEHmB<^Zo03(!$f{MMAIe=4hCaX&@Jh+wfJg=^PqRl>v}l>O|NQIQ9qXn-HlyRibbgkS1A zIY~6aY*Bw5#X**^-9KYQ+y!ea2xZ@=1@gCD_XVabn)Tkx=@qJ&$Klj(j17H4LY5E5 zLla%mR@|Zn=x0t-l=Y(e8@uT;cLP3S}D8BwZ z`*dHjewoCGf2`PkgoB~C4FgUepa$1b(v0iB_y;^b^=w%0*`F-TF!DF$D?mn4VjS;9 z>w2xwg>NUg((_Q_Q!E~Z6l}67fUVdPQqpQ$*N;%XXG`Uiki#S{yt^-+Un_aA)Ij@s z4lNy-2L|0FF)=^22wu16*UuRz@zYpW5Ua=db6nW~oy_BG*Fx`z!!GS9>JP7PMg<&W z@j4o@3C#DlRE%NJK&Q>?zVXoL^y0LusmN{`QGr}kEIG;VUU7xrR3hjDTQaB8X{46o zgd9wvu7CxE2PwF)gp5&+_+#Q$iY3C985Mm6=2*iF-@n1bSCNL|P^^Nt;2Rr;CZW7S zU7NWOSjFS9kubU4|F_CNJp5I^?l?6<{2#p!Ji1KQ~BiCSr z!gb`CT$m~*BtuHFA33mXS15^ws=*UoR2u46w=yVUpd#*cG-NoB}&5 zFg99nE}C)!dm4!p!2RN=DV!%i90)J6tm`Rji(%VK%+z$Wz7^KvBLBCB?U*#{ zQKHo6<;8H!pTFUD8a*Qg?(+9~sc9N`Nsq8H?tgDbsQZ{xw|r8!ObT+q07+1M!lwUB zfdr>&PW1B(b_HbA&Ibh%9(N<0!)~wh*umd=F$R{iO;Q zFb;l;@nV=<+`1L){NiX@B$GL=A5fJTt;+;jR=@F%m9fB^p&8{(&)2!gFnDL#&iEA~ z3dhRM%xR($a~iKD4gkjzaK$v%DY+L&`!|wnb?q@yG?gUe_oPx(a=rye9i`yY`u(g- zpy+jMlwN0|gvaB;r*@iRNM&o>#?`(x_vz{qd(X%&L zw_V5kRqw>_nFzdg6?f*d4G+x`X^S9#HFr}#2WuDLRY+ewZ{3$yJWpt@Syg(<0@tQ> zPUz&*P z!d_A#y&jgYN4I319twE?mY6IX?7PUB z_Jn5LQ|GEck8BoOTDIZK4F8SPk*b_vhv{`Bip$LiETpC zt-dC9*AL$m8a1govT4HS@+^T52?fo5Qhsv_9t`G~+c%kL5;(N49!_K-FDPk|X?-lq z^~4*RGDMV5LUWJg9Qy5LFPe4l8$4z9ES@?qT%WjuOZIA-QuwyTI#2#5>|PqRvXrg2 zY49N5?GNtycsWgQA^ioUyKcpvqd6f7xiR5!ee>_m@LBL~bS;jYvI|k=L0(+{*d_Z% z_tHIUCCtEy3^*S_ta6Ev{XPe)$XCr1d;T+%6k?+{(XwLjwU%+*)X+}(%1fX8C^31L z^U&di>RT&e{JDqAtBs-DvoG4G ze+q<$4G&Fz?p*ubb`8gU`bzVoXwH0gbx+1DqF%TKBL3@PrOb3gz*xq^^vJv$N5Z6a zh2qzRF#Ay5`TBus=VNU7&Pk?jhTD?>J&}{6-|pEW?WXLzKTXTrxBPMa_0-yy@j$Kk ziD$r1EIZZ7#rXJa79o`3I9YLN9$Qb}aLH`G%8v!3j5S03OaW+_oDZ*0f^?lEaa+F@ zJ%6jUl8=7;`w7=p!ZD62!Wn8{{B5^zG1<+8uIa|1FIS8MOR)%MXd*r2TG0qvcDoV} zp&&)!XU=7=>$>xr0CcZB9Qc@QPsGZ7=Gg`0$~5Y{05@Q7bp{;KT5*Z+x6+J zcZeTtKR@TH(j?8h2gB$ARzn))`_qxq7ZiBDF|LnGJ)a(|v_)pqZ^Qc)2;7u}O7O zM+UThMjX0ds`6g~W(J5hi^ZvKkvuTGPcF71c=YM0--#udS((>gg7nX_6@58dNpM3G zN)GX#ck)9*7vwFy8I>2}_HZj|9#5VZz6cJqDf=EFO<2Dyjs7KOE+b(#f}&X> zzUYq>>sXV?_bB5@L~+HM@U#K9`pvtZDQ0(AH;+lCRt!=eX=@cb~p77mV9#+_@B@;{fT`x zjce`7{zA4PWH@QY;YpcRM`;Mh$JYH*2XDb4PQn7g2(k)croD6PL9KaRNNl1I*;%E` ze40M;$hYf#1gr@oSec~c%I4#B_eoWTS7d4xyYI%=#_ro}WR*x6jB6un>pacSnXPt_ z=SBv1ojLy$`}MphVcpCd<+b+9qL%qA8sNQ%t;vz=@$Uy;~Rd#)5Eaui?e!1 zbE-1!uGhd8k!NhxBaS;6?s>mJg$6Uz*}HsIl-g;6ThF0us`46m%ZG%I*+(-o&ec?R za)AJJ>GnU3m(@52Y#|&HBkz@@-PhmU{cXPvvJh!KR2W)(buq_f2z+&V!9m*e{<p7E_Q;5rsRY%F2CTLznWxGKJ?U=-TxnDbeQDrD0ylC}W z5(O<`3Z4x|(fxj82WoyuffsfhPISl{>P)f3jlAHp>JJ`R(~GZBdF5NhaG0|)8vt!= zfj3ISZtHG;^PWK7oCauvCg~v}w>8m&}XB@MXOA`bN^-9DVoTGgH6`D?m=P^<44XIlF_N3rF#8uNGdG` zRy?p>45xfX5swII0|I-ywlU|VKL8FQ7qR>lKv?qd&`ftZrE&jZ&pZYy{Y~;$+@UnM zv11L>(~%aH-$uq|-a@9kvIK7zY{;o2pB~cdkJ2J|If8qtph8-svlcbVK5UQ#_97Eq z9&Crfik$dgiZm_QFS$S?Tkei}2fhvSUBI2l;`>ej#i#w0q!X1qOd4BoWDOvu{D_G( z@oZlP{3PY4OhOI0>(?iPa1=-vEcJ2WIKpNyvf-Jbt?_jMj9dMG!hc%iQz%pe+FBHehfGW-10Jk$U#)$~}sH-6gKi@3n#@31WjJUmv<5dn0FrRe*9 zCkg43?Wk1pH>G^NIP#(B`xitbQgS`pM7$5S^n$~e`C6a9)&7?suPq8k1XlJB`6Zm^ z1suc0W)Yxv-LBu1b&F%*x8Pnja}pd0-Y}I!wY=SXDxKx5f6O-Dyt= zQ>fx8nDFqa%2{r&2R8rXd~MM-zeo2gBVlx-1mAe%v-#lCN{k`f2I}Y>-qLLma>+C1lO9PFjgsHy#2MQK>ksyjD7_|IgCeNYXi}!Q zlFnLayWHNLwp~55f3}E%BK1@wmr(-C8DP+SHUn_nJDYyh*U{47d>VIpj`*?N+HqV| z74RfRva=#hq53N4RJ{^kbDaCxp`TtcF!bR`KlFv{#QRYkevUZ8)}C7K`mm_82Dw%U zf)wn3o&57v4aLC~jAVD4F-Gb?%UGA6ei1I>d=1m`ywcU?buU zA{V_Ud=XAu7^QFx9;$bc!8`GJRfDX#yKu)d0x9csWpeLe0O+!J+ck9WmfnUSUA4j% zj=k!3lj0@jbXb4iyDJTT7n9bs54f-fMY2M&G-&QcwH^QZ&_ffMV9MjJ&ukp}ho|4j zKa#{_dZ}1aNkp>c7Fq6GAO2XX?fhdsU`g~g+?>eY;L0UpSlfI9bAL9uuSC3`O#cN}8>*AnmNa5DeEt*r$HVKta|CbS2J}Tm zZi(Tj*07{H>#z(Iva3?jH^iXFzt;XU zJ!xQ;jd|bVC%a~S4a%}P601K6)<5S{^5f)jbG~K4JV_U@5CLimx&ViZt|n^5j{ikb zMbtSBm?eZfwI+gNK@uk92&)lLzjhM2)Se?P4>Do@f)b#mCctRP7VGgyo7o`EA{d=2Io+6jH83nI9Em5GgX<>95giqDoCL;uT4-ayz>Naw{0 ziA(Bj49O57tlC8SsFg8d9Lbj!F3`&i7PH{5H# z-eB&!{Kqg?(VUm6yB^<)1#XhEE}O1OpZTt|EO-e~v#T}QA0#G&C9m$bE;_TGmx|Sb z<{S~^gD}2Y_7VdfVt3B#D`drK}%O$Ju)N=6R&W1J}t! zUi;S;jE*O!syN*xvE(uhj!_-GtiOkuAd0;KD0JNS)45SF^1Y_ZY@ihlR@o~OrR>oS zl}+h&5!b4N2y=li7a3fFwVWVej?GaxRRU+0Y(U)U%T2>cWyQ30yUbO4{q5*xnTj^r z5L=jYxxd>UqJ{avb`{?%rc|_7aPkhnIsX8X^jL$h7$|hK)PWY3p~&louFUo*z`A>X z1c|K#Ap&`sg^IcVqN+R>nES)S_pYPVO1EECfl@m16$I~tB;5V~SO8OXc0!@)dGs*K zy-?ZKVOL(bsoAee2la489URFkF}!mK01tb;_(h!N(jP=Ie}(qoDGOSVy%WCnhp>Be zrd?_#x;(rd#H=c3v6~Ct+7Phkn52B!amA$G<_*x}A{ z>Ix8$LO`iS7x-fP;lPQg`8&57VeBdr!1rb~xs(7is@U&B$oTfL)5&jie)5L;L+CIc z!Dp@4ZMvn6QNm8Kara?fr$F!xQF9Hq+Q?L7E!wX0@Z3tcVfJEnhb293RL;G6!hQc| z)GDJ6Thm)&k?WXW`YHnnbb^7>V3R%AcdibS)?Fb9ATK5nMn+W>wx9u`_)iDrI0jMB; zkpqZWM={OF(*ryGlPaTo^rzF+qR8v=f){7sbN$})SDjfXAa?usW;itwt0n0bC!ibW z`0df8di)+1q9}HF9`CQ0cY$Tm-*3i3`KM3XY(LP207eH+OatS(3BhU$$#bL3+gDZ_ zbHn$rmEQH&X6dY=1SL^KJWu}50=$~j?~=K*tYxHoAHvGXG=fibC;{Q;-EZqwPU%^gLb)V<$WbwO@2+L|5236d#KKd;6DeEiobDWG=R}9= zEld(W^TTh+k3B@ZH@90_j|W@(s4)q#<_Q*v;ijU(v=EyRz|u$_vmzyDCPxUq-O@r* zPtih>Ldn;^3jWmkzAC`1P9Lh2?O9kM?XwMz9tSko= z5dP<2>e_O6jf3yuvtup*Zv>T3u1v%vS#IkZp87aPcSlDNG4k{hzn3Ql$FVchkf;w} zPyS&WTb#zT5qrv3qkh?F7kx_IS&H8{IJiHb-5h3rKSAY*4LDuLOw8*S(2e2neOO_2 z>lCgWV(WQ8@x4Xjqoig+0)Uc&b>3?uE>GYBew9uNy1X@njZDo;E0ds5!iTM}&bJaR zy|>oXz>%KTH)o%8!5ZA>^$efP^^DXAX*H5#{&{nqCtHraTf|td*4o|QJ8WrPC-43? zW^?V9&4!OsqKb4u8hLBmNW;ygFO55kKDqxPpiCwnMj!UNRSL^} znF0CRQX0RskS?8HdNCj!6O*-3P4CyW4_Xq}x&CQ^#xGMr)j7)z;-4WOr*W#=SEG*o z?IQ*Y;~n^%9ucxme(~|%$s~8EYeGVC5jyY5)p*hlybK;gvZ;OsY?K(wsy*s7dEVsz zbb;V*P=->7t`V|S)YnUYW(wUSs1es)2(3F8;52v`YvzMJTmWUezZeji@n5VwcTARr zCoo66D$-8HsKEX8%IO%Uo;M~7F8z}>H;wIle11B@rYQ8m@{RMB>?ohcbf>J_!=e%; zzGDaKZVMk2Q{%HkCvv(X&vD`gQre?o0MD4@a~1)X3F-er^BeCd$2mJX7u*@Y^SM(x7G>bz9i(v;+IfO zDRQcg>Rc;&G7sA+3i$E5X=UPEb)!^KP*#K&D~ox^n@3EQ$;!mqqjw87tMdADCrd}G zw8VO|qfTj%2`~B@%i<_H5+kkFX^d{VV`a?UkF8FH387~p&I!AXz@*xW_qTPkKEMZb zt7Nn&?|>7%lMlWgt>Ug7f6hx&YZUb6X_xFo%#DJqKb87|H%uLCFm=`XZ3P!JEXzCI zZW`hLc(;one9iX^c$c9PnF!6>iB6At*gGd3VN>Tz9kiX>oqx1{ zK7$!lRp-E?0m+glr;~LP)Czfs;#zE_zyDi1zUV!Lv!klGEh$FHR2JlPi%KGWd6XKM zn+p?kdO=j4$C!{g|98@x=k!Pxcecs1keS)~9}@JMIxA>=+SvhNbK6Iqxul_EMj11xYo&aj9zO0}`H22IAyge8!p+|8X> zE3(0PujsyODva~>peJgih$-kMtO@>yc3plqs!N>MzIwF!a#3GUtARTV?W9rU-zWvQ z=GHIM2jrB5LyEf*cm7Hl)PkbY@~K{iSSiV+<6Ft&nbG0ji$~(W2FSZVdv2j6qr!!o zdyT*`_q3rET8kf=kNz4Qm-0PE(wbxqw>~`b-)%Dz9q|mXj99?;PfoUE(-hw9qaIfN z-f%-{*CUD^0ivK=Yl~SnfIHw#`r9=T3O5YK*cl^TJ$;H`@@(eLFB|y7yyGm}0vrL=# zQfRig0CxKY#TUloxdM(^>if0i4TL)0_u%Sd&RtueK- z@jT$N6a~I}=+q4T(HzD&V>jQBV;}0jYx&*`@sPOGsb}Z5?LPxJzLb#K{b&hx?rpPi z)ifqap#fKt>KRecm!&l}VV13jBY(#0n6hbIVXv<=SQ1DUw-LvO#43$HYpAPN?>Krh zurkd0O^FcO-es+VQGiYzk&A8PC*}PY2}}}!sp)#oJ2f7P89e&8?nv=q5_M7(Krt>W z1+SkvUXJ(5VW`PW&S;hji8@x|fm%*B6m2<$hScDS%@y#JoSlvy5#(nDD}vdvdZY9j zABHUh0rkW6D-JAjvlzMFNuUm?XcIgl0+2A4!u*8Gi_6zxdu_2?7XP_Y*p{ndf74AK zE|{PYRp1#L@Ro(UkhX{~m8$s4i@=e0#0l;(o7+zzObzx#4 zAT}TH=z|$Ht(vNS(NrR;%StyTV#a#X@(`zu3{$FH{i{j-^T%`MQBZy%1>G5MYA7hC zh77KZ=}kSDh?QvP~v44+^2(F7iu7-X1Xn`v zy(k|l0kd)F>|bA{B@6x&ofZhjzGAkCRQE>lfGcZ9f)WkGtXC5tB|R&Hl#xG%p8o3Z zxl+_!I3k+2O72K4mIf%I7rVO`*e($}r~AcZ}IjBCD)Q zUPxcqR1IHJGI;SAl2A;sn=By(LRhZGU$*C!)MG6dPn0}#bdXR^^pZW@rpiI43rze1 zD(!*e`@j>w`ZN@I?iDU;6HN((jZf;|*1%eq0|JL|_y7L>wpe$Gyh=;$%x~{Z2trMw zCygCe#3PI0pijXNz^6;I5WCpysI`iVQ)D)gdTsYM^fPA=Fn}V#gCb$$#|jNJ^zk#4 z^pi_@={v**o`hpuva+az2vK-g7Jh>T_)OX!gl2yYnKtG#iwdr+vB!`ln+XSAl%hh+ zV9_VB$ci=R9`+YUQp-P}Jj|^QM^Y~BiHU0+wWdcRwT>cx6?39}G6B{GgtD3>U8r)q z6=~u|#hvArn{~{)6De{j(vOZySfV^JV-KF3=uxOwM{uluXZX*2`ip8Bne27E13H2$ zNG!5wMHo?BO`yvQ8O}?u4$6p$=0+kjNh&3QsSMSZ6dbY|#*Se3l2MqGPv|e|M|9@K4McK<_M-tEq>&>oN;un`~DLrGGqre z{1ZGe>u}hd=;r-^Z3UoX&lVY|;G$?g7GVX{paHWDw351i9WaV>5>4P(c@%viEP%$o+7D zGU@9`7NKB;j~iyiJ9WD3rTkXQbU;MkiZ3m=M?d2(`z7mxK)3(?y#;o-E;rqAT)C_` z5OPfvseb5r6rI1Y{It667ela8A-op3h<;y|JA6(k z67kfSg_d8j87~uEJDLNmRR3COBLgJjWn%pFHKGJ3)z(_Fl3-ik=xCFF;N#Ev7IRGUx_^WC7-eN zR`H8p$%+V|D*l`lpM&c(Xwyndrw;+GmLhW43B9!cy`$N5@)3BH`WW679;V z(NbOWK{spIBrjsH#4ZQwrl70aXODhm#!?n{Ey59pVuE*+#&Mz+vAnuj;8e6>9%<~yCG7b2d6 zzNH}wpDPJ96@}}Dq+FbxIz5WzzfSQLYy)x-)y4Zkn@?zC^M}H<_ZWB$HZlV6BhzDI z?(#GYBj2569@WTjTr7-Wg2uzt#*6@n@-Imu!O)N6^C zw0})l?aEOZARx69D%dXRMHqtJ}x_Br+riinr_Z z*bAybli!7{Pwxmjcsd7R^)0V*ttHP8iQC^@yE15mI(Q8AGt#T%Ax#fHf}^_X2a%h& z{JFB0{u~BgkIwt}y{X!PF*&}=oHuNZ5o3Nc4%ysrvlePzyhI7W38Rds~AVQ|ssh}~pv+>1p! z4{FfMKyv7mZMZFEvG#S{LAle}55CthXm#D3--hh92aVRw?m=I-pr(}tzzrcBYLiL( zCjk`qa3MmeqNA#HW9CGSO0tqXj$FtLO1ex|Sr)#*xa5&k%=V z8=nWC?HXF80k9`xO;OGXpZzx1ODL^9hs?cjB0IPJ3WUVeikv~!QeO=^6*l5_zYCoG zH4o}+=F1z*EPM5=Mha2Fuk1T0N7(TtdwtiRDfNnBVS}Nhkzg6R;Pwiy-J%GU?8~rC zQO=DE9=innnwKk8Kn!(;Vl%{) z4rR)g2wEAS9@iVKrIWmSO?Y(vd!JV|njs5muH0+cK?|2)x3FH?&m>y#*H$8sGknI+ z%C$q4*XARm{=k1h-u(o`U9L{vX`7ZGf2 zeH?B)OSb6H`ptPiok}t znFk~RL>Y>c9K30#C)7vhRMbbwKp(y?d!hMxraXVX61Vg0L0B5g@1PaK#wKPJPagYy zT~!l*e;*j6guBH!+rw9_hxk00@%2}NJKDSu;)-Db^%e`)XJb(dgAW_ekmyn#lJ4Gz z-S2VIVW8l@F3IfvGo0qT#nh|VeTk_d-^%jBE~C>5{oj?rZzph)zSQvW?U@+nA8V|S z4#gt`Tk;!pr9cu}j6df6L~V%*y+TE}%}IAchx6a93I+lZw>i8dO7?lO)qmS4@MDzV zl$ri{;o6#*Qo8ey!39s7LD66Ic|tf)a9x4rHSL4-RR)z=xfj}tTZOOY+->S!Nu3@uP~oU?L%o86lM>8WIwf)2 zHD@}l_{Z83Q!>*k`ua z@i)6G%qPF;iGCIW%x4EGi|Bu+0GZT?2=cxftr51U2SaDkS^`5X4ARi)qeMLe=2$NmaI;IOkkdT*UB8LyZ& zQ*`yTwy72~P(BW(6-9O;-gy zGy$eRWGn(*f1H0Zgv0=G5|fb}i7w`|@bI61$vm}dy9l&<)<2mkf!pjJ`$CF3O}N1y zK0mn?yO{5pyd90t`0i5NIe5eQw{rBU6L~PvrV#X#B#7~2tl*8#3AOcMjm**xEOK&l z1cN*(=|KQhn|Ru7H8s7^{w{D+Q{xh(gRH3=!{5`M|_(1vQ%Glkgv@GR9-HD#Mw^-53Ic7U2_ld`5o! z!C20(jvocAV`>lP^}>}!+7PRJVPk9sBl1MNzztl@3qjnoZuM4{+c)DqVPm@2vHCQz z1yC?({RNY;>?5uNW~mPUwZfL%7h(~1xoe=W{h{8FE!m%~YYRl_qJRA^VEpMh+DQ^L zY_cCO63jn$v$3K#i-|;_@J*tF>V7DB`PwF?8*Mapq+mYwA?k~)`eUMxUKuMk7hIFq z%!cgxa}a7`lLKu{)5{N=+%*c?#IEHzJmg%{hQea&@#!y@tNvx)1NVHo=6pad)*(3d zKlVi4I81#iJkwleV-Q?|@@XWSD=hktWARZHjqLA*Occd(n={-`>bzcv`!&iZ&+d4! zVbo=+!-h6xJr@ZtKE0S2>A#6WCesSCtrSqp4a#iDB!E*QlHKv*rD&)Aej;sG3|#E5 zC#lqh!P;3PyV{yx`qF=*7)7cC;;QJDU&l33-{6qJ+}lZ-)wm5U7wN$|zx;0V{?Mj- zZE(P5D8vn*Me*iWims&l^0ZQwU_0tz6GPcqj^?UuSh^CrjezB~bL;-owj{uu!3?({ zU?AZP)!Xc0hdbx40KO@k)vJ9aL-@1bu2G?|{WT*a$MZ{V@v%b;=M@tphuvU0x8M2W zj+J;F?fr<+p*BzuR|qC(u7~f`1g(cFlwBex+{5~4Z+7^|HNBE7J90nKP&3@iMJQ-C z1}$n>X(*-Spn_a<2Mx}%C@zS0Twn4_bxbd0G6b^-hMh=Z3vSYtC4&cH@551!dd-{B zW`tJt6xxKLm(8CRr>w-jWuj|U0j`!jmMVL&LD*h&o2+9dK7bOZ_x zY-DIdci5oKUo2?f6l&$LeXQ^0`uP>x9_s3iO_`E|3PKuX^SKGxD8u{?Nwz=cB z^;>`Oj*7eS%WVDw&+gN_x3MVMnvOf0jwM~Z#b0?H6o*Q_YMOl=0-g9%y}ViiN?-O0 zL2tBLFHVCzo9SB2lHpt}o}J2HxG|)BTRi%ftWmUdqCMY0U&l5n5#j>X*?u|u%fPXh%v6B3=tr=}){WTFy$nQ7R| zXm(C?#9J?A)ellq5rJpC7XdKt=OOifc<27nGy3<~7Yv|~g6JHhe_KecDYEQH+E(}z zd~||^go}72>1Kq%aqW|JaQz6^c4zgD(8|}LeKU&;kJCW?9%!#jkmmo|*|7kD95icR z{t+b;_p~}W*u^4YUJ~7Z$yPksGKk&~v@W6{+0ZS(oiCZJ3)d=h($1m9AhNb+R-vomceM}DYQu9`-qzCQGpmQw zO4h{_HK`0kuGfR<%OhWF1vVJqY<*7l=k49`SA^#S9))9;|ylBpK(3_0Df{#N#M7B$(SqIuot#Lp0eKSw*9LGwP9{=A2X4D z4v!&U!$P?w(Q9rIZ$Xg*HUND?aL>jY$&DKnib95NztM)p_I^CtR(pj zN>lh~owY70H2^@W{oi*1_N1}!=CA{TZ`|B>!iLE|L2}Qgl)G-GWpQ$?iYX(^g>JXt zkf*5;U?E3A&p27sznD!7Y^BJqg#sAaC+6Px0NwZLnM8P{Wa_dX8xR5$o;O{lpI_}3 z#c-*~D{`DM{nT+kD}|Oza?X)Vi}LlZ39JUin1 zjlQAlmQ2(qGH~;z_jQ>E=zYp=`!qiP&q(ES9Y0?H`1}Lyfr$VhF$b8rw@X9_A*cKu zzV^pYy&lQ0XcQ)E9&|_#+fy(Fvgi4@=f3iy^CBkAH4Rb>c)^1Fit4!6>Z7Z@9Sz5h zB|J}m_HNy#>wLo*#u)Z)->&PNd$wtxPk{gl;X~AufhdX~gg{XO@b~okf(T)z`a1xV zg$Dpw#3+ta-z#7=z6QmjDB%0wybN5JprAHCL#W@fdF5ktag?^VwkABsU^-gk)4oO` z?(S~gqeiuVz61f5I_3%hSWem}zOLud*Tg#7ePt9$5FSyE+L#X^@6&P5J!8s*uz4?x z0nPoikKFRgxYy{9&!aN_T?6i(w9lgtuUr34_nsbYb!38YfSMDCtqMU9z;PTDB>->x zUrxOq^}aF)(}hP=(*=O+eO7aCm;PLR*=kf)42{_AUr{}~s3h+B`{T;W)McLjWM${- zKId@7m7S&goSE6`8yPDQA+gjkR{+3rvPiSH^WOk^L*+7@(Pn%fjgCOX1HLp>3j34{d^e+0!Xd8 zE~1yCh^e?s-xvLV4^ePs!)&I|~mK4>0j~NRVs2Ue!PEeK_rz?H*X&s)9#{np(UXOJ@8AVb) z9f4=yqo`SoNsv8{M|@umUs|yYXD*@i=aS#Oovh0Sx^Y5o#=LH%uYu)fYC1q6rwiv3G?0XF~ zbTFYcest|uQBm4!qA&o5x}bZB!4+S*1X(%i?dWK%TC)ZYXKH!&lg*-K%TQibsmokv zoO2FJ%A;3`Qy?G+b(QMO#ClTe=xMndeJ3mcU`>+A0whs*SVR`paSBjK?!>~0ZB`3z z{{A(v+j^fOhq?g4qHh?U&1S=u*MB$Rd5TI)vGR&5b=c9!g5!96MyV3{8aS%ljLH`j*o-bWS8Iy7Cm0yNJdjrOd;A{W!eOz(f^_ov#atrct%YXk5%EpW}WM3nN3of}7-~P!@ z!G~)E6om{|CVqa$-IzK%dg+A#b3#d>c#b}Jo`)a^@VP&LA`MI)wONyAvH&KPA&j`V z(Cau=R3Vu7A{?iD{uHN3W|!Oj+K}r z6^6m#GMozs~1EmzNz4~#6TIO)xK*@n+I zZNlE|+tJ?AjDX*djLa+)6c=OW`~_Ha)>#-+9Xo$Xf(^kG3qS8e6a?7qc62rV4gvQI z1Kx8vWkUnQ)RZAiy6^yiE{_1JL|=jvC&QM14-&Ps6h%P>?}b2&@8#JaMk$CH%``?S z@qE4^WoQlfN zRN8}(JF(F9J{W)iG9e>1>QbgLmJqD)v(y$Gy@h@hbf5T-A@6Z~>S!H63JDJYuv?Yb zg8+oQkAsHx>au3936qf)AR`tRhpT zeSj$lcK!}vew|kCjV?^J+6`&+L1Olk$FZp^IDp=m4!`?7NWQm+yvGX^)+7}CiAG8Z z4*Uj`~^trC<%do)5vQjchDotz@2=KA&VRMpZvx?|x`ZcK1 zJmft>0EgAsfUtN;N#Vh9T1u-?NS$|pB~W=R{H#2DEDJIl9zVb|y@Z$*K9&iZnm_F! zSS%L!yPt*RdoS$!R*Pb47dS1X>?iE7sZTd%v=hP3UjZzj>mC>g{K!1%2T7YTsSE=_ zfMuMF8XX?r70>ga(oXm~{uK6oj$uc*u$_fvlw|$Jh@z6Ts_1G-Y2g6?8FocewItd1JY-+| zZ7ZX}KoDfklQ#A6`mv5QC^1_1< z2Af8^@Bg=V?y;6#bshh%wf5t@?mhQ?=g!p7x-#K3+~P-|+$Z|hCqfWZR;7t}MP@)q zSg6tjJVejh00747aPuFAbe=l&7{k;ZrKlJMA|W0?Fn#GX_5%Pw5?=m8&~AUL&T9zN zF%E|^@l>WyG{55@L2+bm*?ln7;pRRF-}(08$1uWJ7mEbLCNjSSXM{|uyDyf=g_FA< z%6qy`z1qcxjVfi~QW_MUuXUu+5Dn`t+VM6-E}Z-aA)W8EJT8;!a)2B%k-E!GL&LVZ zRc04n?){O111QsMyw zYu~L(RUQOU1@s)08kZf?tN|VJ7MUN z4MA^32Q2xT9Lj>0~sPWe*~v-46U8-Nt_a0Fe(j{~?q&`x8jk z4ha(uWddsiDJXhrStAMtTuP&gDL9llc1ftUyaH$Y525|#w#OzDDsp~~y+rz#OC=Pf zW{`e&8hCBR#aQ}&aQz(8^M8h6Xefchqa-{e!6~TCZZZ%2kZ|QFqewu)hGaH0MRvSu zi*|nixA0dWU2c1nT&@kXZFdqhdgqbOAa4<-iP%t53?Z#$A@lWXxukBmC#CaY^o1$_qDJE=)8tN0*yC(z1Q2SQA4hTZkD%REluNC}gLA@o zs`F%5qfFa!lS|*fL!~pE+g4y^jjC5zDTY$j0MX4ItX4I0Hwu`VIXq!9H|8+D_;)Z< z{jFnrGzfwza8lrq;7mb4f(>v;CvYej(8Qsc0ZroA`7JtybQ$IA?}JXbrhTxS$BbYNB9Jbc{l)yn?APUYVa1i6rBpR)VIPk53 zbbgG|>U*HPl>`63C$pHG-CxeK-^HoJq!aPzYRDjBs5=6V%STc~OhY4`@=>h?q0HA- ze2k^HkQ(bSBRl!1to=1){f8Jk`Qf4ZLArs!pvc&@06G}h7^pKL82v$V;JBqnQNI3u zP>a$ZmQh4$TOu_v0YdU&!XM-#!TaLZfZ+Jkk>gabI@8l~0@wYq^q*+k6| z&Z=<&Par7g#VNA4;&a=ob-Hm$gvt6B+vMMm~ z0!oGGudck9P+Z|e42~;MEE}lQ1e6+%h*T?tD;KwM`j#SWOCfp0{Q^Wjs+%82;mUo` z{#r=AjENS6dhT;5EdS1-{*A*(+7h;$MhVMT@>Y-O}f%)C?4>23)_tevy^ue3)3rc=qNM728yy2ngvi z$}fBXg_V1ty!N5TG0}y{M}6xPC@lXb{CfAvq*AsI+dOERLa>c(Y_AodRrCj;zMBS0 za#U-9U!+ zvEy@m$x(I80|Uu=L0GpTbK);xW!@A@doja9`Rk}{{XLxgm(YuYoH0V$_PZ(-0%n24 z492pfw>h$tIFF&k4r|4OrQWiEa!tVRa9>K3YK5@UaEx|?X=r3perT~N%3DV9>bp?5 zdLN{FIh;mg1`3gndhT<`KmXQ-PpcQ<0Mf}Wg#!S{16OO<-VDbo9yCL-JPx91jaWwO z+MMe*6r{6->;e9bPSKj7T(_WgcvC3GEhHy@AE}vlgNgCb+KuTK1h@26RM#Jb@>hFq zB}nGub)TGS9}G2T8gr+TM`$BlLJvn5ZM z_fKOyjl|SFNYA_*jE{xdc1$?0wl&e{sI6XcRQQMIf zkG(W4lB>_vu&}TN&cX}WA_D5J3D*yQ&dEd;si}LBoW2i?XWE|-(|7#Ji59KMCk3uv_r!!{}<|8pXzDZ^~EqoNZAtBflIOBpp*rkn8n!m@X{GX zUOZ|w!rHYw=I3tSN5FaDA2AFKQykqIk(#4gx1jqJiO#qMd+cpU&HMp`eS3c{60-wo z4{rG@sBirPr2Bj*ZE{XXCwv$o+;8n57;x1rX6A;I?jX|Qk=s)6YMpMS2TVf+Vj^_U zrlcl@nqzjo`qLd8*7#eHns^7y%w02EHBbD%b4lU>f z#u%#gSDeWkgCvo!D!TQZ2DIS+EIGCTygY$4q zL&g9@1X-$0p*!}BE6q@?TTme`=N<-Q2CU4RVQ24#m3cE5k47GmVI!Sq;T9i(Q~WZt z+z!2c!3pW4*V6zLHsx{#Gp8nD4yfv8q{KsM!sYW-EG~8z_|+|(6HK9D8VW*COEZ)R za6AK!H@JBx;|W;l*TWirE9~)K0b`@H_CE?p_cFZFSKt&M>3WxIHzX{Hgf&{lm>eg8 zWOf?qO#jF7$cRU+Mp(OA#Qc1W2U#Xkanu}hAXdm;7|*~OdlRh8 zFTzUy0=O|V(Cd!^q&>*mci~kZgIoSaUj+jIgdn66K8%no->}izgk-arn(1#ydnCjo zx1~@oKKh_PTe=)4=`8B!TmBI?3cq#-32psC(P7qz>Vpl_Nf<$B$WGO__Ze+|9z(~ z{)<+arbfyNFH&?cFyxCTDp9JIm8=<;^R0-0(4Cy=z>0W|zFGD)#LE0Hge>e{UzD#3u2d{P%OXE0k zItvl9TQ2nM>0vM^$7ny%8UQ2MhJq^v%5@6>HKsYit6&HFPx^`S*P;A%_;`HhGsaAC z^CY;r2w|OqFc%@LMR4N;n3w<;6JTNjj2-P(AYFl$Iglzqt8FN6723N7<3&0?vViby&ilXptQ+Ve^KM?94QhsliUC2Pz)*8sUYj1uQNepHysi!`bTj0#HJ{%$)xM36*f?>&R}YGaJfMSCLXyhje0qU`T5as zjD`tkgoG9BcW8=8)2XTyhXTjmM~g9PSSA`m1Y*Jwybfz=kaQW!8x6}KY#1Y0hDOpp z9%UX6!nQq-TE~rRvp9KrFu@=L5s%dsA1Qkq6BE%l6I-z(B8Gx1g9OkAt;`THNMeBI zz>A#woCkH=_6Z9EI2)d@L>0yemZ^}m)Ug$fy)dCLo?6H1$_y57?SG(Q-^Jtd1qbu9 zdGG;jCK?quCnPN&36sH79Ij_}yx~skph)Cl!XlV%tN=AE7*5~}V6nLDz!+egLD_>r z?nN052oqzQSh_HQ+g?5f*2hJ`zKMs{gr$o$EG!iIb%~LOHJqxJQ1Fz1>lsJNUKR+; z;d9#Nbci*A-2zbwmY19FkMju3`L5;nQWw&>xdN^&Nu0Q40#*mPK{PRF6;u+`1h^fz zu=*k%bq83xQpCc-;3Gyy5yk??s%hIj9>^2+tiU>at#};8M4_bHm^(A=~-kZg8NPDVB3WT18FoqyZJt>JMF*A z(8VLSrBN^CFgJJn9HU`ia1F!OuuTSEad^@gXoWo@2BjD}eaxC-!_X8K27nsrA$P@w zv7mT18n68x`Gp|ZMo>ID3L@7n%?G+^34ns{y;;&#YX%|)LSQwJ4&H+r$2Va>v6#nJ zeg<2cyPOy2fY^XGcFF8CZLQTpX22HW)h2WtMgoF_Q z9+cM(#D(olp_;-&&G&3?8SdcNW_zX~VOtu;o^+I&*iD7}dKDBR9BdH=&+a~SqnEVv zo6&B6cG_b*1<#rfH#M&Vw(sBj-iL@~N|<^bSDv57%YGufEe)ZG$4k*M8Ww_U2umYj zNl3*Z)o2Pyw*>v)^EdQxYwTo<6B-NzDFvkLZ3Jy5(y*Dv1goYskA<@JjRbZ7!6~2#T6lo@NG7SZjhdU+NUGX~p z-{WD-&*gFDl8;ljXJH=k>2G^_s~fgnBdjhLvA7sJM*9J0!KiCB9D%;#(3%IYitnG> zMikD14EaH#rf~An#2k2ZPk> z?Q=P{6_0ui9)NxEuB$5&33D40lcS&e;5Z|AuxAE)mMC>YL^Sc>ePbzZuj8(t1hFK| zfVO75$>E8XVunX5<-wO5SY4UM;;s8b+MPPuS_N&zBbTF%Q@7x@=D#mra4%wq6J9FOiawka$>HA=g$|m9zFlcC)*v{(Vn~2HT&VCcPl0)CJJ1?VSK)m z$XA5$=&Q?b{FkSnvc45gH)CRAMvWglY5&h7 zm)`Ji03bI{ww-CT6_1A>epmsi3IKfKvDe&l;Ya4xqjxhVCT5hle9^f2$;W=~UH}ln zD4#p`K-&n=jvS`v9)75zrL4GW(*NhLz4|T7&zm=n+S!9rSEx8LTzZF)u> z&&ta&F~@}MP2nyt*^mC~GiTm+V`FjaEqC5hec*uydfKSgvv};~?Afzoa}&vX-}c>i zO=s2Hr>6B!XD9V|A_*hjC>0Yk9JFR|>Ld#}QOf7}`9jJ1(#O93`cJ<+>p0z783z%H zcpS#N@4Cx;-EVE562=H8nTUxwhA7GCvtL_TJ$H`!mN%oX;t>-QbA0JzJ*k+OnBz<= z9x*X7JrRpXOiWBq#NrVX6Vns1c*MlS^h7KkF)=Yc5sODmOiWMwKVbDo9GXP0=>Px# M07*qoM6N<$g70IJ)c^nh literal 0 HcmV?d00001 diff --git a/bemade_mailcow_integration/static/description/logomailcow.svg b/bemade_mailcow_integration/static/description/logomailcow.svg new file mode 100644 index 0000000..9ba163f --- /dev/null +++ b/bemade_mailcow_integration/static/description/logomailcow.svg @@ -0,0 +1,187 @@ + + + +image/svg+xml diff --git a/bemade_mailcow_integration/static/src/js/mailcow.js b/bemade_mailcow_integration/static/src/js/mailcow.js new file mode 100644 index 0000000..d1a4ac4 --- /dev/null +++ b/bemade_mailcow_integration/static/src/js/mailcow.js @@ -0,0 +1,89 @@ +/** @odoo-module **/ + +import ListController from 'web.ListController'; +import ListView from 'web.ListView'; +const viewRegistry = require('web.view_registry'); + +const AliasesListController = ListController.extend({ + // buttons_template must match the t-name on the template for the button (static xml) + buttons_template: 'mail.mailcow_aliases_list_view_buttons', + events: _.extend({}, ListController.prototype.events, { + 'click .o_button_sync_aliases': '_onSyncAliases', + }), + // This may need to be async function() if there needs to be an await this._rpc({ ... }); call to not reload early + _onSyncAliases: function () { + this._rpc({ + model: 'mail.mailcow.alias', + method: 'sync_aliases', + args: [], + }).then(() => { + this.reload(); + }); + // Couldn't test this for real, but it runs the action. May need a this.reload() + }, +}); + +const AliasesListView = ListView.extend({ + config: _.extend({}, ListView.prototype.config, { + Controller: AliasesListController, + }), +}); + +// key must match with the js_class attribute of the tree view you want to modify +viewRegistry.add('mail_mailcow_aliases_tree', AliasesListView); + +const BlacklistListController = ListController.extend({ + // buttons_template must match the t-name on the template for the button (static xml) + buttons_template: 'mail.mailcow_blacklist_list_view_buttons', + events: _.extend({}, ListController.prototype.events, { + 'click .o_button_sync_blacklist': '_onSyncBlacklist', + }), + // This may need to be async function() if there needs to be an await this._rpc({ ... }); call to not reload early + _onSyncBlacklist: function () { + this._rpc({ + model: 'mail.mailcow.blacklist', + method: 'sync_blacklist', + args: [], + }).then(() => { + this.reload(); + }); + // Couldn't test this for real, but it runs the action. May need a this.reload() + }, +}); + +const BlacklistListView = ListView.extend({ + config: _.extend({}, ListView.prototype.config, { + Controller: BlacklistListController, + }), +}); + +// key must match with the js_class attribute of the tree view you want to modify +viewRegistry.add('mail_mailcow_blacklist_tree', BlacklistListView); + +const MailboxesListController = ListController.extend({ + // buttons_template must match the t-name on the template for the button (static xml) + buttons_template: 'mail.mailcow_mailbox_list_view_buttons', + events: _.extend({}, ListController.prototype.events, { + 'click .o_button_sync_mailboxes': '_onSyncMailboxes', + }), + // This may need to be async function() if there needs to be an await this._rpc({ ... }); call to not reload early + _onSyncMailboxes: function () { + this._rpc({ + model: 'mail.mailcow.mailbox', + method: 'sync_mailboxes', + args: [], + }).then(() => { + this.reload(); + }); + // Couldn't test this for real, but it runs the action. May need a this.reload() + }, +}); + +const MailboxesListView = ListView.extend({ + config: _.extend({}, ListView.prototype.config, { + Controller: MailboxesListController, + }), +}); + +// key must match with the js_class attribute of the tree view you want to modify +viewRegistry.add('mail_mailcow_mailbox_tree', MailboxesListView); diff --git a/bemade_mailcow_integration/static/src/xml/mailcow_templates.xml b/bemade_mailcow_integration/static/src/xml/mailcow_templates.xml new file mode 100644 index 0000000..80e19b0 --- /dev/null +++ b/bemade_mailcow_integration/static/src/xml/mailcow_templates.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/bemade_mailcow_integration/tests/__init__.py b/bemade_mailcow_integration/tests/__init__.py new file mode 100644 index 0000000..cc6beea --- /dev/null +++ b/bemade_mailcow_integration/tests/__init__.py @@ -0,0 +1 @@ +from . import test_mailcow diff --git a/bemade_mailcow_integration/tests/test_mailcow.py b/bemade_mailcow_integration/tests/test_mailcow.py new file mode 100644 index 0000000..53ed6ca --- /dev/null +++ b/bemade_mailcow_integration/tests/test_mailcow.py @@ -0,0 +1,12 @@ +from odoo.tests.common import TransactionCase, tagged + + +@tagged('-at_install', 'post_install') +class TestMailcow(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + def test_new_mail_alias(self): + model_id = self.env['ir.model']._get('res.partner').id + self.alias = self.env['mail.alias'].create({'alias_name': 'test', 'alias_model_id': model_id}) diff --git a/bemade_mailcow_integration/views/mailcow_alias_views.xml b/bemade_mailcow_integration/views/mailcow_alias_views.xml new file mode 100644 index 0000000..367c6d2 --- /dev/null +++ b/bemade_mailcow_integration/views/mailcow_alias_views.xml @@ -0,0 +1,76 @@ + + + + mailcow.alias.view.tree + mail.mailcow.alias + + + + + + + + + + + + + + + mailcow.alias.view.form + mail.mailcow.alias + +
+ + + + + + + + + + + + + +
+
+
+ + + Aliases + ir.actions.act_window + mail.mailcow.alias + tree,form + +

+ Create a new Alias +

+

+ Add the details of the new Alias. +

+
+
+ + + + + Sync Aliases + + code + model.sync_aliases() + + + + + tree + + + + +
diff --git a/bemade_mailcow_integration/views/mailcow_blacklist_views.xml b/bemade_mailcow_integration/views/mailcow_blacklist_views.xml new file mode 100644 index 0000000..fe51427 --- /dev/null +++ b/bemade_mailcow_integration/views/mailcow_blacklist_views.xml @@ -0,0 +1,66 @@ + + + + mailcow.blacklist.view.tree + mail.mailcow.blacklist + + + + + + + + + mailcow.blacklist.view.form + mail.mailcow.blacklist + +
+ + + + + + + + + + +
+
+
+ + + Blacklist + ir.actions.act_window + mail.mailcow.blacklist + tree,form + +

+ Create a new Blacklist Entry +

+

+ Add the details of the new Blacklist Entry. +

+
+
+ + + + + Sync Blacklist + + code + model.sync_blacklist() + + + + + tree + + + +
diff --git a/bemade_mailcow_integration/views/mailcow_mailbox_views.xml b/bemade_mailcow_integration/views/mailcow_mailbox_views.xml new file mode 100644 index 0000000..fc233fd --- /dev/null +++ b/bemade_mailcow_integration/views/mailcow_mailbox_views.xml @@ -0,0 +1,82 @@ + + + + mailcow.mailbox.view.tree + mail.mailcow.mailbox + + + + + + + + + + + + + + mailcow.mailbox.view.form + mail.mailcow.mailbox + +
+ + + + + + + + + + + + + + + +
+
+
+ + + Mailboxes + ir.actions.act_window + mail.mailcow.mailbox + tree,form + +

+ Create a new Mailbox +

+

+ Add the details of the new Mailbox. +

+
+
+ + + + + + + Sync Mailboxes + + code + model.sync_mailboxes() + + + + + tree + + + + +
diff --git a/bemade_mailcow_integration/views/res_config_settings_views.xml b/bemade_mailcow_integration/views/res_config_settings_views.xml new file mode 100644 index 0000000..d641fc7 --- /dev/null +++ b/bemade_mailcow_integration/views/res_config_settings_views.xml @@ -0,0 +1,40 @@ + + + + res.config.settings.form.inherit.mailcow + res.config.settings + + +
+
+

Mailcow Settings

+
+
+
+
+
+
+
+
+
diff --git a/bemade_mailcow_integration/views/res_users_views.xml b/bemade_mailcow_integration/views/res_users_views.xml new file mode 100644 index 0000000..366beda --- /dev/null +++ b/bemade_mailcow_integration/views/res_users_views.xml @@ -0,0 +1,19 @@ + + + + res.users.mailcow.form + res.users + + + + + + + + + + + + + + diff --git a/bemade_mailcow_integration/views/templates.xml b/bemade_mailcow_integration/views/templates.xml new file mode 100644 index 0000000..cea6b39 --- /dev/null +++ b/bemade_mailcow_integration/views/templates.xml @@ -0,0 +1,24 @@ + + + + + \ No newline at end of file diff --git a/bemade_mailcow_integration/views/views.xml b/bemade_mailcow_integration/views/views.xml new file mode 100644 index 0000000..45f432e --- /dev/null +++ b/bemade_mailcow_integration/views/views.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/bemade_time_off_follower/__init__.py b/bemade_time_off_follower/__init__.py new file mode 100644 index 0000000..9a7e03e --- /dev/null +++ b/bemade_time_off_follower/__init__.py @@ -0,0 +1 @@ +from . import models \ No newline at end of file diff --git a/bemade_time_off_follower/__manifest__.py b/bemade_time_off_follower/__manifest__.py new file mode 100644 index 0000000..76eea2d --- /dev/null +++ b/bemade_time_off_follower/__manifest__.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +{ + "name": "Time Off Alternative Follower", + "version": "15.0.0.0.1", + "category": "Extra Tools", + 'summary': 'Add Alternative Follower When Receiving Message While On Time Off', + "description": """ + Add Alternative Follower When Receiving Message While On Time Off + """, + "author": "Bemade", + 'website': 'https://www.bemade.org', + "depends": [ + 'hr_holidays', + 'mail' + ], + "data": [ + 'views/hr_leave_views.xml', + ], + "auto_install": False, + "installable": True, + 'license': 'OPL-1' +} diff --git a/bemade_time_off_follower/models/__init__.py b/bemade_time_off_follower/models/__init__.py new file mode 100644 index 0000000..fa00ea0 --- /dev/null +++ b/bemade_time_off_follower/models/__init__.py @@ -0,0 +1,2 @@ +from . import hr_leave +from . import mail_thread diff --git a/bemade_time_off_follower/models/hr_leave.py b/bemade_time_off_follower/models/hr_leave.py new file mode 100644 index 0000000..09750a0 --- /dev/null +++ b/bemade_time_off_follower/models/hr_leave.py @@ -0,0 +1,10 @@ +from odoo import models, fields, api + +class HrLeave(models.Model): + _inherit = 'hr.leave' + + alternate_follower_id = fields.Many2one( + comodel_name='res.users', + string='Alternate Follower', + help='User who will receive a copy of all communications related to this leave', + ) diff --git a/bemade_time_off_follower/models/mail_thread.py b/bemade_time_off_follower/models/mail_thread.py new file mode 100644 index 0000000..8186da4 --- /dev/null +++ b/bemade_time_off_follower/models/mail_thread.py @@ -0,0 +1,39 @@ +from odoo import models, api, fields +import logging + +_logger = logging.getLogger(__name__) + + +class MailThread(models.AbstractModel): + _inherit = 'mail.thread' + + def _notify_compute_recipients(self, message, msg_vals): + recipients = super(MailThread, self)._notify_compute_recipients(message, msg_vals) + + for recipient in recipients: + user = self.env['res.users'].search([('partner_id', '=', recipient['id'])], limit=1) + if user: + employee = self.env['hr.employee'].search([('user_id', '=', user.id)], limit=1) + + if employee: + employee_id = employee.id + leaves = self.sudo().env['hr.leave'].search([ + ('state', '=', 'validate'), + ('date_from', '<=', fields.Date.today()), + ('employee_id', '=', employee_id), + ('date_to', '>=', fields.Date.today()), + ]) + for leave in leaves: + if leave.employee_id.user_id.partner_id.id == recipient['id'] and leave.alternate_follower_id: + _logger.info( + f"adding {leave.alternate_follower_id.partner_id.name} as follower for {employee.name} while on time off.") + recipients.append({ + 'id': leave.alternate_follower_id.partner_id.id, + 'active': True, + 'share': False, + 'groups': leave.alternate_follower_id.groups_id.ids, + 'notif': 'inbox', + 'type': 'user' + }) + + return recipients diff --git a/bemade_time_off_follower/views/hr_leave_views.xml b/bemade_time_off_follower/views/hr_leave_views.xml new file mode 100644 index 0000000..a992f48 --- /dev/null +++ b/bemade_time_off_follower/views/hr_leave_views.xml @@ -0,0 +1,13 @@ + + + + hr.leave.form.view.inherit + hr.leave + + + + + + + + diff --git a/bemade_user_password_bundle/__init__.py b/bemade_user_password_bundle/__init__.py new file mode 100644 index 0000000..5305644 --- /dev/null +++ b/bemade_user_password_bundle/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import models \ No newline at end of file diff --git a/bemade_user_password_bundle/__manifest__.py b/bemade_user_password_bundle/__manifest__.py new file mode 100644 index 0000000..893ccf6 --- /dev/null +++ b/bemade_user_password_bundle/__manifest__.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +{ + 'name': "User Password Bundle", + + 'summary': """ + Module to create password bundles and provide access to them for admins of a newly created user. + """, + + 'description': """ + This module automate the creation of a password bundle for new users in Odoo 15 and changes the default admin + ownership of bundle to admin/setting group instead of the bundle creator. + """, + + 'author': "Bemade", + 'website': "https://www.bemade.org", + 'category': 'Technical', + 'version': '0.1', + 'license': 'AGPL-3', + 'depends': ['odoo_password_manager'], + + 'data': [ + ], + 'demo': [ + ], +} diff --git a/bemade_user_password_bundle/models/__init__.py b/bemade_user_password_bundle/models/__init__.py new file mode 100644 index 0000000..3c6d7ca --- /dev/null +++ b/bemade_user_password_bundle/models/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- + +from . import hr_employee +from . import password_bundle diff --git a/bemade_user_password_bundle/models/hr_employee.py b/bemade_user_password_bundle/models/hr_employee.py new file mode 100644 index 0000000..6517fb9 --- /dev/null +++ b/bemade_user_password_bundle/models/hr_employee.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +from odoo import models, fields, api + + +class HrEmployee(models.Model): + _inherit = 'hr.employee' + + @api.model + def create(self, vals): + newemployee = super(HrEmployee, self).create(vals) + pw_bundle = self.env['password.bundle'].create({ + 'name': newemployee.name, + 'notes': f"Created new employee Password Bundle for {newemployee.name}" + }) + self.env['password.access'].create({ + 'bundle_id': pw_bundle.id, + 'user_id': newemployee.user_id.id, + 'access_level': 'full', + }) + return newemployee \ No newline at end of file diff --git a/bemade_user_password_bundle/models/password_bundle.py b/bemade_user_password_bundle/models/password_bundle.py new file mode 100644 index 0000000..7a93cd2 --- /dev/null +++ b/bemade_user_password_bundle/models/password_bundle.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- + +from odoo import models, fields, api + + +class password_bundle(models.Model): + _inherit = 'password.bundle' + _name = 'password.bundle' + + @api.model + def _default_access_admin_ids(self): + """ + Default method for access_ids + """ + values = { + 'access_level': 'admin', + 'group_id': self.env.ref('base.group_system').id, + } + return [(0, 0, values)] + + # BV: UGLY HACK + # I should be able to remove this field, but I can't just override the default function + # _default_access_ids, so I rename it _default_access_admin_ids and override the th field + + access_ids = fields.One2many( + "password.access", + "bundle_id", + default=_default_access_admin_ids, + ) +