diff --git a/bemade_odoo_partner_scrapper/__init__.py b/bemade_odoo_partner_scrapper/__init__.py
new file mode 100644
index 0000000..5305644
--- /dev/null
+++ b/bemade_odoo_partner_scrapper/__init__.py
@@ -0,0 +1,3 @@
+# -*- coding: utf-8 -*-
+
+from . import models
\ No newline at end of file
diff --git a/bemade_odoo_partner_scrapper/__manifest__.py b/bemade_odoo_partner_scrapper/__manifest__.py
new file mode 100644
index 0000000..4cbe3a7
--- /dev/null
+++ b/bemade_odoo_partner_scrapper/__manifest__.py
@@ -0,0 +1,36 @@
+# -*- coding: utf-8 -*-
+{
+ 'name': 'Bemade Odoo Partner Scrapper',
+ 'version': '17.0.0.0.0',
+ 'category': 'Administration',
+ 'summary': 'Module for scraping partners from odoo.com.',
+ 'description': """
+ This module enables the scraping of partners from odoo.com. It allows for the collection and management of partner contact information.
+
+ Main Features:
+ * Automatically scrape partners from odoo.com.
+ * Extracts partner name, website, and contact information.
+ * Manage contact information of partners.
+ """,
+ 'sequence': 10,
+ 'license': 'AGPL-3',
+ 'author': 'Bemade',
+ 'website': 'https://www.bemade.org',
+ 'depends': ['base', 'contacts', 'sale_management', 'sale' , 'partner_multi_relation'],
+ 'data': [
+ # No security
+ 'views/res_partner_views.xml',
+ # Commented for migration to 17, raises error on update
+ 'data/res_partner_relation_type.xml'
+ ],
+ # "assets": {
+ # "web.assets_backend": [
+ # "bemade_odoo_partner_scrapper/static/src/js/odoo_scrapper.js",
+ # "bemade_odoo_partner_scrapper/static/src/xml/odoo_scrapper_templates.xml",
+ # ],
+ # },
+ 'demo': [],
+ 'installable': True,
+ 'application': False,
+ 'auto_install': False
+}
diff --git a/bemade_odoo_partner_scrapper/data/demo.xml b/bemade_odoo_partner_scrapper/data/demo.xml
new file mode 100644
index 0000000..c47c023
--- /dev/null
+++ b/bemade_odoo_partner_scrapper/data/demo.xml
@@ -0,0 +1,106 @@
+
+
+
+
+
+ Is assistant of
+ Has assistant
+ p
+ p
+
+
+ Is competitor of
+ Is competitor of
+ c
+ c
+
+
+
+ Has worked for
+ Has former employee
+ p
+ c
+
+
+
+ Washing Companies
+
+
+ Washing Gold
+
+
+
+ Washing Silver
+
+
+
+ Washing Services
+
+
+
+
+ Great Washing Powder Company
+
+ 1
+ Le Bourget du Lac
+ 73377
+ +33 4 49 23 44 54
+
+ 93, Press Avenue
+ great@yourcompany.example.com
+ http://www.great.com
+
+
+ Best Washing Powder Company
+
+ 1
+ Champs sur Marne
+ 77420
+
+ best@yourcompany.example.com
+ +33 1 64 61 04 01
+ 12 rue Albert Einstein
+ http://www.best.com/
+
+
+ Super Washing Powder Company
+
+ 1
+ 3rd Floor, Room 3-C,
+ Carretera Panamericana, Km 1, Urb. Delgado Chalbaud
+ Caracas
+ 1090
+ super@yourcompany.example.com
+ +58 212 681 0538
+
+ super.com
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/bemade_odoo_partner_scrapper/data/res_partner_relation_type.xml b/bemade_odoo_partner_scrapper/data/res_partner_relation_type.xml
new file mode 100644
index 0000000..fe644ad
--- /dev/null
+++ b/bemade_odoo_partner_scrapper/data/res_partner_relation_type.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+ Is Odoo Partner Of
+ Is Odoo Client Of
+
+
+
+
+
+
+
+ Odoo Partner
+
+
+ Learning Partner
+
+
+
+ Ready Partner
+
+
+
+ Silver Partner
+
+
+
+ Gold Partner
+
+
+
diff --git a/bemade_odoo_partner_scrapper/models/__init__.py b/bemade_odoo_partner_scrapper/models/__init__.py
new file mode 100644
index 0000000..cd5f4d8
--- /dev/null
+++ b/bemade_odoo_partner_scrapper/models/__init__.py
@@ -0,0 +1,3 @@
+# -*- coding: utf-8 -*-
+
+from . import res_partner
\ No newline at end of file
diff --git a/bemade_odoo_partner_scrapper/models/res_partner.py b/bemade_odoo_partner_scrapper/models/res_partner.py
new file mode 100644
index 0000000..b991d0d
--- /dev/null
+++ b/bemade_odoo_partner_scrapper/models/res_partner.py
@@ -0,0 +1,264 @@
+from odoo import models, fields, api, _
+import requests
+from bs4 import BeautifulSoup
+import logging
+from base64 import b64encode
+import re
+
+_logger = logging.getLogger(__name__)
+
+
+class ResPartner(models.Model):
+ _inherit = 'res.partner'
+
+ is_odoo_partner = fields.Boolean(string="Is Odoo Partner", default=False, tracking=True)
+ is_odoo_user = fields.Boolean(string="Is Odoo User", default=False, tracking=True)
+ odoo_name = fields.Char(string="Name on Odoo")
+ odoo_note = fields.Html(string="Note on Odoo")
+ odoo_id = fields.Char(string="ID on Odoo")
+ odoo_url = fields.Char(string="Odoo Partner URL", tracking=True, index=True)
+ odoo_page = fields.Html(string="Odoo Partner/Referral Page")
+ odoo_page_update = fields.Date(string="Odoo Page Update")
+ odoo_partner_type = fields.Selection(
+ [('learning', 'Learning'),
+ ('ready', 'Ready'),
+ ('silver', 'Silver'),
+ ('gold', 'Gold')],
+ string="Partner Type",
+ tracking=True)
+
+ @api.model
+ def change_color_on_kanban(self):
+ for record in self:
+ color = 0
+ if record.odoo_partner_type == 'learning':
+ color = 2
+ elif record.odoo_partner_type == 'ready':
+ color = 10
+ elif record.odoo_partner_type == 'silver':
+ color = 7
+ elif record.odoo_partner_type == 'gold':
+ color = 3
+ else:
+ if record.is_odoo_user:
+ color = 4
+ record.color = color
+
+ color = fields.Integer('Color Index', compute="change_color_on_kanban")
+
+ def set_image_from_url(self, url):
+ response = requests.get(url)
+ if response.status_code == 200:
+ self.image_1920 = b64encode(response.content).decode('utf-8')
+ _logger.info(f"Image for {self.name} set from {url}")
+ else:
+ raise ValueError('Could not download image: got response code {}'.format(response.status_code))
+
+ @api.model
+ def get_odoo_partner(self):
+
+ def extract_client_data(soup):
+ odoo_name = soup.find(id='partner_name').text
+ odoo_note = soup.find('div', class_='col-lg-8 mt32')
+
+ main_image = soup.find('img', {'class': 'img img-fluid d-block mx-auto mb16'})
+ image_link = main_image['src']
+
+ exist_client = self.env['res.partner'].search([('odoo_name', '=', odoo_name)])
+
+ if not exist_client:
+ exist_client = exist_client.create({
+ 'name': odoo_name,
+ 'is_company': True,
+ 'odoo_name':odoo_name,
+ 'odoo_note':odoo_note,
+ 'is_odoo_user': True,
+ 'odoo_page': soup,
+ })
+ exist_client.set_image_from_url(image_link)
+ _logger.info(f"Client {exist_client.name} created")
+
+ return exist_client
+
+ def process_partner_relation(partner, client):
+ if partner.id != client.id:
+ self.env['res.partner.relation'].create({
+ 'left_partner_id' : partner.id,
+ 'right_partner_id' : client.id,
+ 'type_id' : self.env.ref('bemade_odoo_partner_scrapper.rel_type_odoo_partner').id
+ })
+ _logger.info(f"Partner {partner.name} Odoo Partner of {client.name}")
+ else:
+ _logger.info(f"Partner {partner.name} is the same as client {client.name}")
+
+ def extract_partner_data(url):
+ page = requests.get("https://www.odoo.com" + url)
+ soup = BeautifulSoup(page.content, 'html.parser')
+
+ partner_data = {}
+
+ partner_data['name'] = soup.find(id="partner_name").text
+ partner_type = soup.find('h3', class_='col-lg-12 text-center text-muted')
+
+ if partner_type and partner_type.span:
+ partner_data['odoo_partner_type'] = partner_type.span.text.strip().lower()
+
+ partner_data['odoo_name'] = soup.find(id="partner_name").text
+ address_div = soup.find('span', itemprop='streetAddress')
+ email_div = soup.find('span', itemprop='email')
+ phone_div = soup.find('span', itemprop='telephone')
+ website_div = soup.find('span', itemprop='website')
+
+ main_image = soup.find('img', {'class': 'img img-fluid d-block mx-auto mb16'})
+ image_link = main_image['src']
+
+ clients_url = set()
+ for link in soup.find_all('a'):
+ href = link.get('href')
+ if href is not None:
+ # Ignore any URL containing 'country/canada-36'
+ if '/customers/' in href:
+ # Add to set. Duplicates will be ignored automatically.
+ clients_url.add(href)
+
+ state_data = None
+
+ if address_div is not None:
+ # Extract address components
+ address_parts = address_div.get_text(separator="\n").split("\n")
+ partner_data['street'] = address_parts[0]
+ city_state_postal = address_parts[len(address_parts) - 2].split(', ')
+
+ if city_state_postal != [',']:
+ partner_data['city'] = city_state_postal[0]
+ state_postal = city_state_postal[1].split(' ')
+ if len(state_postal) == 3 and len(state_postal[2]) == 3:
+ partner_data['zip'] = state_postal[1] + state_postal[2]
+ state_data = state_postal[0]
+ elif len(state_postal) == 2 and state_postal[1]:
+ if len(state_postal[1]) == 6:
+ partner_data['zip'] = state_postal[1]
+ state_data = state_postal[0]
+ if len(state_postal[1]) == 3:
+ if len(state_postal[0]) == 2:
+ partner_data['zip'] = state_postal[1]
+ state_data = state_postal[0]
+ else:
+ partner_data['zip'] = state_postal[0] + state_postal[1]
+ else:
+ if len(state_postal[0]) == 6:
+ partner_data['zip'] = state_postal[0]
+ else:
+ state_data = state_postal[0]
+
+ # partner_data['country'] = address_parts[len(address_parts) - 1]
+
+ partner_data['email'] = email_div.string if email_div and email_div.string else False
+ partner_data['phone'] = phone_div.string if phone_div and phone_div.string else False
+ partner_data['website'] = website_div.string if website_div and website_div.string else False
+ partner_data['is_odoo_partner'] = True
+ partner_data['is_odoo_user'] = True
+ partner_data['is_company'] = True
+ partner_data['image_link'] = image_link
+ partner_data['clients_url'] = clients_url
+
+ if state_data:
+ country_id = self.env['res.country'].search([('code', '=', 'CA')]).id
+ partner_data['state_id'] = self.env['res.country.state'].search([('code', '=', state_data),('country_id', '=', country_id)]).id
+
+ partner_data = {k: v for k, v in partner_data.items() if v is not False}
+
+ return partner_data
+
+ # Load the webpage at the given url
+ page = requests.get("https://www.odoo.com/fr_FR/partners/country/canada-36")
+ # Parse the HTML content
+ soup = BeautifulSoup(page.content, 'html.parser')
+ div = soup.find(id="ref_content")
+ partners_url = set()
+ other_pages = set()
+
+ for link in div.find_all('a'):
+ href = link.get('href')
+ if href is not None:
+ # Ignore any URL containing 'country/canada-36'
+ if '/page/' in href:
+ # Add to set. Duplicates will be ignored automatically.
+ other_pages.add(href)
+
+ for link in div.find_all('a'):
+ href = link.get('href')
+ if href is not None and href != '':
+ # Ignore any URL containing 'country/canada-36'
+ if 'country/canada-36' not in href:
+ # Add to set. Duplicates will be ignored automatically.
+ partners_url.add(href.split('#', 1)[0].split('?', 1)[0])
+
+ for other_page in other_pages:
+ page = requests.get("https://www.odoo.com" + other_page)
+ soup = BeautifulSoup(page.content, 'html.parser')
+ div = soup.find(id="ref_content")
+ for link in div.find_all('a'):
+ href = link.get('href')
+ if href is not None and href != '':
+ # Ignore any URL containing 'country/canada-36'
+ if 'country/canada-36' not in href:
+ # Add to set. Duplicates will be ignored automatically.
+ partners_url.add(href.split('#', 1)[0].split('?', 1)[0])
+
+ for partner_url in partners_url:
+ # page = requests.get("https://www.odoo.com" + partner_url)
+ # soup = BeautifulSoup(page.content, 'html.parser')
+
+ odoo_partner = extract_partner_data(partner_url)
+ _logger.info(f"Processing {odoo_partner['name']}")
+
+ image_url = False
+ if odoo_partner['image_link']:
+ image_url = odoo_partner['image_link']
+ del odoo_partner['image_link']
+ _logger.info(f"Image for {odoo_partner['name']} set from {image_url}")
+
+ if odoo_partner['clients_url']:
+ clients_url = odoo_partner['clients_url']
+ else:
+ clients_url = set()
+
+ if 'clients_url' in odoo_partner:
+ del odoo_partner['clients_url']
+
+ exist_odoo_name = self.env['res.partner'].search([('odoo_name', '=', odoo_partner['odoo_name'])])
+ if exist_odoo_name:
+ _logger.info(f"Partner Odoo Name {odoo_partner['name']} already exist")
+ new_partner = exist_odoo_name
+ else:
+ exist_email = self.env['res.partner'].search([('email', '=', odoo_partner['email'])]) if 'email' in odoo_partner else False
+ if exist_email:
+ _logger.info(f"Partner Email {odoo_partner['name']} already exist")
+ new_partner = exist_email
+ else:
+ exist_phone = self.env['res.partner'].search([('phone', '=', odoo_partner['phone'])]) if 'phone' in odoo_partner else False
+ if exist_phone:
+ _logger.info(f"Partner Phone {odoo_partner['name']} already exist")
+ new_partner = exist_phone
+ else:
+ _logger.info(f"Partner {odoo_partner['name']} created")
+ new_partner = self.env['res.partner'].create(odoo_partner)
+ if image_url:
+ new_partner.set_image_from_url(image_url)
+
+ for client_url in clients_url:
+ if 'http' not in client_url:
+ exist_url = self.env['res.partner'].search([('odoo_url', '=', client_url)])
+ # add condition on odoo_date, actually not pulling data if date exist
+ if exist_url and exist_url.odoo_page:
+ soup = exist_url.odoo_page
+ _logger.info(f"Client {exist_url.name} reloading soup from db")
+ else:
+ print("https://www.odoo.com" + client_url)
+ page = requests.get("https://www.odoo.com" + client_url)
+ soup = BeautifulSoup(page.content, 'html.parser')
+ _logger.info(f"Client {exist_url.name} read from odoo.com")
+
+ client = extract_client_data(soup)
+ process_partner_relation(new_partner, client)
diff --git a/bemade_odoo_partner_scrapper/static/src/js/odoo_scrapper.js b/bemade_odoo_partner_scrapper/static/src/js/odoo_scrapper.js
new file mode 100644
index 0000000..cd7b9ef
--- /dev/null
+++ b/bemade_odoo_partner_scrapper/static/src/js/odoo_scrapper.js
@@ -0,0 +1,68 @@
+/** @odoo-module **/
+
+import ListController from 'web.ListController';
+import ListView from 'web.ListView';
+
+import KanbanController from 'web.KanbanController';
+import KanbanView from 'web.KanbanView';
+
+const viewRegistry = require('web.view_registry');
+
+const OdooScrapperListController = ListController.extend({
+ // buttons_template must match the t-name on the template for the button (static xml)
+ buttons_template: 'odoo_scrapper.list_view_buttons',
+ events: _.extend({}, ListController.prototype.events, {
+ 'click .o_button_get_partner': '_onGetPartnerClick',
+ }),
+ // This may need to be async function() if there needs to be an await this._rpc({ ... }); call to not reload early
+ _onGetPartnerClick: function () {
+ this._rpc({
+ model: 'res.partner',
+ method: 'get_odoo_partner',
+ args: [],
+ }).then(() => {
+ this.reload();
+ });
+ // Couldn't test this for real, but it runs the action. May need a this.reload()
+ },
+});
+
+export const OdooScrapperListView = ListView.extend({
+ config: _.extend({}, ListView.prototype.config, {
+ Controller: OdooScrapperListController,
+ }),
+});
+
+// key must match with the js_class attribute of the tree view you want to modify
+viewRegistry.add('res_partner_odoo_scrapper_tree', OdooScrapperListView);
+
+const OdooScrapperKanbanController = KanbanController.extend({
+ // buttons_template must match the t-name on the template for the button (static xml)
+ buttons_template: 'odoo_scrapper.kanban_view_buttons',
+ events: _.extend({}, KanbanController.prototype.events, {
+ 'click .o_button_get_partner': '_onGetPartnerClick',
+ }),
+ // This may need to be async function() if there needs to be an await this._rpc({ ... }); call to not reload early
+ _onGetPartnerClick: function () {
+ this._rpc({
+ model: 'res.partner',
+ method: 'get_odoo_partner',
+ args: [],
+ }).then(() => {
+ this.reload();
+ });
+ // Couldn't test this for real, but it runs the action. May need a this.reload()
+ },
+});
+
+export const OdooScrapperKanbanView = KanbanView.extend({
+ config: _.extend({}, KanbanView.prototype.config, {
+ Controller: OdooScrapperKanbanController,
+ }),
+});
+
+// key must match with the js_class attribute of the tree view you want to modify
+viewRegistry.add('res_partner_odoo_scrapper_kanban', OdooScrapperKanbanView);
+
+
+
diff --git a/bemade_odoo_partner_scrapper/static/src/xml/odoo_scrapper_templates.xml b/bemade_odoo_partner_scrapper/static/src/xml/odoo_scrapper_templates.xml
new file mode 100644
index 0000000..af0a1dd
--- /dev/null
+++ b/bemade_odoo_partner_scrapper/static/src/xml/odoo_scrapper_templates.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/bemade_odoo_partner_scrapper/views/res_partner_views.xml b/bemade_odoo_partner_scrapper/views/res_partner_views.xml
new file mode 100644
index 0000000..22402a4
--- /dev/null
+++ b/bemade_odoo_partner_scrapper/views/res_partner_views.xml
@@ -0,0 +1,86 @@
+
+
+
+
+ res.partner.form.inherit
+ res.partner
+
+
+
+
+ Odoo User
+ Odoo Partner
+
+
+
Partner
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ res.partner.tree.inherit
+ res.partner
+
+
+
+ res_partner_odoo_scrapper_tree
+
+
+
+
+
+
+
+
+
+
+ res.partner.kanban.inherit
+ res.partner
+
+
+
+ res_partner_odoo_scrapper_kanban
+
+
+
+
+
+ oe_kanban_global_click o_kanban_record_has_image_fill o_res_partner_kanban oe_kanban_color_#{record.color}
+
+
+
+
+
+
+
+
+
+
+ res.partner.odoo.search
+ res.partner
+
+
+
+
+
+
+
+
+
+
diff --git a/bemade_odoo_partner_scrapper_js_only/__init__.py b/bemade_odoo_partner_scrapper_js_only/__init__.py
new file mode 100644
index 0000000..40a96af
--- /dev/null
+++ b/bemade_odoo_partner_scrapper_js_only/__init__.py
@@ -0,0 +1 @@
+# -*- coding: utf-8 -*-
diff --git a/bemade_odoo_partner_scrapper_js_only/__manifest__.py b/bemade_odoo_partner_scrapper_js_only/__manifest__.py
new file mode 100644
index 0000000..5214f00
--- /dev/null
+++ b/bemade_odoo_partner_scrapper_js_only/__manifest__.py
@@ -0,0 +1,33 @@
+# -*- coding: utf-8 -*-
+{
+ 'name': 'Bemade Odoo Partner Scrapper JS Only',
+ 'version': '17.0.1.0.0',
+ 'category': 'Administration',
+ 'summary': 'Module for scraping partners from odoo.com.',
+ 'description': """
+ This module enables the scraping of partners from odoo.com. It allows for the collection and management of partner contact information.
+
+ Main Features:
+ * Automatically scrape partners from odoo.com.
+ * Extracts partner name, website, and contact information.
+ * Manage contact information of partners.
+ """,
+ 'license': 'GPL-3',
+ 'author': 'Bemade',
+ 'website': 'https://www.bemade.org',
+ 'depends': [
+ 'bemade_odoo_partner_scrapper'
+ ],
+ 'data': [
+ ],
+ "assets": {
+ "web.assets_backend": [
+ "bemade_odoo_partner_scrapper/static/src/js/odoo_scrapper.js",
+ "bemade_odoo_partner_scrapper/static/src/xml/odoo_scrapper_templates.xml",
+ ],
+ },
+ 'demo': [],
+ 'installable': True,
+ 'application': False,
+ 'auto_install': False
+}
diff --git a/caldav_sync/models/calendar_event.py b/caldav_sync/models/calendar_event.py
index f228952..295f900 100644
--- a/caldav_sync/models/calendar_event.py
+++ b/caldav_sync/models/calendar_event.py
@@ -6,7 +6,6 @@ from datetime import datetime, timezone
from icalendar import Calendar, Event, vCalAddress, vText, vWeekday
from bs4 import BeautifulSoup
from datetime import timedelta
-import dateutil
import re
from pytz import timezone, utc
@@ -320,7 +319,6 @@ class CalendarEvent(models.Model):
end = component.decoded('dtend')
if isinstance(end, datetime):
end = end.astimezone(utc).replace(tzinfo=None)
-
values = {
'name': str(component.get('summary')),
'start': start,
@@ -339,12 +337,27 @@ class CalendarEvent(models.Model):
self.with_context({'caldav_no_sync': True}).create(values)
else:
_logger.info(f"Updating with vals: {values}")
- if recurrency_vals and recurrency_vals.get('recurrency'):
+ changed_vals = {}
+ # Don't update partner_ids if no change
+ if attendee_ids - existing_instance.partner_ids:
+ changed_vals.update({
+ 'partner_ids',values.pop('partner_ids'),
+ })
+
+ # Don't write values that haven't changed
+ for key, val in values.items():
+ if getattr(existing_instance, key) != val:
+ changed_vals.update({key: values.pop(key)})
+ if (recurrency_vals and recurrency_vals.get('recurrency')
+ and (not existing_instance.recurrency or not
+ existing_instance.follow_recurrence)):
existing_instance.write({
'recurrency': True,
'follow_recurrence': True,
})
- existing_instance.with_context({'caldav_no_sync': True}).write(values)
+ existing_instance.with_context({
+ 'caldav_no_sync': True,
+ }).write(changed_vals)
@staticmethod
def _extract_component_text(component, subcomponent_name):