forked from Mapan/odoo17e
298 lines
12 KiB
Python
298 lines
12 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
import base64
|
|
import requests
|
|
from datetime import datetime, timedelta
|
|
from urllib.parse import quote
|
|
from werkzeug.urls import url_join
|
|
|
|
from odoo import _, models, fields, api
|
|
from odoo.addons.social.controllers.main import SocialValidationException
|
|
|
|
|
|
class SocialAccountLinkedin(models.Model):
|
|
_inherit = 'social.account'
|
|
|
|
linkedin_account_urn = fields.Char('LinkedIn Account URN', readonly=True, help='LinkedIn Account URN')
|
|
linkedin_account_id = fields.Char('LinkedIn Account ID', compute='_compute_linkedin_account_id')
|
|
linkedin_access_token = fields.Char('LinkedIn access token', readonly=True, help='The access token is used to perform request to the REST API')
|
|
|
|
@api.depends('linkedin_account_urn')
|
|
def _compute_linkedin_account_id(self):
|
|
"""Depending on the used LinkedIn endpoint, we sometimes need the full URN, sometimes only the ID part.
|
|
|
|
e.g.: "urn:li:person:12365" -> "12365"
|
|
"""
|
|
for social_account in self:
|
|
if social_account.linkedin_account_urn:
|
|
social_account.linkedin_account_id = social_account.linkedin_account_urn.split(':')[-1]
|
|
else:
|
|
social_account.linkedin_account_id = False
|
|
|
|
def _compute_stats_link(self):
|
|
linkedin_accounts = self._filter_by_media_types(['linkedin'])
|
|
super(SocialAccountLinkedin, (self - linkedin_accounts))._compute_stats_link()
|
|
|
|
for account in linkedin_accounts:
|
|
account.stats_link = 'https://www.linkedin.com/company/%s/admin/analytics/visitors/' % account.linkedin_account_id
|
|
|
|
def _compute_statistics(self):
|
|
linkedin_accounts = self._filter_by_media_types(['linkedin'])
|
|
super(SocialAccountLinkedin, (self - linkedin_accounts))._compute_statistics()
|
|
|
|
for account in linkedin_accounts:
|
|
all_stats_dict = account._compute_statistics_linkedin()
|
|
month_stats_dict = account._compute_statistics_linkedin(last_30d=True)
|
|
# compute trend
|
|
for stat_name in list(all_stats_dict.keys()):
|
|
all_stats_dict['%s_trend' % stat_name] = self._compute_trend(all_stats_dict.get(stat_name, 0), month_stats_dict.get(stat_name, 0))
|
|
# store statistics
|
|
account.write(all_stats_dict)
|
|
|
|
def _linkedin_fetch_followers_count(self):
|
|
"""Fetch number of followers from the LinkedIn API."""
|
|
self.ensure_one()
|
|
endpoint = url_join(self.env['social.media']._LINKEDIN_ENDPOINT, 'networkSizes/urn:li:organization:%s' % self.linkedin_account_id)
|
|
# removing X-Restli-Protocol-Version header for this endpoint as it is not required according to LinkedIn Doc.
|
|
# using this header with an endpoint that doesn't support it will cause the request to fail
|
|
headers = self._linkedin_bearer_headers()
|
|
headers.pop('X-Restli-Protocol-Version', None)
|
|
response = requests.get(
|
|
endpoint,
|
|
params={'edgeType': 'COMPANY_FOLLOWED_BY_MEMBER'},
|
|
headers=headers,
|
|
timeout=3)
|
|
if response.status_code != 200:
|
|
return 0
|
|
return response.json().get('firstDegreeSize', 0)
|
|
|
|
def _compute_statistics_linkedin(self, last_30d=False):
|
|
"""Fetch statistics from the LinkedIn API.
|
|
|
|
:param last_30d: If `True`, return the statistics of the last 30 days
|
|
Else, return the statistics of all the time.
|
|
|
|
If we want statistics for the month, we need to choose the granularity
|
|
"month". The time range has to be bigger than the granularity and
|
|
if we have result over 1 month and 1 day (e.g.), the API will return
|
|
2 results (one for the month and one for the day).
|
|
To avoid this, we simply move the end date in the future, so we have
|
|
result only for this month, in one simple dict.
|
|
"""
|
|
self.ensure_one()
|
|
|
|
endpoint = url_join(self.env['social.media']._LINKEDIN_ENDPOINT, 'organizationalEntityShareStatistics')
|
|
params = {
|
|
'q': 'organizationalEntity',
|
|
'organizationalEntity': self.linkedin_account_urn
|
|
}
|
|
|
|
if last_30d:
|
|
# The LinkedIn API take timestamp in milliseconds
|
|
end = int((datetime.now() + timedelta(days=2)).timestamp() * 1000)
|
|
start = int((datetime.now() - timedelta(days=30)).timestamp() * 1000)
|
|
endpoint += '?timeIntervals=%s' % '(timeRange:(start:%i,end:%i),timeGranularityType:MONTH)' % (start, end)
|
|
|
|
response = requests.get(
|
|
endpoint,
|
|
params=params,
|
|
headers=self._linkedin_bearer_headers(),
|
|
timeout=5)
|
|
|
|
if response.status_code != 200:
|
|
return {}
|
|
|
|
data = response.json().get('elements', [{}])[0].get('totalShareStatistics', {})
|
|
|
|
return {
|
|
'audience': self._linkedin_fetch_followers_count(),
|
|
'engagement': data.get('clickCount', 0) + data.get('likeCount', 0) + data.get('commentCount', 0),
|
|
'stories': data.get('shareCount', 0) + data.get('shareMentionsCount', 0),
|
|
}
|
|
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
res = super(SocialAccountLinkedin, self).create(vals_list)
|
|
|
|
linkedin_accounts = res.filtered(lambda account: account.media_type == 'linkedin')
|
|
if linkedin_accounts:
|
|
linkedin_accounts._create_default_stream_linkedin()
|
|
|
|
return res
|
|
|
|
def _linkedin_bearer_headers(self, linkedin_access_token=None):
|
|
if linkedin_access_token is None:
|
|
linkedin_access_token = self.linkedin_access_token
|
|
return {
|
|
'Authorization': 'Bearer %s' % linkedin_access_token,
|
|
'cache-control': 'no-cache',
|
|
'X-Restli-Protocol-Version': '2.0.0',
|
|
'LinkedIn-Version': '202401',
|
|
}
|
|
|
|
def _get_linkedin_accounts(self, linkedin_access_token):
|
|
"""Make an API call to get all LinkedIn pages linked to the actual access token."""
|
|
response = self._linkedin_request(
|
|
'organizationAcls',
|
|
params={
|
|
'q': 'roleAssignee',
|
|
'role': 'ADMINISTRATOR',
|
|
'state': 'APPROVED',
|
|
},
|
|
linkedin_access_token=linkedin_access_token,
|
|
)
|
|
if not response.ok:
|
|
raise SocialValidationException(_('An error occurred when fetching your pages: %r.', response.text))
|
|
|
|
account_ids = [
|
|
organization['organization'].split(':')[-1]
|
|
for organization in response.json().get('elements', [])
|
|
]
|
|
|
|
response = self._linkedin_request(
|
|
'organizations',
|
|
object_ids=account_ids,
|
|
fields=('id', 'name', 'localizedName', 'vanityName', 'logoV2:(original)'),
|
|
linkedin_access_token=linkedin_access_token,
|
|
)
|
|
if not response.ok:
|
|
raise SocialValidationException(_('An error occurred when fetching your pages data: %r.', response.text))
|
|
|
|
organization_results = response.json().get('results', {})
|
|
|
|
images_urns = [
|
|
values.get('logoV2', {}).get('original')
|
|
for values in organization_results.values()
|
|
]
|
|
image_url_by_id = self._linkedin_request_images(images_urns, linkedin_access_token)
|
|
|
|
accounts = []
|
|
for account_id, organization in organization_results.items():
|
|
image_id = organization.get('logoV2', {}).get('original', '').split(':')[-1]
|
|
image_url = image_id and image_url_by_id.get(image_id)
|
|
image_data = image_url and requests.get(image_url, timeout=10).content
|
|
accounts.append({
|
|
'name': organization.get('localizedName'),
|
|
'linkedin_account_urn': f"urn:li:organization:{account_id}",
|
|
'linkedin_access_token': linkedin_access_token,
|
|
'social_account_handle': organization.get('vanityName'),
|
|
'image': base64.b64encode(image_data) if image_data else False,
|
|
})
|
|
|
|
return accounts
|
|
|
|
def _create_linkedin_accounts(self, access_token, media):
|
|
linkedin_accounts = self._get_linkedin_accounts(access_token)
|
|
if not linkedin_accounts:
|
|
message = _('You need a Business Account to post on LinkedIn with Odoo Social.\n Please create one and make sure it is linked to your account')
|
|
documentation_link = 'https://business.linkedin.com/marketing-solutions/linkedin-pages'
|
|
documentation_link_label = _('Read More about Business Accounts')
|
|
documentation_link_icon_class = 'fa fa-linkedin'
|
|
raise SocialValidationException(message, documentation_link, documentation_link_label, documentation_link_icon_class)
|
|
|
|
social_accounts = self.sudo().with_context(active_test=False).search([
|
|
('media_id', '=', media.id),
|
|
('linkedin_account_urn', 'in', [l.get('linkedin_account_urn') for l in linkedin_accounts])])
|
|
|
|
error_message = social_accounts._get_multi_company_error_message()
|
|
if error_message:
|
|
raise SocialValidationException(error_message)
|
|
|
|
existing_accounts = {
|
|
account.linkedin_account_urn: account
|
|
for account in social_accounts
|
|
if account.linkedin_account_urn
|
|
}
|
|
|
|
accounts_to_create = []
|
|
for account in linkedin_accounts:
|
|
if account['linkedin_account_urn'] in existing_accounts:
|
|
existing_accounts[account['linkedin_account_urn']].write({
|
|
'active': True,
|
|
'linkedin_access_token': account.get('linkedin_access_token'),
|
|
'social_account_handle': account.get('username'),
|
|
'is_media_disconnected': False,
|
|
'image': account.get('image')
|
|
})
|
|
else:
|
|
account.update({
|
|
'media_id': media.id,
|
|
'is_media_disconnected': False,
|
|
'has_trends': True,
|
|
'has_account_stats': True,
|
|
})
|
|
accounts_to_create.append(account)
|
|
|
|
self.create(accounts_to_create)
|
|
|
|
def _create_default_stream_linkedin(self):
|
|
"""Create a stream for each organization page."""
|
|
page_posts_stream_type = self.env.ref('social_linkedin.stream_type_linkedin_company_post')
|
|
|
|
streams_to_create = [{
|
|
'media_id': account.media_id.id,
|
|
'stream_type_id': page_posts_stream_type.id,
|
|
'account_id': account.id
|
|
} for account in self if account.linkedin_account_urn]
|
|
|
|
if streams_to_create:
|
|
self.env['social.stream'].create(streams_to_create)
|
|
|
|
def _extract_linkedin_picture_url(self, json_data):
|
|
# TODO: remove in master
|
|
return ''
|
|
|
|
################
|
|
# External API #
|
|
################
|
|
|
|
def _linkedin_request(self, endpoint, params=None, linkedin_access_token=None,
|
|
object_ids=None, fields=None, method="GET", json=None):
|
|
if not linkedin_access_token:
|
|
self.ensure_one()
|
|
|
|
url = url_join(self.env['social.media']._LINKEDIN_ENDPOINT, endpoint)
|
|
|
|
# need to be added manually, so requests doesn't escape them
|
|
get_params = []
|
|
if object_ids:
|
|
get_params.append("ids=List(%s)" % ','.join(map(quote, object_ids)))
|
|
if fields:
|
|
get_params.append('fields=%s' % ','.join(fields))
|
|
if get_params:
|
|
url += "?" + "&".join(get_params)
|
|
|
|
return requests.request(
|
|
method,
|
|
url,
|
|
params=params,
|
|
json=json,
|
|
headers=self._linkedin_bearer_headers(linkedin_access_token),
|
|
timeout=5,
|
|
)
|
|
|
|
def _linkedin_request_images(self, images_ids, linkedin_access_token=None):
|
|
"""Make an API call to get the downloadable URL of the images.
|
|
|
|
:param images_ids: Image ids (li:image or digital asset)
|
|
:param linkedin_access_token: Access token to use
|
|
"""
|
|
images_urns = [
|
|
f"urn:li:image:{images_id.split(':')[-1]}"
|
|
for images_id in images_ids
|
|
if images_id
|
|
]
|
|
if not images_urns:
|
|
return {}
|
|
response = self._linkedin_request(
|
|
'images',
|
|
object_ids=images_urns,
|
|
fields=('downloadUrl',),
|
|
linkedin_access_token=linkedin_access_token,
|
|
)
|
|
return {
|
|
image_urn.split(':')[-1]: image_values['downloadUrl']
|
|
for image_urn, image_values in response.json().get('results', {}).items()
|
|
} if response.ok else {}
|