Files
enterprise-addons/social_linkedin/models/social_account.py
T

363 lines
15 KiB
Python

# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import logging
import requests
from datetime import datetime, timedelta
from urllib.parse import quote
from odoo import _, models, fields, api
from odoo.exceptions import UserError
from odoo.tools.urls import urljoin as url_join
from odoo.addons.social.controllers.main import SocialValidationException
from odoo.addons.social_linkedin.utils import urn_to_id, id_to_urn
_logger = logging.getLogger(__name__)
class SocialAccount(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 = urn_to_id(social_account.linkedin_account_urn)
else:
social_account.linkedin_account_id = False
def _compute_stats_link(self):
linkedin_accounts = self._filter_by_media_types(['linkedin'])
super(SocialAccount, (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(SocialAccount, (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().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': '202511',
}
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: “%s”.', response.text))
account_ids = [
urn_to_id(organization['organization'])
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: “%s”.', 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 = urn_to_id(organization.get('logoV2', {}).get('original'))
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)
################
# External API #
################
def _linkedin_request(self, endpoint, params=None, linkedin_access_token=None,
object_ids=None, complex_object_ids=None, fields=None, method=None,
json=None, session=None, headers=None):
"""Make a request to the LinkedIn API.
:param endpoint: the endpoint to request
:param params: the GET parameters
:param linkedin_access_token: the access token to use
(if it's not yet saved on the social account)
:param object_ids: the LinkedIn objects ids to pass as GET parameters
:param complex_object_ids: some LinkedIn objects are more complex (e.g. for likes, etc)
>>> "(a:xxxx, b:yyyy)"
:param fields: the field to read on the LinkedIn model
:param method: the HTTP verb
:param json: the JSON to post
:param session: the requests session if any
:param headers: custom headers to add to the request
"""
if not linkedin_access_token:
self.ensure_one()
if method is None:
method = "POST" if json else "GET"
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 complex_object_ids:
urns = ",".join(
"(" + ",".join(f"{name}:{quote(urn)}" for name, urn in obj.items()) + ")"
for obj in complex_object_ids
)
get_params.append(f"ids=List({urns})")
if fields:
get_params.append('fields=%s' % ','.join(fields))
if get_params:
url += "?" + "&".join(get_params)
headers = headers or {}
headers.update(self._linkedin_bearer_headers(linkedin_access_token))
return (session or requests).request(
method,
url,
params=params,
json=json,
headers=headers,
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 = [id_to_urn(image_id, "li:image") for image_id in images_ids if image_id]
if not images_urns:
return {}
response = self._linkedin_request(
'images',
object_ids=images_urns,
fields=('downloadUrl',),
linkedin_access_token=linkedin_access_token,
)
return {
urn_to_id(image_urn): image_values['downloadUrl']
for image_urn, image_values in response.json().get('results', {}).items()
if 'downloadUrl' in image_values
} if response.ok else {}
def _linkedin_upload_image(self, image_data):
"""Upload an image on LinkedIn.
:param image_data: Raw bytes of the image
"""
self.ensure_one()
# 1 - Register your image to be uploaded
data = {
"initializeUploadRequest": {
"owner": self.linkedin_account_urn,
},
}
response = requests.post(
url_join(self.env['social.media']._LINKEDIN_ENDPOINT, 'images?action=initializeUpload'),
headers=self._linkedin_bearer_headers(),
json=data, timeout=10)
if not response.ok:
_logger.error('Could not upload the image: %r.', response.text)
response = response.json()
if 'value' not in response or 'uploadUrl' not in response['value']:
raise UserError(_("We could not upload your image, try reducing its size and posting it again (error: Failed during upload registering)."))
# 2 - Upload image binary file
upload_url = response['value']['uploadUrl']
image_urn = response['value']['image']
headers = self._linkedin_bearer_headers()
headers['Content-Type'] = 'application/octet-stream'
response = requests.request('POST', upload_url, data=image_data, headers=headers, timeout=15)
if not response.ok:
raise UserError(_("We could not upload your image, try reducing its size and posting it again."))
return image_urn