diff --git a/requirements.txt b/requirements.txt index 154935992a..95edff88ac 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ # generated from manifests external_dependencies cairosvg +linkedin-api-client lottie python-telegram-bot requests_toolbelt diff --git a/social_media_linkedin/README.rst b/social_media_linkedin/README.rst new file mode 100644 index 0000000000..687faf8afd --- /dev/null +++ b/social_media_linkedin/README.rst @@ -0,0 +1,293 @@ +===================== +Social Media Linkedin +===================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:5b02242f5dbe90213a92d0d2632daa7f4c6b7553a8057302a5ab4f1f79657f90 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fsocial-lightgray.png?logo=github + :target: https://github.com/OCA/social/tree/18.0/social_media_linkedin + :alt: OCA/social +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/social-18-0/social-18-0-social_media_linkedin + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/social&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module provides the necessary functionality for basic interaction +with the LinkedIn social network. + +Main features: + +- User account integration. +- Post creation. +- Post reactions (likes, comments). +- Comment reactions (likes) +- Reports and graphs with agnostic metrics. + +Statistics account +------------------ + +1. The eye icon: Total number of views, which may include multiple views + by the same user. + +2. The hand icon: means the impressions (clicks, likes, comments, + shares) that the posts created historically on the account have had. + +3. The star icon: means the value of the engagement of the publications, + it is a calculation similar to (interactions / impressions) \* 100, + that is, a percentage. + + |STATISTICS_ACCOUNT| + +.. |STATISTICS_ACCOUNT| image:: https://raw.githubusercontent.com/social_media_linkedin/static/img/readme/STATISTICS_ACCOUNT.png + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +To configure this module, you need to: +-------------------------------------- + +Please note that you must have a developer account. The steps required +for using it are defined below: + +Before creating this developer account, you must have a partner company +on your LinkedIn account and be an administrator. + +- Go to https://developer.linkedin.com/ + +- Create a new Developer App. + +- Fill in the requested fields. In the *Privacy Policy URL* field, copy + your company's URL. + + |FORM_CREATE_APP| + +- Once the app is created, go to the *My Apps* menu and you will see the + newly created app; select it. + +- Within the app, in the *Settings* tab, verify your company. You will + see a button that says Verify. Click it, and in the window that + appears in the lower left corner, click the *Generate URL* button. + Copy the generated URL into your browser and accept. + +- Then go to the *Products* tab and request access to the following + products (for basic and free use): + + - LinkedIn Ad Library + - Share on LinkedIn + - Advertising API + - Events Management API + - Sign In with LinkedIn using OpenID Connect + + |PRODUCTS| + + Note that some products require you to fill out a form; you must do + so, otherwise, the necessary scopes for basic use of your account will + not be enabled. + +- After requesting the aforementioned Products, go to the Auth tab and + you will see all the enabled scopes at the bottom. + +- At the top of the aforementioned tab, you will see the Client ID and + Primary Client Secret information. + +- Configure the access points for which you want to use the account. + Follow these steps: + + - Go to *Settings* > *Technical* > System Parameters. + - Search for web.base.url + - Copy the base URL and concatenate it with the endpoint. Then, in + your LinkedIn Developer Account, on the Authentication tab, in the + Authorized Redirect URLs for Your App section, add a new item. \* + Example: web.base.url: http://192.168.1.7:8017 endpoint: + /linkedin/callback linkedin_url: + http://192.168.1.7:8017/linkedin/callback + + |CONFIGURE_URL_CALLBACK| + +Registering the Client ID and Client Secret. Integration of a user account. +--------------------------------------------------------------------------- + +- Go to *Social Media* > Settings > Social Media + +- Click on the *Associate Account* button for the desired social + network. + + |ASSOCIATE_ACCOUNT| + +- A wizard will open for you to add the Client ID and Client Secret + obtained from your developer account. + + |WIZARD_ASSOCIATE_ACCOUNT| + +- By clicking the *Associate* button here, you'll be taken to a LinkedIn + authentication page. Once you validate your information, you'll be + taken to the system and the Dashboard view, where you'll see your + posts. + + |AUTHORIZE_ACCOUNT| + +- Once you have completed these steps and everything is working + correctly, you can see your account in *Social Media* > Configuration + > Accounts + +.. |FORM_CREATE_APP| image:: https://raw.githubusercontent.com/OCA/social/18.0/social_media_linkedin/static/img/readme/FORM_CREATE_APP.png +.. |PRODUCTS| image:: https://raw.githubusercontent.com/OCA/social/18.0/social_media_linkedin/static/img/readme/PRODUCTS.png +.. |CONFIGURE_URL_CALLBACK| image:: https://raw.githubusercontent.com/OCA/social/18.0/social_media_linkedin/static/img/readme/CONFIGURE_URL_CALLBACK.png +.. |ASSOCIATE_ACCOUNT| image:: https://raw.githubusercontent.com/OCA/social/18.0/social_media_linkedin/static/img/readme/ASSOCIATE_ACCOUNT.png +.. |WIZARD_ASSOCIATE_ACCOUNT| image:: https://raw.githubusercontent.com/OCA/social/18.0/social_media_linkedin/static/img/readme/WIZARD_ASSOCIATE_ACCOUNT.png +.. |AUTHORIZE_ACCOUNT| image:: https://raw.githubusercontent.com/OCA/social/18.0/social_media_linkedin/static/img/readme/AUTHORIZE_ACCOUNT.png + +Usage +===== + +List of posts generated from Odoo. +---------------------------------- + +Only posts generated using Odoo are displayed. + +- Go to *Social Media* > Post + +Generate a post. +---------------- + +This feature acts as a template for generating multiple posts from a +single view, depending on the selected accounts. + +- Go to *Social Media* > Post > New or Go to *Social Media* > Dashboard + > Add Post +- Fill in the required fields |CREATE_POST| +- Save +- Click on the *Post* button + +Update token, client ID, client Secret and organization data +------------------------------------------------------------ + +- Go to *Social Media* > Configuration > Accounts + +- Select the account + +- Click on the *Update account* button + + |BUTTON_UPDATE_ACCOUNT| + +- In the wizard that appears, if none of the checkboxes are selected and + the *Update* button is pressed, the system will update only the + organization's data. + +- If the *Update keys* checkbox is selected, the current Client ID and + Client Secret values will be displayed by default. Modify any of these + values and authentication will be performed again through LinkedIn to + update these values and the token. + + |UPDATE_KEYS| + +- Selecting the *Update token* checkbox will update the current token. + + |UPDATE_TOKEN| + +Archive Account Linkedin +------------------------ + +- Go to *Social Media* > Configuration > Accounts + +- Select the account + +- Click on the *Delete account* button + + |ARCHIVE_ACCOUNT| + +- Please note that all data associated with this account will be + archived. + +.. |CREATE_POST| image:: https://raw.githubusercontent.com/social_media_linkedin/static/img/readme/CREATE_POST.png +.. |BUTTON_UPDATE_ACCOUNT| image:: https://raw.githubusercontent.com/social_media_linkedin/static/img/readme/BUTTON_UPDATE_ACCOUNT.png +.. |UPDATE_KEYS| image:: https://raw.githubusercontent.com/social_media_linkedin/static/img/readme/UPDATE_KEYS.png +.. |UPDATE_TOKEN| image:: https://raw.githubusercontent.com/social_media_linkedin/static/img/readme/UPDATE_TOKEN.png +.. |ARCHIVE_ACCOUNT| image:: https://raw.githubusercontent.com/social_media_linkedin/static/img/readme/ARCHIVE_ACCOUNT.png + +Known issues / Roadmap +====================== + +Like the comments. +------------------ + +- To like a comment you need the scopes: + + - w_member_social_feed, + - r_organization_social_feed Which are special permissions granted by + LinkedIn + +Comments on publications +------------------------ + +- Images, videos, documents, or any type of media are not allowed in + post comments via the API, due to LinkedIn's own limitations. Although + there is an example of how to do it in the documentation, in practice + it doesn't work, even in paid versions. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* BinhexTeam + +Contributors +------------ + +- [Binhex] (https://www.binhex.cloud): + + - Edilio Escalona Almira e.escalona@binhex.cloud + +- [Trobz] (https://trobz.com/): + + - Khanh (Dinh Van) dinhvankhanhfit@gmail.com + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/social `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/social_media_linkedin/__init__.py b/social_media_linkedin/__init__.py new file mode 100644 index 0000000000..4f82eb86b8 --- /dev/null +++ b/social_media_linkedin/__init__.py @@ -0,0 +1,6 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import controllers +from . import models +from . import wizards diff --git a/social_media_linkedin/__manifest__.py b/social_media_linkedin/__manifest__.py new file mode 100644 index 0000000000..3fc51e5476 --- /dev/null +++ b/social_media_linkedin/__manifest__.py @@ -0,0 +1,38 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Social Media Linkedin", + "summary": """Integration of the LinkedIn social media.""", + "version": "18.0.1.0.0", + "license": "AGPL-3", + "author": "BinhexTeam,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/social", + "depends": [ + "social_media_base", + ], + "data": [ + "data/social_media_data.xml", + "views/social_post_account_views.xml", + "views/social_account_views.xml", + "views/utm_group_campaign_views.xml", + "views/utm_campaign_views.xml", + "wizards/wizard_social_account.xml", + ], + "assets": { + "web.assets_backend": [ + # COMPONENTS + "social_media_linkedin/static/src/components/**/*.js", + # SERVICES + "social_media_linkedin/static/src/js/services/**/*.js", + # KANBAN + "social_media_linkedin/static/src/js/views/**/*.js", + ], + }, + "external_dependencies": { + "python": [ + "linkedin-api-client", + ], + }, + "exclude": ["social"], +} diff --git a/social_media_linkedin/controllers/__init__.py b/social_media_linkedin/controllers/__init__.py new file mode 100644 index 0000000000..07bac08854 --- /dev/null +++ b/social_media_linkedin/controllers/__init__.py @@ -0,0 +1 @@ +from . import social_media_linkedin diff --git a/social_media_linkedin/controllers/social_media_linkedin.py b/social_media_linkedin/controllers/social_media_linkedin.py new file mode 100644 index 0000000000..ef55076caf --- /dev/null +++ b/social_media_linkedin/controllers/social_media_linkedin.py @@ -0,0 +1,51 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging + +from odoo import http +from odoo.http import request, route + +_logger = logging.getLogger(__name__) + + +class SocialMediaLinkedin(http.Controller): + @route( + ["/linkedin/callback"], + type="http", + auth="user", + ) + def social_linkedin(self, access_token=None, code=None, **kwargs): + SocialAccount = request.env["social.account"] + try: + client_id = None + client_secret = None + if not access_token: + ( + client_id, + client_secret, + access_token, + ) = SocialAccount.get_access_token_linkedin( + code, request.httprequest.path, kwargs + ) + SocialAccount.create_account_linkedin( + client_id, client_secret, access_token + ) + return request.redirect("/web") + except Exception as e: + # Notifying the user + SocialAccount._notify_user_client( + notif_type="social_kanban_danger", + notif_message=e, + media="linkedin", + ) + _logger.error(e) + return request.redirect("/web") + + @route( + ["/linkedin/webhook"], + type="http", + auth="user", + ) + def social_linkedin_webhook(self, **kwargs): + _logger.info(f"WEBHOOK LINKEDIN: {kwargs}") diff --git a/social_media_linkedin/data/social_media_data.xml b/social_media_linkedin/data/social_media_data.xml new file mode 100644 index 0000000000..5b50187772 --- /dev/null +++ b/social_media_linkedin/data/social_media_data.xml @@ -0,0 +1,14 @@ + + + + Linkedin + linkedin + Manage your LinkedIn account and schedule posts + + + + diff --git a/social_media_linkedin/models/__init__.py b/social_media_linkedin/models/__init__.py new file mode 100644 index 0000000000..ce73bbb830 --- /dev/null +++ b/social_media_linkedin/models/__init__.py @@ -0,0 +1,9 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import social_account +from . import social_media +from . import social_post +from . import social_post_account +from . import utm_campaign +from . import utm_group_campaign diff --git a/social_media_linkedin/models/social_account.py b/social_media_linkedin/models/social_account.py new file mode 100644 index 0000000000..ba069a6a26 --- /dev/null +++ b/social_media_linkedin/models/social_account.py @@ -0,0 +1,1315 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import base64 +import itertools +import logging +from datetime import date, datetime, timedelta + +import pytz +import requests +from dateutil.relativedelta import relativedelta +from linkedin_api.clients.restli.client import RestliClient +from werkzeug.urls import url_join, url_quote + +from odoo import api, fields, models +from odoo.exceptions import ValidationError +from odoo.tools import DEFAULT_SERVER_DATE_FORMAT + +from odoo.addons.social_media_base.social_utils import ( + _generate_timestamps, + convert_to_date, + get_weeks, + social_url_encode, +) + +from ..social_linkedin_utils import ( + _FIELDS_CAMPAIGN_LINKEDIN, + _FIELDS_STATISTIC_LINKEDIN, + _URL_AUTH_V2_LINKEDIN, + _URL_LINKEDIN, + _URL_REST_LINKEDIN, + _URL_V2_LINKEDIN, + _URN_ORGANIZATION_LINKEDIN, +) + +_logger = logging.getLogger(__name__) + + +class SocialAccount(models.Model): + _inherit = "social.account" + + linkedin_account_id = fields.Char( + compute="_compute_linkedin_account_id", store=True + ) + linkedin_account_urn = fields.Char() + refresh_token_expires_in = fields.Date() + linkedin_client_id = fields.Char(string="Client ID") + linkedin_secret = fields.Char( + string="Client Secret", + ) + + @api.model + def _get_restli_client(self): + return RestliClient() + + def _fields_account_url(self): + return super()._fields_account_url() + [ + ( + "linkedin_account_urn", + f"https://www.linkedin.com/company/{self.linkedin_account_id}/admin/dashboard/", + ) + ] + + @api.depends("linkedin_account_urn") + def _compute_linkedin_account_id(self): + for social_account in self: + if social_account.linkedin_account_urn: + social_account.linkedin_account_id = ( + social_account.linkedin_account_urn.split(":")[-1] + ) + + def unique_account(self, linkedin_client_id=None, linkedin_secret=None): + account_count = self.with_context(active_test=False).search_count( + [ + ( + "linkedin_client_id", + "=", + linkedin_client_id or self.linkedin_client_id, + ), + ("linkedin_secret", "=", linkedin_secret or self.linkedin_secret), + ] + ) + if account_count > 0: + raise ValidationError( + self.env._( + "An account with this information " + "already exists; please also check " + "archived accounts." + ) + ) + + @api.model + def _request_linkedin( + self, + method="GET", + endpoint=None, + params=None, + headers=None, + timeout=10, + linkedin_v2=False, + data=None, + token=False, + return_json=True, + json_data=None, + params_fields=None, + params_values=None, + params_values_char_ignore=None, + complete_url=False, + format_quote=False, + ): + base_url_linkedin = _URL_REST_LINKEDIN + if linkedin_v2: + base_url_linkedin = _URL_V2_LINKEDIN + elif token: + base_url_linkedin = _URL_AUTH_V2_LINKEDIN + url = base_url_linkedin + endpoint if not complete_url else complete_url + if params_fields: + url += "?" + url_params = [] + for param_field in params_fields: + url_params.append( + social_url_encode( + param_field, + params_values, + params_values_char_ignore, + format_quote, + ) + ) + url += "&".join(url_params) + response = requests.request( + method=method, + url=url, + params=params, + timeout=timeout, + headers=headers, + data=data, + json=json_data, + ) + if return_json and response.status_code == 200: + return response.json() + return response + + def update_account(self): + res = super().update_account() + if self.media_type == "linkedin": + ctx = dict(res.get("context", {})) + ctx.update( + { + "default_linkedin_client": self.linkedin_client_id, + "default_linkedin_secret": self.linkedin_secret, + } + ) + res["context"] = ctx + return res + + def _refresh_token(self): + response = self._request_linkedin( + method="POST", + endpoint="/accessToken", + token=True, + headers=self.media_id._get_linkedin_headers(), + params_fields=["grant_type", "refresh_token", "client_id", "client_secret"], + params_values={ + "grant_type": "refresh_token", + "refresh_token": self.refresh_access_token, + "client_id": self.linkedin_client_id, + "client_secret": self.linkedin_secret, + }, + ) + if isinstance(response, dict): + return response + else: + raise ValidationError(self.env._("REFRESH TOKEN: %s") % response.text) + + def _prepare_url_upload_asset(self, feedshare="image"): + try: + json_data = { + "registerUploadRequest": { + "owner": self.linkedin_account_urn, + "recipes": [f"urn:li:digitalmediaRecipe:feedshare-{feedshare}"], + "serviceRelationships": [ + { + "identifier": "urn:li:userGeneratedContent", + "relationshipType": "OWNER", + } + ], + } + } + asset = self._request_linkedin( + method="POST", + endpoint="/assets", + headers=self.media_id._get_linkedin_headers(self.access_token), + params_fields=["action"], + params_values={"action": "registerUpload"}, + json_data=json_data, + linkedin_v2=True, + ) + if not isinstance(asset, dict): + raise ValidationError( + self.env._("UPLOADING VIDEO: %(error_video)s") + % {"error_video": asset.text} + ) + else: + value_upload_asset = asset.get("value", {}) + return value_upload_asset.get("asset", {}), value_upload_asset.get( + "uploadMechanism", {} + ).get( + "com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest", {} + ).get("uploadUrl", {}) + except Exception as e: + _logger.error(e) + raise ValidationError( + self.env._("UPLOADING VIDEO: %(error_video)s") % {"error_video": str(e)} + ) from e + + def _prepare_url_upload_image(self): + image = self._request_linkedin( + method="POST", + endpoint="/images", + headers=self.media_id._get_linkedin_headers(self.access_token), + params_fields=["action"], + params_values={"action": "initializeUpload"}, + json_data={ + "initializeUploadRequest": { + "owner": self.linkedin_account_urn, + } + }, + ) + value_upload_image = image.get("value", {}) + return value_upload_image.get("image", {}), value_upload_image.get("uploadUrl") + + def _prepare_images_for_post(self, image_ids=None, image_datas=None): + images_upload = [] + if image_datas: + image_ids = [image_datas.split(",")[-1]] + for image in image_ids or []: + value_upload_asset, url_upload_asset = self._prepare_url_upload_asset() + upload_image = self._request_linkedin( + method="PUT", + complete_url=url_upload_asset, + headers=self.media_id._get_linkedin_headers( + self.access_token, content_type="application/octet-stream" + ), + data=base64.b64decode(image.datas) + if not isinstance(image, str) + else base64.b64decode(image), + linkedin_v2=True, + return_json=False, + ) + if upload_image.status_code == 201: + images_upload.append(value_upload_asset) + return images_upload + + def _prepare_videos_for_post(self, video_ids): + videos_upload = [] + for video in video_ids or []: + value_upload_asset, url_upload_asset = self._prepare_url_upload_asset( + feedshare="video" + ) + + upload_image = self._request_linkedin( + method="PUT", + complete_url=url_upload_asset, + headers=self.media_id._get_linkedin_headers( + self.access_token, content_type="application/octet-stream" + ), + data=base64.b64decode(video.datas), + linkedin_v2=True, + ) + if upload_image.status_code == 201: + videos_upload.append(value_upload_asset) + return videos_upload + + def create_restclient_linkedin(self, resource_path, message, image_ids, video_ids): + if self.access_token: + assets_image_post = self._prepare_images_for_post(image_ids) + assets_video_post = self._prepare_videos_for_post(video_ids) + medias = [] + media_category = "NONE" + if assets_image_post: + medias = [ + { + "status": "READY", + "media": asset_id, + } + for asset_id in assets_image_post + ] + media_category = "IMAGE" + elif assets_video_post: + medias = [ + { + "status": "READY", + "media": asset_id, + } + for asset_id in assets_video_post + ] + media_category = "VIDEO" + + entity_post = { + "author": f"{_URN_ORGANIZATION_LINKEDIN}{self.linkedin_account_id}", + "lifecycleState": "PUBLISHED", + "specificContent": { + "com.linkedin.ugc.ShareContent": { + "shareCommentary": {"text": message}, + "shareMediaCategory": media_category, + "media": medias, + } + }, + # PUBLIC, CONNECTIONS (Private) + "visibility": {"com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"}, + } + + if assets_image_post: + entity_post["specificContent"]["com.linkedin.ugc.ShareContent"][ + "media" + ] = [ + { + "status": "READY", + "media": asset_id, + } + for asset_id in assets_image_post + ] + + response = self._get_restli_client().create( + resource_path=resource_path, + entity=entity_post, + access_token=self.access_token, + ) + if response.status_code == 201 and response.entity_id: + return response.entity_id + return False + + def get_access_token_linkedin( + self, authorization_code, redirect_endpoint_uri, kwargs + ): + wizard_social_account = ( + self.env["wizard.social.account"] + .sudo() + .search([("csrf_state_token", "=", kwargs.get("state", ""))]) + ) + params = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": url_join(self.get_base_url(), redirect_endpoint_uri), + } + client_id = None + client_secret = None + if wizard_social_account: + client_id = wizard_social_account.linkedin_client + client_secret = wizard_social_account.linkedin_secret + params.update( + { + "client_id": client_id, + "client_secret": client_secret, + } + ) + return ( + client_id, + client_secret, + self._request_linkedin( + endpoint="/accessToken", params=params, timeout=10, token=True + ), + ) + + def get_account_linkedin(self, access_token): + response = self._request_linkedin( + endpoint="/organizationAcls", + headers=self.media_id._get_linkedin_headers(access_token), + params={"q": "roleAssignee", "role": "ADMINISTRATOR", "state": "APPROVED"}, + ) + organization_ids = ( + [ + organization["organization"].split(":")[-1] + for organization in response.get("elements", []) + ] + if not self + else [self.linkedin_account_id] + ) + + organizations_data = [] + for organization_id in organization_ids: + response_organizations = self._request_linkedin( + endpoint=f"/organizations/{organization_id}", + linkedin_v2=True, + headers=self.media_id._get_linkedin_headers(access_token), + params={ + "projection": "(id,name,vanityName," + "logoV2(original~:playableStreams))" + }, + ) + logo_binary = None + if ( + response_organizations.get("logoV2", {}) + .get("original~", {}) + .get("elements", []) + ): + elements = ( + response_organizations.get("logoV2", {}) + .get("original~", {}) + .get("elements", []) + ) + complete_url = list( + filter(lambda x: "logo_400_400" in x.get("artifact", ""), elements) + ) + if not complete_url and elements: + complete_url = elements[0] + identifiers = ( + complete_url[0].get("identifiers", {}) if complete_url else [] + ) + if len(identifiers) > 0: + media_content = self._request_linkedin( + complete_url=identifiers[0].get("identifier", False), + return_json=False, + ) + logo_binary = ( + base64.b64encode(media_content.content) + if media_content.status_code == 200 + else False + ) + + localized_name = response_organizations.get("name", {}).get("localized", {}) + organizations_data.append( + { + "id": response_organizations.get("id", False), + "localizedName": ( + localized_name.get("es_ES") + or localized_name.get("en_US") + or list(localized_name.values())[0] + ), + "vanityName": response_organizations.get("vanityName", False), + "logo": logo_binary, + } + ) + return organizations_data + + def create_account_linkedin(self, client_id, client_secret, token): + if isinstance(token, dict): + access_token = token.get("access_token", False) + if access_token: + wizard_account_id = ( + self.env["wizard.social.account"] + .sudo() + .search( + [ + ("linkedin_client", "=", client_id), + ("linkedin_secret", "=", client_secret), + ] + ) + ) + + organizations = ( + self.get_account_linkedin(access_token) + if not wizard_account_id + else wizard_account_id.account_id.get_account_linkedin(access_token) + ) + expire_token = date.today() + timedelta( + days=token.get("expires_in", 0) / 86400 + ) + expire_refresh_token = convert_to_date( + seconds=token.get( + "refresh_token_expires_in", + int(datetime.now().timestamp() * 1000), + ) + / 86400, + ) + social_account_ids = list( + map( + lambda x: x["linkedin_account_id"], + self.env["social.account"] + .sudo() + .search_read([], ["linkedin_account_id"]), + ) + ) + for organization in organizations: + values_data = { + "name": organization.get("localizedName", False), + "username": organization.get("vanityName", False), + "image_1920": organization.get("logo", False), + "linkedin_client_id": client_id, + "linkedin_secret": client_secret, + "access_token": access_token, + "refresh_access_token": token.get("refresh_token", False), + "expire_access_token_date": expire_token, + "refresh_token_expires_in": expire_refresh_token, + } + if str(organization.get("id", False)) not in social_account_ids: + linkedin_account_urn = ( + f"{_URN_ORGANIZATION_LINKEDIN}{organization.get('id')}" + ) + values_data.update( + { + "linkedin_account_id": organization.get("id"), + "linkedin_account_urn": linkedin_account_urn, + "media_id": self.env.ref( + "social_media_linkedin.social_media_linkedin" + ).id, + } + ) + self.create(values_data) + wizard_account_id.unlink() + + else: + message_error = f"Creating account: {token.text}" + raise ValidationError(message_error) + + def validate_linkedin_access_token(self, access_token): + data = { + "client_id": self.linkedin_client_id, + "client_secret": self.linkedin_secret, + "token": access_token, + } + response = self._request_linkedin( + method="POST", + endpoint="/introspectToken", + data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + token=True, + ) + if response and response.get("active", False): + return True + return False + + def validate_access_token(self): + res = super().validate_access_token() + if ( + self.media_id.id + == self.env.ref("social_media_linkedin.social_media_linkedin").id + ): + timezone = pytz.timezone(self.env.user.tz or "UTC") + ctx = dict(self.env.context) + if self.expire_access_token_date < datetime.now(tz=timezone).date(): + is_valid_token_access = self.validate_linkedin_access_token( + self.access_token or self.env.context("access_token", False) + ) + if not is_valid_token_access: + self.env["wizard.social.account"].sudo().create( + { + "account_id": self.id, + "media_id": self.media_id.id, + "linkedin_client": self.linkedin_client_id, + "linkedin_secret": self.linkedin_secret, + "update_token": True, + } + ).with_context(**ctx)._update_account() + elif not ctx.get("not_notify", False): + # Notifying the user + self._notify_user_client( + notif_type="social_form_success" + if is_valid_token_access + else "social_form_danger", + notif_message=self.env._("The token is %(token_valid)s valid.") + % {"token_valid": "not " if not is_valid_token_access else ""}, + media="linkedin", + account_name=self.name or "LINKEDIN", + ) + + return res + + def _get_posts(self, params_fields=None, params_values=None, add_values=False): + self.ensure_one() + params_field_default = ["q", "authors"] + params_value_default = { + "q": "authors", + "authors": [f"{_URN_ORGANIZATION_LINKEDIN}{self.linkedin_account_id}"], + } + if add_values: + params_fields += params_field_default + params_values.update(params_value_default) + else: + params_fields = params_field_default + params_values = params_value_default + response = self._request_linkedin( + endpoint="/ugcPosts", + headers=self.media_id._get_linkedin_headers(self.access_token), + params_fields=params_fields, + params_values=params_values, + linkedin_v2=True, + return_json=False, + ) + if response.status_code == 200: + response_ugc_posts = response.json() + if "ids" in params_fields: + ugc_posts = response_ugc_posts.get("results", []) + else: + ugc_posts = [ + { + "id": post["id"], + "share_content": post.get("specificContent", {}).get( + "com.linkedin.ugc.ShareContent", {} + ), + } + for post in response_ugc_posts.get("elements", []) + ] + else: + raise ValidationError(f"GET UGC POSTS: {response.json()}") + return ugc_posts + + def get_share_statistics( + self, + posts=None, + params_fields=None, + params_values=None, + params_values_char_ignore=None, + format_quote=None, + ): + data = {} + if not posts: + return data + share_posts = list(filter(lambda x: "urn:li:share:" in x.get("id", ""), posts)) + if share_posts: + params_fields.append("shares") + params_values.update( + { + "shares": [ + "{}".format( + ",".join(list(map(lambda val: val.get("id"), share_posts))) + ) + ] + } + ) + response = self._request_linkedin( + endpoint="/organizationalEntityShareStatistics", + headers=self.media_id._get_linkedin_headers( + access_token=self.access_token, x_restli_method="FINDER" + ), + params_fields=params_fields, + params_values=params_values, + params_values_char_ignore=params_values_char_ignore, + linkedin_v2=True, + return_json=False, + format_quote=format_quote, + ) + if response.status_code == 200: + post_reactions = response.json().get("elements", []) + data = { + post_reaction["share"]: ( + post_reaction.get("totalShareStatistics", {}).get( + "clickCount", 0 + ), + post_reaction.get("totalShareStatistics", {}).get( + "likeCount", 0 + ), + post_reaction.get("totalShareStatistics", {}).get( + "commentCount", 0 + ), + post_reaction.get("totalShareStatistics", {}).get( + "shareCount", 0 + ), + post_reaction.get("totalShareStatistics", {}).get( + "engagement", 0 + ), + post_reaction.get("totalShareStatistics", {}).get( + "impressionCount", 0 + ), + ) + for post_reaction in post_reactions + } + + else: + raise ValidationError(f"GET SHARE POSTS STATISTICS: {response.json()}") + return data + + def get_ugc_posts_statistics( + self, + posts=None, + params_fields=None, + params_values=None, + params_values_char_ignore=None, + format_quote=None, + ): + data = {} + if not posts: + return data + ugc_posts = list(filter(lambda x: "urn:li:ugcPost:" in x.get("id", ""), posts)) + if ugc_posts: + params_fields.append("ids") + params_values.update( + { + "ids": [ + "{}".format( + ",".join(list(map(lambda val: val.get("id"), ugc_posts))) + ) + ] + } + ) + response = self._request_linkedin( + endpoint="/socialActions", + headers=self.media_id._get_linkedin_headers( + access_token=self.access_token, x_restli_method="BATCH_GET" + ), + params_fields=params_fields, + params_values=params_values, + params_values_char_ignore=params_values_char_ignore, + return_json=False, + linkedin_v2=True, + format_quote=format_quote, + ) + if response.status_code == 200: + post_reactions = response.json().get("results", []) + data = { + urn_id: ( + 0, + post_reaction.get("likesSummary", {}).get("totalLikes", 0), + post_reaction.get("commentsSummary", {}).get( + "aggregatedTotalComments", 0 + ), + 0, + 0, + 0, + ) + for urn_id, post_reaction in post_reactions.items() + } + else: + raise ValidationError(f"GET UGC POSTS STATISTICS: {response.json()}") + return data + + def _get_entity_share_statistics( + self, + posts=None, + params_fields=None, + params_values=None, + params_values_char_ignore=None, + format_quote=None, + ): + if not params_fields: + params_fields = ["q", "organizationalEntity"] + if not params_values: + params_values = { + "q": "organizationalEntity", + "organizationalEntity": f"{_URN_ORGANIZATION_LINKEDIN}" + f"{self.linkedin_account_id}", + } + data = [] + if posts: + data = self.get_share_statistics( + posts=posts, + params_fields=params_fields, + params_values=params_values, + params_values_char_ignore=params_values_char_ignore, + format_quote=format_quote, + ) + params_fields.remove("shares") + params_fields.remove("q") + params_fields.remove("organizationalEntity") + params_values.pop("shares") + params_values.pop("q") + params_values.pop("organizationalEntity") + data = self.get_ugc_posts_statistics( + posts=posts, + params_fields=params_fields, + params_values=params_values, + params_values_char_ignore=params_values_char_ignore, + format_quote=format_quote, + ) + return data + + def _update_posts_statistics(self, post_id, domain): + statistics = super()._update_posts_statistics(post_id, domain) + PostAccount = self.env["social.post.account"] + if not self: + account_ids = self.search([("media_type", "=", "linkedin")]) + elif any(val.media_type == "linkedin" for val in self): + account_ids = self + else: + return statistics + try: + for account in account_ids: + account.with_context(not_notify=True).validate_access_token() + post_accounts = [] + if account.linkedin_account_id: + # POSTS + if post_id: + ugc_posts = account._get_posts( + **{ + "params_fields": ["ids"], + "params_values": {"ids": [post_id]}, + } + ) + else: + ugc_posts = account._get_posts() + PostAccount.search( + [ + ( + "linkedin_post_account_urn", + "not in", + list(map(lambda x: x["id"], ugc_posts)), + ), + ("linkedin_post_account_urn", "!=", False), + ("account_id", "=", account.id), + ] + ).write( + {"post_account_url": False, "linkedin_post_account_urn": False} + ) + # POSTS REACTIONS + post_reactions = account._get_entity_share_statistics( + posts=ugc_posts + ) + post_data_reactions = {} + if post_reactions: + post_data_reactions.update(post_reactions) + for ugc_post in ugc_posts: + post_account = PostAccount.search( + [("linkedin_post_account_urn", "=", ugc_post.get("id"))] + ) + share_content = ugc_post.get("share_content", {}) + post_id = ugc_post.get("id") + post_data_reaction = post_data_reactions.get(post_id, {}) + data = { + "linkedin_post_account_urn": post_id, + "post_account_url": f"https://www.linkedin.com/feed/update/{post_id}", + "message": share_content.get("shareCommentary", {}).get( + "text", "" + ), + "account_id": account.id, + "click_count": post_data_reaction[0] + if post_data_reaction + else 0, + "like_count": post_data_reaction[1] + if post_data_reaction + else 0, + "comment_count": post_data_reaction[2] + if post_data_reaction + else 0, + "share_count": post_data_reaction[3] + if post_data_reaction + else 0, + "engagement": round(post_data_reaction[4], 2) + if post_data_reaction + else 0, + "impression_count": post_data_reaction[5] + if post_data_reaction + else 0, + "published_date": convert_to_date( + miliseconds=ugc_post.get( + "firstPublishedAt", + int(datetime.now().timestamp() * 1000), + ), + expire_date=False, + ), + "actor_urn": ugc_post.get("created", {}).get( + "actor", False + ), + "state": "posted", + } + + attach_images = post_account._get_assets_save(share_content) + data.update({"image_ids": attach_images}) + + if not post_account: + post_accounts.append((0, 0, data)) + else: + post_accounts.append((1, post_account.id, data)) + update_account_data = { + "post_account_ids": post_accounts, + "need_update": False, + } + + entity_statistics = account._get_entity_share_statistics() + if len(entity_statistics) > 0: + share_statistics = entity_statistics[0].get( + "totalShareStatistics", {} + ) + update_account_data.update( + { + "click_count": share_statistics.get("clickCount", 0), + "like_count": share_statistics.get("likeCount", 0), + "impression_count": share_statistics.get( + "impressionCount", 0 + ), + "comment_count": share_statistics.get( + "commentCount", 0 + ), + "share_count": share_statistics.get("shareCount", 0), + "engagement": round( + share_statistics.get("engagement", 0), 2 + ), + } + ) + + account.write(update_account_data) + except Exception as ex: + self._notify_user_client( + notif_type="social_kanban_danger", + notif_message=str(ex), + media="linkedin", + account_name=self.name, + ) + return self._get_account_statistics(statistics=statistics) + + def _get_account_statistics(self, statistics=None): + data = self.search_read( + [("media_type", "=", "linkedin")], + [ + "name", + "company_id", + "media_id", + "account_url", + "impression_count", + "interactions_count", + "engagement", + "need_update", + ], + ) + if statistics: + data = list( + itertools.chain( + statistics, + data, + ) + ) + return data + + def _get_default_filter_date(self, start_date, end_date, time_date=False): + start = start_date or (datetime.now() - relativedelta(months=1)) + end = end_date or (datetime.now()) + if time_date: + return _generate_timestamps(date_start=start, date_end=end) + return start, end + + def _get_chart_account_statistics( + self, start_date=None, end_date=None, granularity="WEEK" + ): + data = super()._get_chart_account_statistics(start_date, end_date, granularity) + account_ids = self or self.search([("media_type", "=", "linkedin")]) + data_linkedin = [] + for account in account_ids: + start_date_time, end_date_time = account._get_default_filter_date( + start_date, end_date, time_date=True + ) + start_date, end_date = account._get_default_filter_date( + start_date, end_date + ) + + params_fields = ["q", "organizationalEntity", "timeIntervals", "count"] + params_values = { + "q": "organizationalEntity", + "organizationalEntity": f"{_URN_ORGANIZATION_LINKEDIN}" + f"{account.linkedin_account_id}", + "timeIntervals": f"(timeRange:(start:{start_date_time}," + f"end:{end_date_time})" + f",timeGranularityType:{granularity})", + "count": 100, + } + params_values_char_ignore = {"timeIntervals": [{"all": ":"}]} + account_share_statistics = account._get_entity_share_statistics( + params_fields=params_fields, + params_values=params_values, + params_values_char_ignore=params_values_char_ignore, + format_quote=True, + ) + freq = "W-MON" + if granularity == "DAY": + freq = "D" + elif granularity == "MONTH": + freq = "ME" + chart_weeks = get_weeks(start_date, end_date, freq=freq) + + if account_share_statistics: + + def map_chart_data(share_statistics, label, key_data="clickCount"): + dataset = { + "pointStyle": "circle", + "pointRadius": 10, + "pointHoverRadius": 15, + "label": self.env._(label), + "data": [ + share_statistic.get("totalShareStatistics", {}).get( + key_data, 0 + ) + for share_statistic in share_statistics + ], + } + return dataset + + impression_count = sum( + [ + val.get("totalShareStatistics", {}).get("impressionCount", 0) + for val in account_share_statistics + ] + ) + comment_count = sum( + [ + val.get("totalShareStatistics", {}).get("commentCount", 0) + for val in account_share_statistics + ] + ) + reaction_count = sum( + [ + val.get("totalShareStatistics", {}).get("likeCount", 0) + + val.get("totalShareStatistics", {}).get("shareCount", 0) + for val in account_share_statistics + ] + ) + data_linkedin.append( + { + "id": account.id, + "name": self.env._( + f"[{account.media_type.upper()}] {account.name}" + ), + "impressionCount": impression_count, + "commentCount": comment_count, + "reactionCount": reaction_count, + "chartLabel": self.env._("Statistics"), + "labels": [week for week in chart_weeks], + "datasets": [ + map_chart_data( + account_share_statistics, "Clicks", "clickCount" + ), + map_chart_data( + account_share_statistics, "Shares", "shareCount" + ), + map_chart_data( + account_share_statistics, "Likes", "likeCount" + ), + map_chart_data( + account_share_statistics, "Comments", "commentCount" + ), + map_chart_data( + account_share_statistics, + "Impressions", + "impressionCount", + ), + map_chart_data( + account_share_statistics, "Engagement", "engagement" + ), + ], + } + ) + return list(itertools.chain(data, data_linkedin)) + + def _get_campaigns(self, start_date=None, end_date=None, campaign_ids=None): + start_time, end_time = _generate_timestamps(start_date, end_date) + param_values = { + "q": "search", + "search": f"(startDate:(values:{start_time})," + f"endDate:(values:{end_time}),test:true)", + "fields": _FIELDS_CAMPAIGN_LINKEDIN, + "count": 100, + } + params_values_char_ignore = {"search": [{"all": ":"}]} + if campaign_ids: + search_campaign = param_values["search"].strip("()") + param_values["search"] = ( + f"({search_campaign},campaigns:(values:List({','.join(campaign_ids)})))" + ) + params_values_char_ignore = {"search": [{"1,2,3,4,5,6,7": ":"}]} + response = self._request_linkedin( + endpoint="/adCampaignsV2", + headers=self.media_id._get_linkedin_headers(self.access_token), + params_fields=["q", "search", "fields", "count"], + params_values=param_values, + params_values_char_ignore=params_values_char_ignore, + return_json=False, + linkedin_v2=True, + format_quote=True, + ) + + if response.status_code == 200: + campaigns = response.json().get("elements", []) + else: + raise ValidationError(f"GET CAMPAIGNS: {response.json()}") + return campaigns + + def _get_statistics(self, ads_ids=None, start_date=None, end_date=None): + start_date, end_date = self._get_default_filter_date(start_date, end_date) + start_date = ( + start_date.strftime(DEFAULT_SERVER_DATE_FORMAT).split("-") + if not isinstance(start_date, str) + else start_date + ) + parse_start_date = ( + f"(year:{start_date[0]},month:{int(start_date[1])}," + f"day:{int(start_date[2])})" + ) + end_date = ( + end_date.strftime(DEFAULT_SERVER_DATE_FORMAT).split("-") + if not isinstance(end_date, str) + else end_date + ) + parse_end_date = ( + f"(year:{end_date[0]},month:{int(end_date[1])},day:{int(end_date[2])})" + ) + dateStatisticsRange = f"(start:{parse_start_date},end:{parse_end_date})" + + params_fields = [ + "q", + "pivots", + "timeGranularity", + "dateRange", + "fields", + "count", + ] + params_values = { + "q": "statistics", + "pivots": ["CAMPAIGN"], + "timeGranularity": "ALL", + "dateRange": dateStatisticsRange, + "fields": _FIELDS_STATISTIC_LINKEDIN, + "count": 100, + } + if ads_ids: + params_fields.append("accounts") + params_values.update( + { + "pivots": ["CREATIVE"], + "accounts": list( + map(lambda x: f"urn:li:sponsoredAccount:{x}", ads_ids) + ), + } + ) + response = self._request_linkedin( + endpoint="/adAnalyticsV2", + headers=self.media_id._get_linkedin_headers(self.access_token), + params_fields=params_fields, + params_values=params_values, + params_values_char_ignore={"dateRange": [{"all": ":"}]}, + return_json=False, + linkedin_v2=True, + format_quote=True, + ) + + if response.status_code == 200: + statistics = response.json().get("elements", []) + else: + raise ValidationError(f"GET CAMPAIGNS STATISTICS: {response.json()}") + return statistics + + def _get_statistics_ads(self, ads_ids, start_date, end_date): + return self._get_statistics( + ads_ids=ads_ids, start_date=start_date, end_date=end_date + ) + + def _load_ads(self, start_date=None, end_date=None): + start_date, end_date = self._get_default_filter_date(start_date, end_date) + response = self._request_linkedin( + endpoint="/adCreativesV2", + headers=self.media_id._get_linkedin_headers(self.access_token), + params_fields=["q", "search", "fields", "count"], + params_values={ + "q": "search", + "search": "(test:false)", + "fields": "id,reference,test,campaign," + "changeAuditStamps,servingStatuses", + "count": 100, + }, + params_values_char_ignore={"search": [{"1,2,6": ":"}]}, + return_json=False, + linkedin_v2=True, + format_quote=True, + ) + if response.status_code == 200: + ads = response.json().get("elements", []) + else: + raise ValidationError(f"GET ADS: {response.json()}") + + # STATISTICS + ads_parse = [] + if ads: + ads_statistics = [] + ads_ids = list(map(lambda x: x["id"], ads)) + ads_statistics = self._get_statistics_ads( + ads_ids, start_date=None, end_date=None + ) + + # CAMPAIGNS + campaign_ids = list(map(lambda x: x["campaign"], ads)) + ads_campaigns = self._get_campaigns( + start_date, end_date, campaign_ids=campaign_ids + ) + + # POSTS + post_ids = list( + map( + lambda x: x["reference"], + list(filter(lambda x: x.get("reference", False), ads)), + ) + ) + ads_ugc_posts = self._get_posts( + **{"params_fields": ["ids"], "params_values": {"ids": post_ids}} + ) + for ad in ads: + statistic = list( + filter( + lambda x: f"urn:li:sponsoredAccount:{ad['id']}" + in x["pivotValues"], + ads_statistics, + ) + ) + campaign = list( + filter( + lambda x: int(ad["campaign"].split(":")[-1]) == x["id"], + ads_campaigns, + ) + ) + post = {} + if ad.get("reference", False) and ads_ugc_posts.get( + ad["reference"], False + ): + post = { + "id": ads_ugc_posts[ad["reference"]]["id"], + "name": ads_ugc_posts[ad["reference"]] + .get("specificContent", {}) + .get("com.linkedin.ugc.ShareContent", {}) + .get("shareCommentary", {}) + .get("text", ""), + } + account_id = campaign[0]["account"].split(":")[-1] + ad.update( + { + "media_type": self.media_type, + "statistic": statistic[0] if len(statistic) > 0 else {}, + "campaign": campaign[0] if len(campaign) > 0 else {}, + "created": convert_to_date( + miliseconds=ad["changeAuditStamps"]["created"]["time"], + expire_date=False, + format_date="%d/%m/%Y", + ), + "status": ", ".join(ad["servingStatuses"]), + "post": post, + "url": f"{_URL_LINKEDIN}{account_id}/" + f"creatives?creativeIds={url_quote([ad['id']])}", + } + ) + ads_parse.append(ad) + return ads_parse + + def _load_ads_accounts(self): + ads = super()._load_ads_accounts() + account_ids = self.search([("media_type", "=", "linkedin")]) + for account in account_ids: + ads_linkedin = account._load_ads() + ads = list(itertools.chain(ads, ads_linkedin)) + return { + "ads": ads, + } + + def _need_update(self, need_update=True): + self.env["bus.bus"]._sendone( + self.env.user.partner_id, + "social_need_update", + {"need_update": need_update}, + ) + + def _run_check_media_updates(self): + update = super()._run_check_media_updates() + if not update: + account_ids = self.search([("media_type", "=", "linkedin")]) + PostAccount = self.env["social.post.account"] + for account in account_ids: + post_ids = account._get_posts( + params_fields=["sortBy"], + params_values={"sortBy": "LAST_MODIFIED"}, + add_values=True, + ) + if post_ids: + count_post = PostAccount.search_count( + [ + ( + "linkedin_post_account_urn", + "=", + post_ids[0]["id"], + ), + ("linkedin_post_account_urn", "!=", False), + ("account_id", "=", account.id), + ], + limit=1, + ) + if count_post == 0: + account.need_update = True + return self._need_update() + else: + post_reactions = account._get_entity_share_statistics( + posts=post_ids + ) + for post_reaction in post_reactions: + statistic = post_reaction.get("totalShareStatistics", {}) + if statistic: + post_account = PostAccount.search_count( + [ + "&", + ( + "linkedin_post_account_urn", + "=", + post_reaction.get("share", "-"), + ), + "|", + ( + "comment_count", + "!=", + statistic.get("commentCount", 0), + ), + "|", + ( + "like_count", + "!=", + statistic.get("likeCount", 0), + ), + "|", + ( + "click_count", + "!=", + statistic.get("clickCount", 0), + ), + ( + "share_count", + "!=", + statistic.get("shareCount", 0), + ), + ] + ) + if post_account: + account.need_update = True + return self._need_update() + + return update diff --git a/social_media_linkedin/models/social_media.py b/social_media_linkedin/models/social_media.py new file mode 100644 index 0000000000..f8d782befb --- /dev/null +++ b/social_media_linkedin/models/social_media.py @@ -0,0 +1,41 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +from odoo import fields, models + +from ..social_linkedin_utils import _HEADERS_LINKEDIN + + +class SocialMedia(models.Model): + _inherit = "social.media" + + media_type = fields.Selection( + selection_add=[("linkedin", "Linkedin")], default="linkedin" + ) + + def _get_linkedin_headers( + self, access_token=None, content_type=None, x_restli_method=None + ): + headers = _HEADERS_LINKEDIN + if x_restli_method: + headers.update({"X-RestLi-Method": x_restli_method}) + if access_token: + headers.update({"Authorization": f"Bearer {access_token}"}) + if content_type: + headers.update({"Content-Type": content_type}) + return headers + + def open_action_account(self): + res = super().open_action_account() + if self.media_type == "linkedin": + return { + "res_model": "wizard.social.account", + "views": [[False, "form"]], + "target": "new", + "type": "ir.actions.act_window", + "context": { + "default_media_id": self.id, + }, + } + return res diff --git a/social_media_linkedin/models/social_post.py b/social_media_linkedin/models/social_post.py new file mode 100644 index 0000000000..441cc913e3 --- /dev/null +++ b/social_media_linkedin/models/social_post.py @@ -0,0 +1,23 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import itertools + +from odoo import fields, models + + +class SocialPost(models.Model): + _inherit = "social.post" + + campaign_id = fields.Many2one("utm.campaign", domain=[("account_id", "!=", False)]) + + def _default_account_ids(self): + res = super()._default_account_ids() + account_ids = ( + self.env["social.account"] + .with_company(self.env.company) + .search([("media_type", "=", "linkedin")]) + ) + if account_ids: + return list(itertools.chain(account_ids.ids, res)) + return res diff --git a/social_media_linkedin/models/social_post_account.py b/social_media_linkedin/models/social_post_account.py new file mode 100644 index 0000000000..e2689f53de --- /dev/null +++ b/social_media_linkedin/models/social_post_account.py @@ -0,0 +1,534 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import base64 +import itertools +import logging +from urllib.parse import quote + +from odoo import Command, fields, models +from odoo.exceptions import ValidationError + +from odoo.addons.social_media_base.social_utils import ( + _generate_timestamps, + convert_date_in_time, +) + +from ..social_linkedin_utils import _URL_V2_LINKEDIN + +_logger = logging.getLogger(__name__) + + +class SocialPostAccount(models.Model): + _inherit = "social.post.account" + + linkedin_post_account_urn = fields.Char() + linkedin_account_urn = fields.Char(related="account_id.linkedin_account_urn") + creative_urn = fields.Char() + + def _get_assets_save(self, share_content): + medias = share_content.get("media", []) + media_ids = [val.get("media", "") for val in medias] + medias_exist = ( + self.env["ir.attachment"] + .search( + [ + ("name", "in", media_ids), + ] + ) + .mapped("name") + ) + attachments = [] + for media in medias: + if media.get("media", False) not in medias_exist and media.get( + "originalUrl", False + ): + media_content = self.account_id._request_linkedin( + complete_url=media.get("originalUrl", False), + return_json=False, + ) + if media_content.status_code == 200: + attachments.append( + Command.create( + { + "name": media.get("media", False), + "type": "binary", + "res_model": self._name, + "res_id": self.id, + "datas": base64.b64encode(media_content.content), + } + ) + ) + return attachments + + def _linkedin_advertising_accounts(self): + advertising_account_id = self.account_id.advertising_account_id + if not advertising_account_id: + response = self.account_id._request_linkedin( + endpoint="/adAccountsV2", + headers=self.account_id.media_id._get_linkedin_headers( + self.account_id.access_token + ), + params_fields=["q", "fields"], + params_values={"q": "search", "fields": "id,test"}, + token=True, + return_json=False, + linkedin_v2=True, + ) + if response.status_code == 200: + total = response.json().get("paging", {}).get("total", 0) + if total > 0: + elements = response.json().get("elements", []) + filter_test = False + if self.account_id.environment == "test": + filter_test = True + filter_account = list( + filter(lambda x: x.get("test", False) == filter_test, elements) + ) + advertising_account_id = ( + "urn:li:sponsoredAccount:{}".format(filter_account[0]["id"]) + if filter_account + else False + ) + else: + raise ValidationError( + self.env._( + f"Error get advertising account in Linkedin: " + f"{response.json()}" + ) + ) + return advertising_account_id + + def _action_campaign_group(self): + advertising_account_id = self._linkedin_advertising_accounts() + group_campaign = False + if advertising_account_id: + post_campaign_id = self.post_id.campaign_id + if post_campaign_id.campaign_group_id.linkedin_urn: + group_campaign = self.account_id._request_linkedin( + endpoint="/adCampaignGroupsV2/{}".format( + post_campaign_id.campaign_group_id.linkedin_urn.split(":")[-1] + ), + headers=self.account_id.media_id._get_linkedin_headers( + self.account_id.access_token + ), + token=True, + return_json=False, + linkedin_v2=True, + ) + if group_campaign and group_campaign.status_code == 200: + return post_campaign_id.campaign_group_id.linkedin_urn + elif not group_campaign or group_campaign.status_code == 404: + start, end = _generate_timestamps() + response = self.account_id._request_linkedin( + method="POST", + endpoint="/adCampaignGroupsV2", + headers=self.account_id.media_id._get_linkedin_headers( + self.account_id.access_token + ), + json_data={ + "account": advertising_account_id, + "name": post_campaign_id.campaign_group_id.name, + "runSchedule": { + "start": start, + "end": end, + }, + "status": "ACTIVE", + "totalBudget": { + "amount": post_campaign_id.campaign_group_id.total_budget, + "currencyCode": post_campaign_id.currency_id.name, + }, + }, + token=True, + return_json=False, + linkedin_v2=True, + ) + if response.status_code == 201: + group_campaign = "urn:li:sponsoredCampaignGroup:{}".format( + response.headers.get("Location").split("/")[-1] + ) + post_campaign_id.campaign_group_id.linkedin_urn = group_campaign + else: + raise ValidationError( + self.env._( + f"Error creating group campaign in Linkedin: " + f"{response.json()}" + ) + ) + else: + raise ValidationError( + self.env._( + f"Error creating group campaign \ + in Linkedin: {group_campaign.json()}" + ) + ) + return group_campaign + + def _action_campaign(self): + campaign_group_linkedin_urn = self._action_campaign_group() + campaign = False + if campaign_group_linkedin_urn: + post_campaign_id = self.post_id.campaign_id + if post_campaign_id.linkedin_urn: + campaign = self.account_id._request_linkedin( + endpoint="/adCampaignsV2/{}".format( + post_campaign_id.linkedin_urn.split(":")[-1] + ), + headers=self.account_id.media_id._get_linkedin_headers( + self.account_id.access_token + ), + return_json=False, + linkedin_v2=True, + ) + if campaign and campaign.status_code == 200: + return post_campaign_id.linkedin_urn + elif not campaign or campaign.status_code == 404: + start, end = _generate_timestamps() + response = self.account_id._request_linkedin( + method="POST", + endpoint="/adCampaignsV2", + headers=self.account_id.media_id._get_linkedin_headers( + self.account_id.access_token + ), + json_data={ + "account": self._linkedin_advertising_accounts(), + "campaignGroup": campaign_group_linkedin_urn, + "name": f"{post_campaign_id.name}", + "type": "SPONSORED_UPDATES", + "offsiteDeliveryEnabled": False, + "runSchedule": { + "start": start, + "end": end, + }, + "locale": { + "country": self.env.user.country_id.code or "US", + "language": self.env.user.lang.split("_")[0], + }, + "unitCost": { + "amount": f"{post_campaign_id.unit_cost}", + "currencyCode": post_campaign_id.currency_id.name, + }, + "dailyBudget": { + "amount": f"{post_campaign_id.daily_budget}", + "currencyCode": post_campaign_id.currency_id.name, + }, + "status": "ACTIVE", + }, + token=True, + return_json=False, + linkedin_v2=True, + ) + if response.status_code == 201: + campaign = "urn:li:sponsoredCampaign:{}".format( + response.headers.get("Location").split("/")[-1] + ) + post_campaign_id.linkedin_urn = campaign + else: + raise ValidationError( + self.env._( + f"Error creating campaign in Linkedin: " + f"{response.json()}" + ) + ) + else: + raise ValidationError( + self.env._( + f"""Error creating group campaign in Linkedin: + {campaign.json()}""" + ) + ) + + return campaign + + def _action_campaign_post(self, post_id): + res = super()._action_campaign_post(post_id) + if ( + self.media_id.media_type == "linkedin" + and self.post_id.campaign_id + and self.post_id.campaign_id.campaign_group_id + and self.post_id.campaign_id.media_id.media_type == "linkedin" + ): + campaign_linkedin_urn = self._action_campaign() + if campaign_linkedin_urn and post_id: + response = self.account_id._request_linkedin( + method="POST", + endpoint="/adCreativesV2", + headers=self.account_id.media_id._get_linkedin_headers( + self.account_id.access_token + ), + json_data={ + "campaign": campaign_linkedin_urn, + "reference": post_id, + "status": "ACTIVE", + "type": "SPONSORED_STATUS_UPDATE", + "variables": { + "data": { + "com.linkedin.ads.SponsoredUpdateCreativeVariables": {} + } + }, + }, + token=True, + return_json=False, + linkedin_v2=True, + ) + if response.status_code == 201: + res = response.headers.get("Location").split("/")[-1] + else: + raise ValidationError( + self.env._( + f"""Error creating campaign post in Linkedin: + {response.json()}""" + ) + ) + else: + raise ValidationError( + self.env._( + """The campaign could not be generated for the post, + please try again later.""" + ) + ) + return res + + def _action_post(self): + res = super()._action_post() + if any( + account.media_type == "linkedin" for account in self.post_id.account_ids + ): + post_accounts = self.filter_by_media_types(["linkedin"]) + for post_account in post_accounts: + post_entity = post_account.account_id.create_restclient_linkedin( + "/ugcPosts", + message=post_account.message, + image_ids=post_account.post_id.image_ids, + video_ids=post_account.post_id.video_ids, + ) + if post_entity: + ugc_post = post_account.account_id._get_posts( + **{ + "params_fields": ["ids"], + "params_values": {"ids": [post_entity]}, + } + ) + if ugc_post and ugc_post[0].get("share_content", False): + attach_images = post_account._get_assets_save( + ugc_post[0].get("share_content", {}) + ) + post_account.write( + { + "linkedin_post_account_urn": f"urn:li:share:{post_entity}", + "post_account_url": f"https://www.linkedin.com/feed/update/urn:li:share:{post_entity}", + "creative_urn": post_account._action_campaign_post( + post_entity + ), + "image_ids": attach_images, + "state": "posted", + "published_date": fields.Datetime.now(), + } + ) + else: + post_account.write( + { + "state": "failed", + } + ) + return res + + def action_like_post(self, author_urn=None): + res = super().action_like_post(author_urn) + if self.media_id.media_type == "linkedin": + like_ok = False + response = self.account_id._request_linkedin( + method="POST", + endpoint="/reactions", + headers=self.account_id.media_id._get_linkedin_headers( + self.account_id.access_token, content_type="application/json" + ), + token=True, + return_json=False, + linkedin_v2=True, + params_fields=["actor"], + params_values={"actor": author_urn}, + json_data={ + "root": self.linkedin_post_account_urn, + "reactionType": "LIKE", + }, + ) + message_like = "" + if response.status_code == 201: + like_ok = True + elif response.status_code == 409: + message_like = self.env._("You have already reacted to this post.") + elif response.status_code == 404: + message_like = self.env._( + "The post does not exist or has been deleted." + ) + else: + message_like = response.json().get("message", "") + return {"success": like_ok, "message": message_like} + return res + + def action_like_comment(self, comment_id=None, author_urn=None): + super().action_like_comment(author_urn) + return {"success": False, "message": ""} + + def get_comments(self): + data = super().get_comments() + comments = [] + if "linkedin" == self.account_id.media_type and self.linkedin_post_account_urn: + response = self.account_id._request_linkedin( + method="GET", + endpoint=f"/socialActions/{quote(self.linkedin_post_account_urn)}/comments", + headers=self.account_id.media_id._get_linkedin_headers( + self.account_id.access_token + ), + token=True, + return_json=False, + linkedin_v2=True, + ) + if response.status_code == 200: + response_comments = response.json().get("elements", []) + comments = [ + { + "id": comment.get("id"), + "text": comment.get("message", {}).get("text"), + "actor": comment.get("lastModified", {}).get("actor", {}), + "published_time": convert_date_in_time( + miliseconds=comment.get("lastModified", 0).get("time", 0), + timezone=self.env.user.tz, + ), + "images_url": [ + val.get("url", {}) for val in comment.get("content", {}) + ], + } + for comment in response_comments + ] + else: + return_message = f"Error GET COMMENTS LINKEDIN: {response.json()}" + _logger.error(return_message) + if not self.env.user.has_group("base.group_no_one"): + return_message = self.env._( + "An error occurred while retrieving comments, " + "please try again later." + ) + return { + "success": False, + "message": return_message, + } + return { + "success": True, + "data": list(itertools.chain(data.get("data", []), comments)), + } + + def create_linkedin_comment(self, post_data): + if "linkedin" == self.account_id.media_type: + json_data = { + "actor": self.account_id.linkedin_account_urn, + "message": {"text": post_data.get("body", "")}, + "object": self.linkedin_post_account_urn, + } + response = self.account_id._request_linkedin( + method="POST", + endpoint=f"/socialActions/{quote(self.linkedin_post_account_urn)}/comments", + headers=self.account_id.media_id._get_linkedin_headers( + self.account_id.access_token + ), + json_data=json_data, + token=True, + return_json=False, + linkedin_v2=True, + ) + if response.status_code != 201: + return_message = response.json().get("message", "") + # _logger.warning(f"ERROR CREATE COMMENT LINKEDIN: {return_message}") + + if not self.env.user.has_group("base.group_no_one"): + return_message = self.env._( + "An error occurred while commenting, please try again later." + ) + return { + "success": False, + "message": return_message, + } + return { + "success": True, + } + + def create_comment(self, post_data, context=None): + if "linkedin" == self.account_id.media_type: + return self.create_linkedin_comment(post_data) + else: + return super().create_comment(post_data, context) + + def delete_linkedin_comment(self, comment_id, actor_urn): + if "linkedin" == self.account_id.media_type: + response = self.account_id._request_linkedin( + method="DELETE", + endpoint=f"/socialActions/{quote(self.linkedin_post_account_urn)}/comments/{quote(comment_id)}", + headers=self.account_id.media_id._get_linkedin_headers( + self.account_id.access_token + ), + params_fields=["actor"], + params_values={"actor": actor_urn}, + token=True, + return_json=False, + linkedin_v2=True, + ) + if response.status_code != 204: + return { + "success": False, + "message": self.env._( + """ + An error occurred while deleting the comment or + it no longer exists, please try again later. + """ + ), + } + return { + "success": True, + } + + def get_linkedin_comment(self): + if "linkedin" == self.account_id.media_type and self.linkedin_post_account_urn: + response = self.account_id._request_linkedin( + endpoint=f"/shares/{quote(self.linkedin_post_account_urn)}", + headers=self.account_id.media_id._get_linkedin_headers( + self.account_id.access_token + ), + token=True, + return_json=False, + linkedin_v2=True, + ) + if response.status_code != 200: + self.linkedin_post_account_urn = None + self.post_account_url = None + return False + return True + return False + + def _delete_post_account(self): + if self.media_id.media_type == "linkedin": + if self.linkedin_post_account_urn: + pos_account_urn = quote(self.linkedin_post_account_urn, safe="") + delete_post = self.account_id._request_linkedin( + method="DELETE", + complete_url=f"{_URL_V2_LINKEDIN}/shares/{pos_account_urn}", + headers=self.media_id._get_linkedin_headers( + self.account_id.access_token + ), + linkedin_v2=True, + return_json=False, + ) + if delete_post.status_code != 200: + error_message = delete_post.json().get( + "message", + self.env._( + "The post could not be deleted, please try again later." + ), + ) + raise ValidationError(error_message) + return super()._delete_post_account() + + def _action_like_linkedin_comment(self, actor_urn): + if actor_urn: + pass + return {"success": False} diff --git a/social_media_linkedin/models/utm_campaign.py b/social_media_linkedin/models/utm_campaign.py new file mode 100644 index 0000000000..b252f4f82c --- /dev/null +++ b/social_media_linkedin/models/utm_campaign.py @@ -0,0 +1,38 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging + +from odoo import api, fields, models +from odoo.exceptions import ValidationError + +_logger = logging.getLogger(__name__) + + +class UtmCampaign(models.Model): + _inherit = "utm.campaign" + + linkedin_urn = fields.Char(copy=False) + unit_cost = fields.Float(help="Cost per post") + daily_budget = fields.Float(help="Maximum daily campaign spending") + currency_id = fields.Many2one( + "res.currency", related="campaign_group_id.currency_id" + ) + + def _available_campaign(self): + media_type = super()._available_campaign() + media_type.append("linkedin") + return media_type + + @api.constrains("daily_budget") + def _check_daily_budget(self): + for campaign in self: + if campaign.campaign_group_id.total_budget < sum( + campaign.campaign_group_id.campaign_ids.mapped("daily_budget") + ): + raise ValidationError( + self.env._( + """The amount you want to add exceeds + the campaign group limit.""" + ) + ) diff --git a/social_media_linkedin/models/utm_group_campaign.py b/social_media_linkedin/models/utm_group_campaign.py new file mode 100644 index 0000000000..54278054bd --- /dev/null +++ b/social_media_linkedin/models/utm_group_campaign.py @@ -0,0 +1,17 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class UtmGroupCampaign(models.Model): + _inherit = "utm.group.campaign" + + linkedin_urn = fields.Char() + currency_id = fields.Many2one("res.currency") + campaign_ids = fields.One2many("utm.campaign", "campaign_group_id") + total_budget = fields.Float( + help=""" + Maximum budget that the campaign can spend over its entire duration + """ + ) diff --git a/social_media_linkedin/pyproject.toml b/social_media_linkedin/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/social_media_linkedin/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/social_media_linkedin/readme/CONFIGURE.md b/social_media_linkedin/readme/CONFIGURE.md new file mode 100644 index 0000000000..09d4521ec5 --- /dev/null +++ b/social_media_linkedin/readme/CONFIGURE.md @@ -0,0 +1,65 @@ +To configure this module, you need to: +--------------- + +Please note that you must have a developer account. +The steps required for using it are defined below: + +Before creating this developer account, you must have a partner company +on your LinkedIn account and be an administrator. + +- Go to https://developer.linkedin.com/ +- Create a new Developer App. +- Fill in the requested fields. In the *Privacy Policy URL* field, copy your company's URL. + + ![FORM_CREATE_APP](../static/img/readme/FORM_CREATE_APP.png) + +- Once the app is created, go to the *My Apps* menu and you will see the newly created app; select it. +- Within the app, in the *Settings* tab, verify your company. You will see a button that says Verify. Click it, and in the window that appears in the lower left corner, click the *Generate URL* button. Copy the generated URL into your browser and accept. +- Then go to the *Products* tab and request access to the following products (for basic and free use): + * LinkedIn Ad Library + * Share on LinkedIn + * Advertising API + * Events Management API + * Sign In with LinkedIn using OpenID Connect + + ![PRODUCTS](../static/img/readme/PRODUCTS.png) + + Note that some products require you to fill out a form; you must do so, otherwise, + the necessary scopes for basic use of your account will not be enabled. + +- After requesting the aforementioned Products, go to the Auth tab and you will see all the enabled scopes at the bottom. + +- At the top of the aforementioned tab, you will see the Client ID and Primary Client Secret information. + +- Configure the access points for which you want to use the account. Follow these steps: + * Go to *Settings* > *Technical* > System Parameters. + * Search for web.base.url + * Copy the base URL and concatenate it with the endpoint. Then, in your LinkedIn Developer Account, on the Authentication tab, in the Authorized Redirect URLs for Your App section, add a new item. * Example: + web.base.url: http://192.168.1.7:8017 + endpoint: /linkedin/callback + linkedin_url: http://192.168.1.7:8017/linkedin/callback + + ![CONFIGURE_URL_CALLBACK](../static/img/readme/CONFIGURE_URL_CALLBACK.png) + + +Registering the Client ID and Client Secret. Integration of a user account. +--------------- + +- Go to *Social Media* > Settings > Social Media +- Click on the *Associate Account* button for the desired social network. + + ![ASSOCIATE_ACCOUNT](../static/img/readme/ASSOCIATE_ACCOUNT.png) + +- A wizard will open for you to add the Client ID and Client Secret obtained + from your developer account. + + ![WIZARD_ASSOCIATE_ACCOUNT](../static/img/readme/WIZARD_ASSOCIATE_ACCOUNT.png) + +- By clicking the *Associate* button here, you'll be taken to a LinkedIn authentication page. + Once you validate your information, you'll be taken to the system and the Dashboard view, + where you'll see your posts. + + ![AUTHORIZE_ACCOUNT](../static/img/readme/AUTHORIZE_ACCOUNT.png) + +- Once you have completed these steps and everything is working correctly, + you can see your account in *Social Media* > Configuration > Accounts diff --git a/social_media_linkedin/readme/CONTRIBUTORS.md b/social_media_linkedin/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..63aae81f84 --- /dev/null +++ b/social_media_linkedin/readme/CONTRIBUTORS.md @@ -0,0 +1,5 @@ +- [Binhex] (https://www.binhex.cloud): + - Edilio Escalona Almira + +- [Trobz] (https://trobz.com/): + - Khanh (Dinh Van) \ No newline at end of file diff --git a/social_media_linkedin/readme/DESCRIPTION.md b/social_media_linkedin/readme/DESCRIPTION.md new file mode 100644 index 0000000000..84bfa1cf1c --- /dev/null +++ b/social_media_linkedin/readme/DESCRIPTION.md @@ -0,0 +1,20 @@ +This module provides the necessary functionality for +basic interaction with the LinkedIn social network. + +Main features: +- User account integration. +- Post creation. +- Post reactions (likes, comments). +- Comment reactions (likes) +- Reports and graphs with agnostic metrics. + + +Statistics account +------------------- +1. The eye icon: Total number of views, which may include multiple views by the same user. +2. The hand icon: means the impressions (clicks, likes, comments, shares) + that the posts created historically on the account have had. +3. The star icon: means the value of the engagement of the publications, it is a calculation + similar to (interactions / impressions) * 100, that is, a percentage. + + ![STATISTICS_ACCOUNT](/social_media_linkedin/static/img/readme/STATISTICS_ACCOUNT.png) diff --git a/social_media_linkedin/readme/ROADMAP.md b/social_media_linkedin/readme/ROADMAP.md new file mode 100644 index 0000000000..f430df3d72 --- /dev/null +++ b/social_media_linkedin/readme/ROADMAP.md @@ -0,0 +1,16 @@ +Like the comments. +--------------- + +- To like a comment you need the scopes: + + * w_member_social_feed, + * r_organization_social_feed + Which are special permissions granted by LinkedIn + +Comments on publications +--------------- +- Images, videos, documents, or any type of media are not allowed + in post comments via the API, due to LinkedIn's own limitations. + Although there is an example of how to do it in the documentation, + in practice it doesn't work, even in paid versions. + diff --git a/social_media_linkedin/readme/USAGE.md b/social_media_linkedin/readme/USAGE.md new file mode 100644 index 0000000000..649592d4af --- /dev/null +++ b/social_media_linkedin/readme/USAGE.md @@ -0,0 +1,50 @@ +List of posts generated from Odoo. +--------------- + +Only posts generated using Odoo are displayed. + +- Go to *Social Media* > Post + +Generate a post. +--------------- + +This feature acts as a template for generating multiple posts +from a single view, depending on the selected accounts. + +- Go to *Social Media* > Post > New or Go to *Social Media* > Dashboard > Add Post +- Fill in the required fields + ![CREATE_POST](/social_media_linkedin/static/img/readme/CREATE_POST.png) +- Save +- Click on the *Post* button + +Update token, client ID, client Secret and organization data +--------------- + +- Go to *Social Media* > Configuration > Accounts +- Select the account +- Click on the *Update account* button + + ![BUTTON_UPDATE_ACCOUNT](/social_media_linkedin/static/img/readme/BUTTON_UPDATE_ACCOUNT.png) + +- In the wizard that appears, if none of the checkboxes are selected and the + *Update* button is pressed, the system will update only the organization's data. +- If the *Update keys* checkbox is selected, the current Client ID and Client Secret + values will be displayed by default. Modify any of these values and authentication + will be performed again through LinkedIn to update these values and the token. + + ![UPDATE_KEYS](/social_media_linkedin/static/img/readme/UPDATE_KEYS.png) + +- Selecting the *Update token* checkbox will update the current token. + + ![UPDATE_TOKEN](/social_media_linkedin/static/img/readme/UPDATE_TOKEN.png) + +Archive Account Linkedin +---------------------------- + +- Go to *Social Media* > Configuration > Accounts +- Select the account +- Click on the *Delete account* button + + ![ARCHIVE_ACCOUNT](/social_media_linkedin/static/img/readme/ARCHIVE_ACCOUNT.png) + +- Please note that all data associated with this account will be archived. diff --git a/social_media_linkedin/social_linkedin_utils.py b/social_media_linkedin/social_linkedin_utils.py new file mode 100644 index 0000000000..c8dc232fef --- /dev/null +++ b/social_media_linkedin/social_linkedin_utils.py @@ -0,0 +1,36 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +_URL_LINKEDIN = "https://www.linkedin.com/campaignmanager/accounts/" +_URL_REST_LINKEDIN = "https://api.linkedin.com/rest" +_URL_V2_LINKEDIN = "https://api.linkedin.com/v2" +_URL_AUTH_V2_LINKEDIN = "https://www.linkedin.com/oauth/v2" + +_VERSION_STRING = "202502" + +_HEADERS_LINKEDIN = { + "X-Restli-Protocol-Version": "2.0.0", + "LinkedIn-Version": _VERSION_STRING, +} + +_SCOPE_LINKEDIN = [ + "profile", + "r_ads_reporting", + "r_organization_social", + "rw_organization_admin", + "w_member_social", + "r_ads", + "w_organization_social", + "rw_ads", + "r_basicprofile", + "r_organization_admin", + "email", + "r_1st_connections_size", +] + +_FIELDS_CAMPAIGN_LINKEDIN = "id,name,test,account" +_FIELDS_STATISTIC_LINKEDIN = ( + "actionClicks,adUnitClicks,clicks,costInUsd," + "externalWebsiteConversions,impressions,pivotValues" +) +_URN_ORGANIZATION_LINKEDIN = "urn:li:organization:" diff --git a/social_media_linkedin/static/description/icon.png b/social_media_linkedin/static/description/icon.png new file mode 100644 index 0000000000..9cd4ce2988 Binary files /dev/null and b/social_media_linkedin/static/description/icon.png differ diff --git a/social_media_linkedin/static/description/index.html b/social_media_linkedin/static/description/index.html new file mode 100644 index 0000000000..d77475486a --- /dev/null +++ b/social_media_linkedin/static/description/index.html @@ -0,0 +1,621 @@ + + + + + +Social Media Linkedin + + + +
+

Social Media Linkedin

+ + +

Beta License: AGPL-3 OCA/social Translate me on Weblate Try me on Runboat

+

This module provides the necessary functionality for basic interaction +with the LinkedIn social network.

+

Main features:

+
    +
  • User account integration.
  • +
  • Post creation.
  • +
  • Post reactions (likes, comments).
  • +
  • Comment reactions (likes)
  • +
  • Reports and graphs with agnostic metrics.
  • +
+
+

Statistics account

+
    +
  1. The eye icon: Total number of views, which may include multiple views +by the same user.

    +
  2. +
  3. The hand icon: means the impressions (clicks, likes, comments, +shares) that the posts created historically on the account have had.

    +
  4. +
  5. The star icon: means the value of the engagement of the publications, +it is a calculation similar to (interactions / impressions) * 100, +that is, a percentage.

    +

    STATISTICS_ACCOUNT

    +
  6. +
+

Table of contents

+ + +
+
+

To configure this module, you need to:

+

Please note that you must have a developer account. The steps required +for using it are defined below:

+

Before creating this developer account, you must have a partner company +on your LinkedIn account and be an administrator.

+
    +
  • Go to https://developer.linkedin.com/

    +
  • +
  • Create a new Developer App.

    +
  • +
  • Fill in the requested fields. In the Privacy Policy URL field, copy +your company’s URL.

    +

    FORM_CREATE_APP

    +
  • +
  • Once the app is created, go to the My Apps menu and you will see the +newly created app; select it.

    +
  • +
  • Within the app, in the Settings tab, verify your company. You will +see a button that says Verify. Click it, and in the window that +appears in the lower left corner, click the Generate URL button. +Copy the generated URL into your browser and accept.

    +
  • +
  • Then go to the Products tab and request access to the following +products (for basic and free use):

    +
      +
    • LinkedIn Ad Library
    • +
    • Share on LinkedIn
    • +
    • Advertising API
    • +
    • Events Management API
    • +
    • Sign In with LinkedIn using OpenID Connect
    • +
    +

    PRODUCTS

    +

    Note that some products require you to fill out a form; you must do +so, otherwise, the necessary scopes for basic use of your account will +not be enabled.

    +
  • +
  • After requesting the aforementioned Products, go to the Auth tab and +you will see all the enabled scopes at the bottom.

    +
  • +
  • At the top of the aforementioned tab, you will see the Client ID and +Primary Client Secret information.

    +
  • +
  • Configure the access points for which you want to use the account. +Follow these steps:

    +
      +
    • Go to Settings > Technical > System Parameters.
    • +
    • Search for web.base.url
    • +
    • Copy the base URL and concatenate it with the endpoint. Then, in +your LinkedIn Developer Account, on the Authentication tab, in the +Authorized Redirect URLs for Your App section, add a new item. * +Example: web.base.url: http://192.168.1.7:8017 endpoint: +/linkedin/callback linkedin_url: +http://192.168.1.7:8017/linkedin/callback
    • +
    +

    CONFIGURE_URL_CALLBACK

    +
  • +
+
+
+

Registering the Client ID and Client Secret. Integration of a user account.

+
    +
  • Go to Social Media > Settings > Social Media

    +
  • +
  • Click on the Associate Account button for the desired social +network.

    +

    ASSOCIATE_ACCOUNT

    +
  • +
  • A wizard will open for you to add the Client ID and Client Secret +obtained from your developer account.

    +

    WIZARD_ASSOCIATE_ACCOUNT

    +
  • +
  • By clicking the Associate button here, you’ll be taken to a LinkedIn +authentication page. Once you validate your information, you’ll be +taken to the system and the Dashboard view, where you’ll see your +posts.

    +

    AUTHORIZE_ACCOUNT

    +
  • +
  • Once you have completed these steps and everything is working +correctly, you can see your account in Social Media > Configuration +> Accounts

    +
  • +
+
+

Usage

+
+
+
+

List of posts generated from Odoo.

+

Only posts generated using Odoo are displayed.

+
    +
  • Go to Social Media > Post
  • +
+
+
+

Generate a post.

+

This feature acts as a template for generating multiple posts from a +single view, depending on the selected accounts.

+
    +
  • Go to Social Media > Post > New or Go to Social Media > Dashboard +> Add Post
  • +
  • Fill in the required fields CREATE_POST
  • +
  • Save
  • +
  • Click on the Post button
  • +
+
+
+

Update token, client ID, client Secret and organization data

+
    +
  • Go to Social Media > Configuration > Accounts

    +
  • +
  • Select the account

    +
  • +
  • Click on the Update account button

    +

    BUTTON_UPDATE_ACCOUNT

    +
  • +
  • In the wizard that appears, if none of the checkboxes are selected and +the Update button is pressed, the system will update only the +organization’s data.

    +
  • +
  • If the Update keys checkbox is selected, the current Client ID and +Client Secret values will be displayed by default. Modify any of these +values and authentication will be performed again through LinkedIn to +update these values and the token.

    +

    UPDATE_KEYS

    +
  • +
  • Selecting the Update token checkbox will update the current token.

    +

    UPDATE_TOKEN

    +
  • +
+
+
+

Archive Account Linkedin

+
    +
  • Go to Social Media > Configuration > Accounts

    +
  • +
  • Select the account

    +
  • +
  • Click on the Delete account button

    +

    ARCHIVE_ACCOUNT

    +
  • +
  • Please note that all data associated with this account will be +archived.

    +
  • +
+
+

Known issues / Roadmap

+
+
+
+

Like the comments.

+
    +
  • To like a comment you need the scopes:
      +
    • w_member_social_feed,
    • +
    • r_organization_social_feed Which are special permissions granted by +LinkedIn
    • +
    +
  • +
+
+
+

Comments on publications

+
    +
  • Images, videos, documents, or any type of media are not allowed in +post comments via the API, due to LinkedIn’s own limitations. Although +there is an example of how to do it in the documentation, in practice +it doesn’t work, even in paid versions.
  • +
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+
+
+

Authors

+
    +
  • BinhexTeam
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/social project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+ + diff --git a/social_media_linkedin/static/img/like.png b/social_media_linkedin/static/img/like.png new file mode 100644 index 0000000000..ada7ed6285 Binary files /dev/null and b/social_media_linkedin/static/img/like.png differ diff --git a/social_media_linkedin/static/img/linkedin.png b/social_media_linkedin/static/img/linkedin.png new file mode 100644 index 0000000000..9eeb4cf8a0 Binary files /dev/null and b/social_media_linkedin/static/img/linkedin.png differ diff --git a/social_media_linkedin/static/img/readme/ARCHIVE_ACCOUNT.png b/social_media_linkedin/static/img/readme/ARCHIVE_ACCOUNT.png new file mode 100644 index 0000000000..b5a1a22df8 Binary files /dev/null and b/social_media_linkedin/static/img/readme/ARCHIVE_ACCOUNT.png differ diff --git a/social_media_linkedin/static/img/readme/ASSOCIATE_ACCOUNT.png b/social_media_linkedin/static/img/readme/ASSOCIATE_ACCOUNT.png new file mode 100644 index 0000000000..7a839f5714 Binary files /dev/null and b/social_media_linkedin/static/img/readme/ASSOCIATE_ACCOUNT.png differ diff --git a/social_media_linkedin/static/img/readme/AUTHORIZE_ACCOUNT.png b/social_media_linkedin/static/img/readme/AUTHORIZE_ACCOUNT.png new file mode 100644 index 0000000000..8393c842f9 Binary files /dev/null and b/social_media_linkedin/static/img/readme/AUTHORIZE_ACCOUNT.png differ diff --git a/social_media_linkedin/static/img/readme/BUTTON_CREATE_APP.png b/social_media_linkedin/static/img/readme/BUTTON_CREATE_APP.png new file mode 100644 index 0000000000..bfe7bf8737 Binary files /dev/null and b/social_media_linkedin/static/img/readme/BUTTON_CREATE_APP.png differ diff --git a/social_media_linkedin/static/img/readme/BUTTON_UPDATE_ACCOUNT.png b/social_media_linkedin/static/img/readme/BUTTON_UPDATE_ACCOUNT.png new file mode 100644 index 0000000000..b365d3bd4d Binary files /dev/null and b/social_media_linkedin/static/img/readme/BUTTON_UPDATE_ACCOUNT.png differ diff --git a/social_media_linkedin/static/img/readme/CONFIGURE_URL_CALLBACK.png b/social_media_linkedin/static/img/readme/CONFIGURE_URL_CALLBACK.png new file mode 100644 index 0000000000..ac5a6c4a05 Binary files /dev/null and b/social_media_linkedin/static/img/readme/CONFIGURE_URL_CALLBACK.png differ diff --git a/social_media_linkedin/static/img/readme/CREATE_POST.png b/social_media_linkedin/static/img/readme/CREATE_POST.png new file mode 100644 index 0000000000..054783ae20 Binary files /dev/null and b/social_media_linkedin/static/img/readme/CREATE_POST.png differ diff --git a/social_media_linkedin/static/img/readme/FORM_CREATE_APP.png b/social_media_linkedin/static/img/readme/FORM_CREATE_APP.png new file mode 100644 index 0000000000..16130a0ba7 Binary files /dev/null and b/social_media_linkedin/static/img/readme/FORM_CREATE_APP.png differ diff --git a/social_media_linkedin/static/img/readme/PRODUCTS.png b/social_media_linkedin/static/img/readme/PRODUCTS.png new file mode 100644 index 0000000000..0a854e10ea Binary files /dev/null and b/social_media_linkedin/static/img/readme/PRODUCTS.png differ diff --git a/social_media_linkedin/static/img/readme/STATISTICS_ACCOUNT.png b/social_media_linkedin/static/img/readme/STATISTICS_ACCOUNT.png new file mode 100644 index 0000000000..14529a6374 Binary files /dev/null and b/social_media_linkedin/static/img/readme/STATISTICS_ACCOUNT.png differ diff --git a/social_media_linkedin/static/img/readme/UPDATE_KEYS.png b/social_media_linkedin/static/img/readme/UPDATE_KEYS.png new file mode 100644 index 0000000000..c25444354b Binary files /dev/null and b/social_media_linkedin/static/img/readme/UPDATE_KEYS.png differ diff --git a/social_media_linkedin/static/img/readme/UPDATE_TOKEN.png b/social_media_linkedin/static/img/readme/UPDATE_TOKEN.png new file mode 100644 index 0000000000..823d625a01 Binary files /dev/null and b/social_media_linkedin/static/img/readme/UPDATE_TOKEN.png differ diff --git a/social_media_linkedin/static/img/readme/WIZARD_ASSOCIATE_ACCOUNT.png b/social_media_linkedin/static/img/readme/WIZARD_ASSOCIATE_ACCOUNT.png new file mode 100644 index 0000000000..7c3f525b22 Binary files /dev/null and b/social_media_linkedin/static/img/readme/WIZARD_ASSOCIATE_ACCOUNT.png differ diff --git a/social_media_linkedin/static/src/components/social_comment/social_comment.esm.js b/social_media_linkedin/static/src/components/social_comment/social_comment.esm.js new file mode 100644 index 0000000000..2a3a5d56e9 --- /dev/null +++ b/social_media_linkedin/static/src/components/social_comment/social_comment.esm.js @@ -0,0 +1,56 @@ +import {SocialComment} from "@social_media_base/components/social_comment/social_comment.esm"; +import {patch} from "@web/core/utils/patch"; +import {useService} from "@web/core/utils/hooks"; + +patch(SocialComment.prototype, { + /** + * Sets up the component's services. + * + * This method is called once, when the component is set up. + * It sets up the component's services and initializes its state. + * It also fetches the `socialLinkedinService` service that provides + * the logic for deleting comments on LinkedIn. + */ + setup() { + super.setup(); + this.socialLinkedinService = useService("social_linkedin_service"); + }, + /** + * Deletes a LinkedIn comment. + * + * This method calls the `deleteLinkedinComment` method from the + * `socialLinkedinService` to delete the comment associated with the + * provided post and comment IDs, as well as the actor. It overrides + * the base implementation to provide LinkedIn-specific deletion logic. + * + * @returns {Object} + * An object containing a `success` property indicating whether the + * deletion was successful, and a `message` property with the result + * message. + */ + async _onDeleteComment() { + let result = super._onDeleteComment(); + result = await this.socialLinkedinService.deleteLinkedinComment( + this.props.post.id.raw_value, + this.props.socialComment.id, + this.props.socialComment.actor + ); + return result; + }, + + /** + * Returns the list of media types for which liking a comment is not + * supported. + * + * This method overrides the base implementation to add LinkedIn to the + * list of media types for which liking a comment is not supported. + * + * @returns {String[]} + * An array of strings, where each string is a media type. + */ + mediaNotLikeEnable() { + const values = super.mediaNotLikeEnable(); + values.push("linkedin"); + return values; + }, +}); diff --git a/social_media_linkedin/static/src/components/social_comment_dialog/social_comment_dialog.esm.js b/social_media_linkedin/static/src/components/social_comment_dialog/social_comment_dialog.esm.js new file mode 100644 index 0000000000..c924e09988 --- /dev/null +++ b/social_media_linkedin/static/src/components/social_comment_dialog/social_comment_dialog.esm.js @@ -0,0 +1,12 @@ +import {SocialCommentDialog} from "@social_media_base/components/social_comment_dialog/social_comment_dialog.esm"; +import {patch} from "@web/core/utils/patch"; + +patch(SocialCommentDialog.prototype, { + _commentAllowUpload() { + const result = super._commentAllowUpload(); + if (this.props.media_type.raw_value === "linkedin") { + return false; + } + return result; + }, +}); diff --git a/social_media_linkedin/static/src/js/services/social_linkedin_service.esm.js b/social_media_linkedin/static/src/js/services/social_linkedin_service.esm.js new file mode 100644 index 0000000000..6db448161a --- /dev/null +++ b/social_media_linkedin/static/src/js/services/social_linkedin_service.esm.js @@ -0,0 +1,42 @@ +import {registry} from "@web/core/registry"; + +export const socialLinkedinService = { + dependencies: ["orm"], + + async start(env, {orm}) { + return { + /** + * Delete a comment on a social network post. + * @param {Number} post_account_id - The id of the SocialPostAccount record + * @param {String} comment_id - The id of the comment to be deleted + * @param {String} actor_urn - The URN of the actor performing the action + * @returns {Promise} The result of the server call to delete a comment + */ + async deleteLinkedinComment(post_account_id, comment_id, actor_urn) { + if (!post_account_id) { + return []; + } + return await orm.call( + "social.post.account", + "delete_linkedin_comment", + [post_account_id, comment_id, actor_urn] + ); + }, + /** + * Checks if a post already exists on LinkedIn. + * @param {Number} post_account_id - The id of the SocialPostAccount record + * @returns {Promise} Whether the post exists + */ + async validPostLinkedinExist(post_account_id) { + if (!post_account_id) { + return false; + } + return await orm.call("social.post.account", "get_linkedin_comment", [ + post_account_id, + ]); + }, + }; + }, +}; + +registry.category("services").add("social_linkedin_service", socialLinkedinService); diff --git a/social_media_linkedin/static/src/js/views/kanban/social_kanban_model.esm.js b/social_media_linkedin/static/src/js/views/kanban/social_kanban_model.esm.js new file mode 100644 index 0000000000..a147432fa2 --- /dev/null +++ b/social_media_linkedin/static/src/js/views/kanban/social_kanban_model.esm.js @@ -0,0 +1,33 @@ +import {SocialKanbanModel} from "@social_media_base/js/views/kanban/social_kanban_model.esm"; +import {patch} from "@web/core/utils/patch"; + +patch(SocialKanbanModel.prototype, { + /** + * Handles the like button click for a post in the kanban view + * @param {Object} record - the current record + * @returns {Promise} resolves with the result of the RPC call + */ + async onLikePost(record) { + super.onLikePost(); + const post_id = record.id.raw_value; + const author_urn = record.linkedin_account_urn.value; + const result = await this.orm.silent.call( + "social.post.account", + "action_like_post", + [[post_id], author_urn] + ); + this.env.bus.trigger("SOCIAL:RELOAD_ORGANIZATION", { + account_id: record.account_id.raw_value, + post_id: record.linkedin_post_account_urn.raw_value, + }); + return result; + }, + + _get_select_fields(media) { + var res = super._get_select_fields(media); + if (media === "linkedin") { + res.push("linkedin_account_urn", "image_1920"); + } + return res; + }, +}); diff --git a/social_media_linkedin/static/src/js/views/kanban/social_kanban_record.esm.js b/social_media_linkedin/static/src/js/views/kanban/social_kanban_record.esm.js new file mode 100644 index 0000000000..b84d426fca --- /dev/null +++ b/social_media_linkedin/static/src/js/views/kanban/social_kanban_record.esm.js @@ -0,0 +1,33 @@ +import {SocialKanbanRecord} from "@social_media_base/js/views/kanban/social_kanban_record.esm"; +import {patch} from "@web/core/utils/patch"; +import {useService} from "@web/core/utils/hooks"; + +patch(SocialKanbanRecord.prototype, { + /** + * @override + */ + setup() { + super.setup(); + this.socialLinkedinService = useService("social_linkedin_service"); + }, + + /** + * Checks if the post exists. + * + * This function returns a boolean indicating whether the post exists. + * It calls the "validPostLinkedinExist" method on the "social_linkedin_service", + * passing the `id` field of the record as an argument. + * + * @returns {Promise} a promise that resolves with `true` if the post exists, + * otherwise `false`. + */ + async validPostExist() { + var res = super.validPostExist(); + if (this.record.media_type.raw_value === "linkedin") { + return await this.socialLinkedinService.validPostLinkedinExist( + this.record.id.raw_value + ); + } + return res; + }, +}); diff --git a/social_media_linkedin/tests/__init__.py b/social_media_linkedin/tests/__init__.py new file mode 100644 index 0000000000..7cf00cfa8e --- /dev/null +++ b/social_media_linkedin/tests/__init__.py @@ -0,0 +1,8 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import test_common_linkedin +from . import test_account_linkedin +from . import test_media_linkedin +from . import test_post_linkedin +from . import test_controller diff --git a/social_media_linkedin/tests/test_account_linkedin.py b/social_media_linkedin/tests/test_account_linkedin.py new file mode 100644 index 0000000000..59ef9a968e --- /dev/null +++ b/social_media_linkedin/tests/test_account_linkedin.py @@ -0,0 +1,751 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +from datetime import datetime, timedelta +from unittest.mock import MagicMock, Mock, patch + +from linkedin_api.clients.restli.client import RestliClient + +from odoo.exceptions import ValidationError + +from odoo.addons.social_media_base.social_utils import ( + _generate_timestamps, +) +from odoo.addons.social_media_base.tests.test_social_common import ( + PATCH_SOCIAL_BASE_MIXIN, +) +from odoo.addons.social_media_linkedin.models.social_account import ( + SocialAccount, +) +from odoo.addons.social_media_linkedin.tests.test_common_linkedin import ( + PATCH_ACCOUNT_LINKEDIN, + TestSocialCommonLinkedin, +) + +from ..social_linkedin_utils import ( + _FIELDS_CAMPAIGN_LINKEDIN, + _FIELDS_STATISTIC_LINKEDIN, +) + + +class LinkedinMockMixin: + def _mock_linkedin(self, return_value, account, attribute="_request_linkedin"): + return patch.object(type(account), attribute, return_value=return_value) + + +class TestSocialLinkedin(LinkedinMockMixin, TestSocialCommonLinkedin): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.video_mock = type("Video", (), {"datas": cls.video_data})() + cls.mediaAsset = "urn:li:digitalmediaAsset:{}" + cls.mediaImage = "urn:li:digitalmediaImage:{}" + + def test_prepare_url_upload_asset_image(self): + fake_response = { + "value": { + "asset": self.mediaAsset.format("C123"), + "uploadMechanism": { + "com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest": { + "uploadUrl": "https://fake.upload.url/image" + } + }, + } + } + + with self._mock_linkedin( + return_value=fake_response, account=self.SocialAccountLinkedin + ) as mock_request: + asset, upload_url = self.SocialAccountLinkedin._prepare_url_upload_asset( + feedshare="image" + ) + + self.assertEqual(asset, self.mediaAsset.format("C123")) + self.assertEqual(upload_url, "https://fake.upload.url/image") + + mock_request.assert_called_once() + args, kwargs = mock_request.call_args + self.assertEqual(kwargs["method"], "POST") + self.assertIn( + "feedshare-image", + kwargs["json_data"]["registerUploadRequest"]["recipes"][0], + ) + + def test_prepare_url_upload_image(self): + fake_response = { + "value": { + "image": self.mediaImage.format("C123456"), + "uploadUrl": "https://fake.upload.url/image", + } + } + + with self._mock_linkedin( + return_value=fake_response, account=self.SocialAccountLinkedin + ) as mock_request: + image, upload_url = self.SocialAccountLinkedin._prepare_url_upload_image() + + self.assertEqual(image, self.mediaImage.format("C123456")) + self.assertEqual(upload_url, "https://fake.upload.url/image") + + mock_request.assert_called_once() + + def test_prepare_images_videos_for_post_success(self): + mock_upload_asset_image = ( + self.mediaAsset.format("XYZ"), + "https://fake.upload/asset/image", + ) + mock_upload_asset_video = ( + self.mediaAsset.format("VID123"), + "https://fake.upload/asset/video", + ) + mock_response = Mock() + mock_response.status_code = 201 + method_asset = "_prepare_url_upload_asset" + + def mock_upload_image_video(mock_upload_asset): + return self._mock_linkedin( + return_value=mock_upload_asset, + attribute=method_asset, + account=self.SocialAccountLinkedin, + ), self._mock_linkedin( + return_value=mock_response, account=self.SocialAccountLinkedin + ) + + val1, val2 = mock_upload_image_video(mock_upload_asset_image) + + with val1, val2: + images = self.SocialAccountLinkedin._prepare_images_for_post( + image_ids=[self.image_base64] + ) + self.assertEqual(len(images), 1) + self.assertEqual(images[0], self.mediaAsset.format("XYZ")) + + val1, val2 = mock_upload_image_video(mock_upload_asset_video) + + with val1, val2: + videos = self.SocialAccountLinkedin._prepare_videos_for_post( + video_ids=[self.video_mock] + ) + self.assertEqual(len(videos), 1) + self.assertEqual(videos[0], self.mediaAsset.format("VID123")) + + @patch(PATCH_ACCOUNT_LINKEDIN.format("_request_linkedin")) + def test_get_posts(self, mock_request_linkedin): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "elements": [ + { + "id": "123", + "specificContent": { + "com.linkedin.ugc.ShareContent": {"text": "Post 1"} + }, + }, + { + "id": "456", + "specificContent": { + "com.linkedin.ugc.ShareContent": {"text": "Post 2"} + }, + }, + ] + } + + mock_request_linkedin.return_value = mock_response + + linkedin_account = self.SocialAccountLinkedin + posts = linkedin_account._get_posts() + + self.assertEqual(len(posts), 2) + self.assertEqual(posts[0]["id"], "123") + self.assertEqual(posts[1]["id"], "456") + self.assertEqual(posts[0]["share_content"]["text"], "Post 1") + self.assertEqual(posts[1]["share_content"]["text"], "Post 2") + + mock_request_linkedin.assert_called_once_with( + endpoint="/ugcPosts", + headers=self.media_linkedin_id._get_linkedin_headers( + linkedin_account.access_token + ), + params_fields=["q", "authors"], + params_values={ + "q": "authors", + "authors": [ + f"urn:li:organization:{linkedin_account.linkedin_account_id}" + ], + }, + linkedin_v2=True, + return_json=False, + ) + + mock_response.status_code = 400 + with self.assertRaises(ValidationError): + linkedin_account._get_posts() + + @patch(PATCH_ACCOUNT_LINKEDIN.format("_get_entity_share_statistics")) + @patch(PATCH_ACCOUNT_LINKEDIN.format("_get_default_filter_date")) + def test_get_chart_account_statistics( + self, mock_get_default_filter_date, mock_get_entity_share_statistics + ): + mock_get_default_filter_date.return_value = ( + "2025-01-01T00:00:00", + "2025-01-07T23:59:59", + ) + + mock_get_entity_share_statistics.return_value = [ + { + "totalShareStatistics": { + "clickCount": 100, + "shareCount": 50, + "likeCount": 30, + } + }, + { + "totalShareStatistics": { + "clickCount": 200, + "shareCount": 100, + "likeCount": 70, + } + }, + ] + + linkedin_account = self.SocialAccountLinkedin + + result = linkedin_account._get_chart_account_statistics( + start_date="2025-01-01", end_date="2025-01-07", granularity="WEEK" + ) + + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["datasets"][0]["data"], [100, 200]) + self.assertEqual(result[0]["datasets"][1]["data"], [50, 100]) + self.assertEqual(result[0]["datasets"][2]["data"], [30, 70]) + + @patch(PATCH_ACCOUNT_LINKEDIN.format("_request_linkedin")) + def test_get_campaigns(self, mock_request_linkedin): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "elements": [ + {"id": "123", "name": "Campaign A"}, + {"id": "456", "name": "Campaign B"}, + ] + } + mock_request_linkedin.return_value = mock_response + linkedin_account = self.SocialAccountLinkedin + + startDate = datetime(2025, 1, 1) + endDate = datetime(2025, 1, 31) + + result = linkedin_account._get_campaigns( + start_date=startDate, end_date=endDate, campaign_ids=["123"] + ) + + start_time, end_time = _generate_timestamps(startDate, endDate) + + self.assertEqual(len(result), 2) + self.assertEqual(result[0]["id"], "123") + self.assertEqual(result[1]["id"], "456") + start_date = f"(startDate:(values:{start_time})" + end_date = f"endDate:(values:{end_time})" + mock_request_linkedin.assert_called_once_with( + endpoint="/adCampaignsV2", + headers=self.media_linkedin_id._get_linkedin_headers( + linkedin_account.access_token + ), + params_fields=["q", "search", "fields", "count"], + params_values={ + "q": "search", + "search": f"{start_date},{end_date}," + "test:true,campaigns:(values:List(123)))", + "fields": _FIELDS_CAMPAIGN_LINKEDIN, + "count": 100, + }, + params_values_char_ignore={"search": [{"1,2,3,4,5,6,7": ":"}]}, + return_json=False, + linkedin_v2=True, + format_quote=True, + ) + + with self.assertRaises(ValidationError): + mock_request_linkedin.return_value = MagicMock(status_code=403) + linkedin_account._get_campaigns( + start_date=startDate, end_date=endDate, campaign_ids=["420"] + ) + + @patch(PATCH_ACCOUNT_LINKEDIN.format("_request_linkedin")) + def test_get_statistics(self, mock_request_linkedin): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "elements": [ + { + "campaign": "123", + "statistics": {"clickCount": 100, "impressionCount": 500}, + }, + { + "campaign": "456", + "statistics": {"clickCount": 200, "impressionCount": 600}, + }, + ] + } + mock_request_linkedin.return_value = mock_response + + linkedin_account = self.SocialAccountLinkedin + + result = linkedin_account._get_statistics( + ads_ids=["123", "456"], + start_date=datetime(2025, 1, 1), + end_date=datetime(2025, 1, 31), + ) + + self.assertEqual(len(result), 2) + self.assertEqual(result[0]["campaign"], "123") + self.assertEqual(result[1]["campaign"], "456") + start_range = "(start:(year:2025,month:1,day:1)" + end_range = "end:(year:2025,month:1,day:31))" + mock_request_linkedin.assert_called_once_with( + endpoint="/adAnalyticsV2", + headers=linkedin_account.media_id._get_linkedin_headers( + linkedin_account.access_token + ), + params_fields=[ + "q", + "pivots", + "timeGranularity", + "dateRange", + "fields", + "count", + "accounts", + ], + params_values={ + "q": "statistics", + "pivots": ["CREATIVE"], + "timeGranularity": "ALL", + "dateRange": f"{start_range},{end_range}", + "fields": _FIELDS_STATISTIC_LINKEDIN, + "count": 100, + "accounts": [ + "urn:li:sponsoredAccount:123", + "urn:li:sponsoredAccount:456", + ], + }, + params_values_char_ignore={"dateRange": [{"all": ":"}]}, + return_json=False, + linkedin_v2=True, + format_quote=True, + ) + + with self.assertRaises(ValidationError): + mock_request_linkedin.return_value = MagicMock(status_code=403) + linkedin_account._get_statistics( + ads_ids=["423", "756"], + start_date=datetime(2025, 1, 1), + end_date=datetime(2025, 1, 31), + ) + + @patch.object(SocialAccount, "_get_statistics") + def test_get_statistics_ads_calls_internal_method(self, mock_get_statistics): + ads_ids = [123, 456] + start_date = datetime(2024, 1, 1) + end_date = datetime(2024, 1, 31) + + expected_result = [{"mock": "data"}] + mock_get_statistics.return_value = expected_result + + result = self.SocialAccountLinkedin._get_statistics_ads( + ads_ids, start_date, end_date + ) + + mock_get_statistics.assert_called_once_with( + ads_ids=ads_ids, + start_date=start_date, + end_date=end_date, + ) + self.assertEqual(result, expected_result) + + @patch(PATCH_ACCOUNT_LINKEDIN.format("_get_statistics_ads")) + @patch(PATCH_ACCOUNT_LINKEDIN.format("_get_campaigns")) + @patch(PATCH_ACCOUNT_LINKEDIN.format("_get_posts")) + @patch(PATCH_ACCOUNT_LINKEDIN.format("_request_linkedin")) + def test_load_ads( + self, + mock_request_linkedin, + mock_get_posts, + mock_get_campaigns, + mock_get_statistics_ads, + ): + # Arrange + fake_ads = [ + { + "id": 1, + "reference": "ref1", + "campaign": "urn:li:sponsoredCampaign:123", + "changeAuditStamps": {"created": {"time": 1735689600000}}, + "servingStatuses": ["ACTIVE"], + } + ] + fake_stats = [ + { + "pivotValues": ["urn:li:sponsoredAccount:1"], + "clicks": 10, + } + ] + fake_campaigns = [ + { + "id": 123, + "account": "urn:li:sponsoredAccount:999", + } + ] + fake_posts = { + "ref1": { + "id": "ref1", + "specificContent": { + "com.linkedin.ugc.ShareContent": { + "shareCommentary": {"text": "Test post"} + } + }, + } + } + + mock_request_linkedin.return_value = MagicMock( + status_code=200, json=lambda: {"elements": fake_ads} + ) + mock_get_statistics_ads.return_value = fake_stats + mock_get_campaigns.return_value = fake_campaigns + mock_get_posts.return_value = fake_posts + + mock_account = MagicMock() + mock_account.media_type = "linkedin" + mock_account._get_default_filter_date.side_effect = ( + lambda s, e, time_date=False: ("2025-01-01", "2025-01-31") + if not time_date + else (1735689600000, 1738281600000) + ) + + with patch( + PATCH_ACCOUNT_LINKEDIN.format("_get_default_filter_date"), + self.SocialAccountLinkedin._get_default_filter_date, + ): + mock_account._request_linkedin = mock_request_linkedin + mock_account._get_statistics_ads = mock_get_statistics_ads + mock_account._get_campaigns = mock_get_campaigns + mock_account._get_posts = mock_get_posts + + result = self.SocialAccountLinkedin._load_ads( + start_date="2025-01-01", end_date="2025-01-31" + ) + + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["id"], 1) + self.assertEqual(result[0]["post"]["name"], "Test post") + self.assertEqual(result[0]["campaign"]["id"], 123) + self.assertEqual(result[0]["statistic"]["clicks"], 10) + self.assertIn("url", result[0]) + + with self.assertRaises(ValidationError): + mock_request_linkedin.return_value = MagicMock(status_code=403) + self.SocialAccountLinkedin._load_ads( + start_date="2025-01-01", end_date="2025-01-31" + ) + + def test_get_restli_client(self): + result = self.SocialAccountLinkedin._get_restli_client() + self.assertIsInstance(result, RestliClient) + + @patch(PATCH_ACCOUNT_LINKEDIN.format("_request_linkedin")) + def test_validate_linkedin_access_token(self, mock_request_linkedin): + mock_request_linkedin.return_value = {"active": True} + result = self.SocialAccountLinkedin.validate_linkedin_access_token("token") + self.assertTrue(result) + + mock_request_linkedin.return_value = {"active": False} + result = self.SocialAccountLinkedin.validate_linkedin_access_token("token") + self.assertFalse(result) + + self.assertEqual(mock_request_linkedin.call_count, 2) + + @patch(PATCH_ACCOUNT_LINKEDIN.format("_get_restli_client")) + @patch(PATCH_ACCOUNT_LINKEDIN.format("_prepare_images_for_post")) + @patch(PATCH_ACCOUNT_LINKEDIN.format("_prepare_videos_for_post")) + def test_create_restclient_linkedin( + self, mock_videos, mock_images, mock_get_restli_client + ): + mock_client = Mock() + mock_images.image_datas = ["dataimage,1", "dataimage,2", "dataimage,3"] + mock_videos.return_value = [] + mock_response = Mock(status_code=201, entity_id="XYZ123") + mock_client.create.return_value = mock_response + mock_get_restli_client.return_value = mock_client + result = self.SocialAccountLinkedin.create_restclient_linkedin( + resource_path="/", + message="", + image_ids=[], + video_ids=[], + ) + self.assertEqual(result, "XYZ123") + + mock_images.return_value = [] + mock_videos.return_value = [4, 5, 6] + mock_response = Mock(status_code=201, entity_id="XYZ123") + mock_client.create.return_value = mock_response + mock_get_restli_client.return_value = mock_client + result = self.SocialAccountLinkedin.create_restclient_linkedin( + resource_path="/", + message="", + image_ids=[], + video_ids=[], + ) + self.assertEqual(result, "XYZ123") + + mock_response = Mock(status_code=201, entity_id=None) + mock_client.create.return_value = mock_response + mock_get_restli_client.return_value = mock_client + result = self.SocialAccountLinkedin.create_restclient_linkedin( + resource_path="/", + message="", + image_ids=[], + video_ids=[], + ) + self.assertFalse(result) + + self.assertEqual(mock_images.call_count, 3) + self.assertEqual(mock_videos.call_count, 3) + + def test_get_default_filter_date(self): + result = self.SocialAccountLinkedinData._get_default_filter_date( + start_date=datetime.now(), + end_date=datetime.now() + timedelta(days=30), + time_date=True, + ) + self.assertIsInstance(result, tuple) + + @patch("odoo.addons.social_media_linkedin.models.social_account.requests.request") + def test_request_linkedin(self, mock_request): + url_test = "https://api-fake.linkedin.com/v2/test" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.url = url_test + mock_request.return_value = mock_response + + result = self.SocialAccount._request_linkedin( + complete_url=url_test, + return_json=True, + params_fields=["authors"], + params_values={ + "q": "authors", + "authors": ["urn:li:organization:123456789"], + }, + ) + self.assertEqual(result, mock_response.json()) + + mock_request._URL_V2_LINKEDIN = "https://api-fake.linkedin.com" + result = self.SocialAccount._request_linkedin( + linkedin_v2=True, + return_json=True, + endpoint="/test", + params_fields=["authors"], + params_values={ + "q": "authors", + "authors": ["urn:li:organization:123456789"], + }, + ) + self.assertEqual(result, mock_response.json()) + + mock_request._URL_REST_LINKEDIN = "https://api-rest-fake.linkedin.com" + result = self.SocialAccount._request_linkedin( + token="fake-token", + return_json=True, + endpoint="/test-api-rest", + params_fields=["authors"], + params_values={ + "q": "authors", + "authors": ["urn:li:organization:123456789"], + }, + ) + self.assertEqual(result, mock_response.json()) + + self.assertEqual(mock_request.call_count, 3) + + @patch(PATCH_ACCOUNT_LINKEDIN.format("_load_ads")) + @patch(PATCH_ACCOUNT_LINKEDIN.format("search")) + def test_load_ads_accounts(self, mock_search, mock_load_ads): + mock_load_ads_data = MagicMock() + mock_load_ads_data.ads_linkedin = [ + { + "media_type": "linkedin", + "statistic": {"clicks": 10}, + } + ] + mock_load_ads.return_value = mock_load_ads_data + mock_search.return_value = [self.SocialAccountLinkedin] + self.SocialAccount._load_ads_accounts() + self.assertEqual(mock_search.call_count, 1) + + def test_unique_account(self): + with self.assertRaises(ValidationError): + self.SocialAccountLinkedin.unique_account( + linkedin_client_id="fake-client-id", linkedin_secret="fake-secret" + ) + + def test_update_account(self): + res = self.SocialAccountLinkedin.update_account() + self.assertEqual(res["context"]["default_linkedin_client"], "fake-client-id") + self.assertEqual(res["context"]["default_linkedin_secret"], "fake-secret") + + def test_refresh_token(self): + fake_response = { + "access_token": "fake-access-token", + "refresh_token": "fake-refresh-token", + "expires_in": 3600, + } + with self._mock_linkedin( + return_value=fake_response, account=self.SocialAccountLinkedin + ) as mock_request: + self.SocialAccountLinkedin._refresh_token() + mock_value = mock_request.return_value + self.assertEqual(mock_value.get("access_token"), "fake-access-token") + self.assertEqual(mock_value.get("refresh_token"), "fake-refresh-token") + self.assertEqual(mock_value.get("expires_in"), 3600) + mock_request.assert_called_once() + + mock_response = MagicMock() + mock_response.text.return_value = "Error" + with self._mock_linkedin( + return_value=mock_response, account=self.SocialAccountLinkedin + ) as mock_request: + with self.assertRaises(ValidationError): + self.SocialAccountLinkedin._refresh_token() + + mock_request.assert_called_once() + + @patch(PATCH_ACCOUNT_LINKEDIN.format("_request_linkedin")) + def test_get_access_token_linkedin(self, mock_request_linkedin): + mock_request_linkedin.return_value = "fake-csrf-token" + result = self.SocialAccountLinkedin.get_access_token_linkedin( + "CODE", "/web", {"state": "fake-csrf-token"} + ) + self.assertEqual(result[2], "fake-csrf-token") + + @patch(PATCH_ACCOUNT_LINKEDIN.format("_request_linkedin")) + def test_get_account_linkedin(self, mock_request_linkedin): + mock_request_linkedin.side_effect = [ + {"elements": [{"organization": "urn:li:organization:1153624578"}]}, + { + "id": "1153624578", + "vanityName": "userLinkedin", + "logo": "logo_test", + "name": {"localized": {"en_US": "ORGANITATION X"}}, + }, + "logo_test", + ] + result = self.SocialAccountLinkedin.get_account_linkedin("TOKEN-TEST") + self.assertEqual(result[0]["id"], "1153624578") + self.assertEqual(result[0]["localizedName"], "ORGANITATION X") + self.assertEqual(result[0]["vanityName"], "userLinkedin") + self.assertIsNone(result[0]["logo"]) + + def test_get_url_redirect(self): + result = self.wizard_account_id._get_url_redirect() + self.assertEqual(result, self.url_callback) + + with patch.object(type(self.WizardAccount), "_get_url_redirect") as url: + self.WizardAccount._get_url_redirect() + url.assert_called_once() + + def test_generate_code(self): + result = self.wizard_account_id._generate_code() + self.assertEqual(len(result), 10) + + def test_action_add_account(self): + with ( + patch.object( + type(self.wizard_account_id), + "_get_url_redirect", + return_value=self.url_callback, + ), + patch.object( + type(self.wizard_account_id), + "_generate_code", + return_value="fake-code-token", + ), + ): + result = self.wizard_account_id._action_add_account() + self.assertIn("fake-client-id", result["url"]) + self.assertEqual(result["type"], "ir.actions.act_url") + + result = self.wizard_account_id.with_context( + only_url=True + )._action_add_account() + self.assertIn("fake-client-id", result) + + def test_action_valid_add_account(self): + with patch.object(type(self.SocialAccount), "unique_account") as uni_acc: + self.wizard_account_id._action_valid_add_account() + uni_acc.assert_called_once() + + def test_update_account_keys(self): + with patch.object( + type(self.wizard_account_id), + "_update_account", + ) as upd_acc: + self.wizard_account_id._update_account() + upd_acc.assert_called_once() + + self.wizard_account_id.write( + { + "update_keys": True, + "account_id": self.SocialAccountLinkedin.id, + } + ) + result = self.wizard_account_id._update_account() + self.assertEqual(result["type"], "ir.actions.act_url") + self.assertEqual(result["target"], "self") + self.assertEqual( + self.SocialAccountLinkedin.linkedin_client_id, + self.wizard_account_id.linkedin_client, + ) + self.assertEqual( + self.SocialAccountLinkedin.linkedin_secret, + self.wizard_account_id.linkedin_secret, + ) + + @patch(PATCH_SOCIAL_BASE_MIXIN.format("_notify_user_client")) + @patch(PATCH_ACCOUNT_LINKEDIN.format("_refresh_token")) + def test_update_account_token(self, mock_refresh_linkedin, mock_notify_user): + self.wizard_account_id.write( + { + "update_keys": False, + "update_token": True, + "account_id": self.SocialAccountLinkedin.id, + } + ) + mock_refresh_linkedin.return_value = { + "access_token": "fake-access-token", + "refresh_token": "fake-refresh-token", + "expires_in": 1597560000, + } + self.wizard_account_id._update_account() + mock_notify_user.assert_called_once() + self.assertEqual(self.SocialAccountLinkedin.access_token, "fake-access-token") + self.assertEqual( + self.SocialAccountLinkedin.refresh_access_token, "fake-refresh-token" + ) + + @patch(PATCH_ACCOUNT_LINKEDIN.format("get_account_linkedin")) + def test_update_account_organization(self, mock_linkedin): + self.wizard_account_id.write( + { + "account_id": self.SocialAccountLinkedin.id, + } + ) + mock_linkedin.return_value = [ + { + "localizedName": "Localized X", + "vanityName": "Vanity X", + "logo": self.VALID_PNG_B64, + } + ] + self.wizard_account_id._update_account() + self.assertEqual(self.SocialAccountLinkedin.name, "Localized X") + self.assertEqual(self.SocialAccountLinkedin.username, "Vanity X") + mock_linkedin.assert_called_once() diff --git a/social_media_linkedin/tests/test_common_linkedin.py b/social_media_linkedin/tests/test_common_linkedin.py new file mode 100644 index 0000000000..68d34a656b --- /dev/null +++ b/social_media_linkedin/tests/test_common_linkedin.py @@ -0,0 +1,159 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import base64 + +from odoo.fields import Command +from odoo.tools import hmac + +from odoo.addons.mail.tests.common import mail_new_test_user +from odoo.addons.social_media_base.tests.test_social_common import ( + TestSocialMediaBaseCommon, +) + +PATCH_ACCOUNT_LINKEDIN = ( + "odoo.addons.social_media_linkedin.models." "social_account.SocialAccount.{}" +) +PATCH_POST_ACCOUNT_LINKEDIN = ( + "odoo.addons.social_media_linkedin.models." + "social_post_account.SocialPostAccount.{}" +) +PATCH_CONTROLLER_LINKEDIN = ( + "odoo.addons.social_media_linkedin.controllers." + "social_media_linkedin.SocialMediaLinkedin.{}" +) + + +class TestSocialCommonLinkedin(TestSocialMediaBaseCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.image_base64 = base64.b64encode(b"testimage").decode("utf-8") + cls.video_data = base64.b64encode(b"testvideo").decode("utf-8") + cls.VALID_PNG_B64 = ( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQYV2NgYGBgAAAA" + "BAABJzQnCgAAAABJRU5ErkJggg==" + ) + cls.WizardAccount = cls.env["wizard.social.account"] + cls.wizard_account_id = cls.WizardAccount.create( + { + "media_id": cls.env.ref( + "social_media_linkedin.social_media_linkedin" + ).id, + "csrf_state_token": "fake-csrf-token", + "linkedin_client": "fake-client-id", + "linkedin_secret": "fake-secret", + } + ) + cls.url_callback = f"{cls.wizard_account_id.get_base_url()}/linkedin/callback" + cls.media_linkedin_id = cls.SocialMedia.create( + { + "name": "linkedin", + "media_type": "linkedin", + } + ) + + cls.SocialAccountLinkedin = cls.SocialAccount.create( + { + "name": "Linkedin Account", + "media_id": cls.media_linkedin_id.id, + "linkedin_account_urn": "urn:li:organization:123456", + "access_token": "fake-token", + "linkedin_client_id": "fake-client-id", + "linkedin_secret": "fake-secret", + } + ) + + cls.SocialAccountLinkedinData = cls.SocialAccount.create( + { + "name": "Linkedin Account", + "media_id": cls.env.ref( + "social_media_linkedin.social_media_linkedin" + ).id, + "linkedin_account_urn": "urn:li:organization:123456890", + "access_token": "fake-token", + } + ) + + cls.SocialPostLinkedin = cls.SocialPost.create( + { + "message": "Test Message", + "account_ids": [Command.set(cls.SocialAccountLinkedin.ids)], + } + ) + + post_account = { + "message": "Test Message", + "account_id": cls.SocialAccountLinkedin.id, + "media_id": cls.media_linkedin_id.id, + "post_id": cls.SocialPostLinkedin.id, + "linkedin_post_account_urn": "1234567890", + "state": "posted", + } + + cls.SocialPostAccountLinkedin = cls.SocialPostAccount.create(post_account) + + post_account.update( + { + "state": "ready", + "linkedin_post_account_urn": False, + } + ) + cls.SocialPostAccountReadyLinkedin = cls.SocialPostAccount.create(post_account) + + cls.SocialCampaignGroupLinkedin = cls.UtmGroupCampaign.create( + { + "name": "Campaign Group 1", + "linkedin_urn": "urn:li:sponsoredCampaignGroup:456", + "total_budget": 10000, + "currency_id": cls.env.ref("base.USD").id, + } + ) + + cls.SocialCampaignLinkedin = cls.UtmCampaign.create( + { + "name": "Campaign 1", + "campaign_group_id": cls.SocialCampaignGroupLinkedin.id, + "currency_id": cls.SocialCampaignGroupLinkedin.currency_id.id, + "linkedin_urn": "urn:li:sponsoredCampaign:001", + "media_id": cls.media_linkedin_id.id, + } + ) + + cls.SocialCampaignLinkedin2 = cls.UtmCampaign.create( + { + "name": "Campaign 2", + "campaign_group_id": cls.SocialCampaignGroupLinkedin.id, + "currency_id": cls.SocialCampaignGroupLinkedin.currency_id.id, + "linkedin_urn": "urn:li:sponsoredCampaign:002", + } + ) + + cls.SocialPostCampaignLinkedin = cls.SocialPost.create( + { + "message": "Test Message for Campaign", + "account_ids": [Command.set(cls.SocialAccountLinkedin.ids)], + "campaign_id": cls.SocialCampaignLinkedin.id, + } + ) + values = { + "message": "Test Message for Campaign", + "account_id": cls.SocialAccountLinkedin.id, + "media_id": cls.media_linkedin_id.id, + "post_id": cls.SocialPostCampaignLinkedin.id, + } + cls.SocialPostAccountCampaignLinkedin = cls.SocialPostAccount.create(values) + cls.admin_media_linkedin = mail_new_test_user( + cls.env, + groups="base.group_user,base.group_system", + login="admin_media_linkedin", + name="Admin Media Linkedin", + signature="--\nMEDIAX", + ) + + def generate_code(self, code_generated="fake-code-token"): + return hmac( + self.env(su=True), + f"{self.media_linkedin_id.media_type}-account-{code_generated}-csrf-token", + self.media_linkedin_id.id, + ) diff --git a/social_media_linkedin/tests/test_controller.py b/social_media_linkedin/tests/test_controller.py new file mode 100644 index 0000000000..762ae7974c --- /dev/null +++ b/social_media_linkedin/tests/test_controller.py @@ -0,0 +1,20 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.tests.common import HttpCase + +from odoo.addons.social_media_linkedin.controllers.social_media_linkedin import ( + SocialMediaLinkedin, +) + + +class TestSocialController(HttpCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.controller = SocialMediaLinkedin() + + def test_social_linkedin_webhook(self): + controller = SocialMediaLinkedin() + result = controller.social_linkedin_webhook() + self.assertTrue(result) diff --git a/social_media_linkedin/tests/test_media_linkedin.py b/social_media_linkedin/tests/test_media_linkedin.py new file mode 100644 index 0000000000..8ceaeffdf2 --- /dev/null +++ b/social_media_linkedin/tests/test_media_linkedin.py @@ -0,0 +1,37 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from unittest.mock import patch + +from odoo.addons.social_media_linkedin.tests.test_common_linkedin import ( + TestSocialCommonLinkedin, +) + +PATCH_UTILS = "odoo.addons.social_media_linkedin.social_linkedin_utils.{}" +PATCH_BASE_LINKEDIN = "odoo.addons.social_media_linkedin.models.{}" +PATCH_LINKEDIN_MEDIA = PATCH_BASE_LINKEDIN.format("social_media.SocialMedia.{}") + + +class TestSocialNetworkLinkedin(TestSocialCommonLinkedin): + @classmethod + def setUpClass(cls): + super().setUpClass() + + @patch( + PATCH_UTILS.format("_HEADERS_LINKEDIN"), + { + "X-Restli-Protocol-Version": "2.0.0", + "LinkedIn-Version": "202411", + }, + ) + def test_get_linkedin_headers_with_token_and_content_type(self): + access_token = "fake-token" + content_type = "application/json" + headers = self.SocialMedia._get_linkedin_headers(access_token, content_type) + + self.assertIn("Authorization", headers) + self.assertEqual(headers["Authorization"], f"Bearer {access_token}") + self.assertIn("Content-Type", headers) + self.assertEqual(headers["Content-Type"], content_type) + self.assertIn("LinkedIn-Version", headers) + self.assertIn("X-Restli-Protocol-Version", headers) diff --git a/social_media_linkedin/tests/test_post_linkedin.py b/social_media_linkedin/tests/test_post_linkedin.py new file mode 100644 index 0000000000..793155a109 --- /dev/null +++ b/social_media_linkedin/tests/test_post_linkedin.py @@ -0,0 +1,397 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import base64 +from unittest.mock import MagicMock, patch + +from odoo.exceptions import ValidationError + +from odoo.addons.social_media_base.tests.test_social_common import ( + PATCH_SOCIAL_BASE_UTILS, +) +from odoo.addons.social_media_linkedin.tests.test_common_linkedin import ( + PATCH_ACCOUNT_LINKEDIN, + PATCH_POST_ACCOUNT_LINKEDIN, + TestSocialCommonLinkedin, +) + + +class TestSocialPostLinkedin(TestSocialCommonLinkedin): + @classmethod + def setUpClass(cls): + super().setUpClass() + + @patch("odoo.addons.social_media_linkedin.models.social_account.requests.get") + def test_get_assets_save(self, mock_get): + fake_content = b"fake image data" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.content = fake_content + mock_get.return_value = mock_response + media_1 = { + "media": "test_image.jpg", + "originalUrl": "https://fake-url.com/test_image.jpg", + } + self.env["ir.attachment"].create( + { + "name": "existing_image.jpg", + "type": "binary", + "datas": base64.b64encode(b"existing").decode(), + } + ) + share_content = { + "media": [ + media_1, + { + "media": "existing_image.jpg", + "originalUrl": "https://fake-url.com/existing_image.jpg", + }, + ] + } + with patch.object( + type(self.SocialAccountLinkedin), + "_request_linkedin", + return_value=mock_response, + ): + attachments = self.SocialPostAccountLinkedin._get_assets_save(share_content) + self.assertEqual(len(attachments), 1) + self.assertEqual(attachments[0][2]["name"], "test_image.jpg") + self.assertEqual(attachments[0][2]["datas"], base64.b64encode(fake_content)) + + @patch(PATCH_ACCOUNT_LINKEDIN.format("_request_linkedin")) + def test_linkedin_advertising_accounts_success(self, mock_request_linkedin): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "paging": {"total": 1}, + "elements": [{"id": 123, "test": True}], + } + mock_request_linkedin.return_value = mock_response + ad_account_id = self.SocialPostAccountLinkedin._linkedin_advertising_accounts() + self.assertEqual(ad_account_id, "urn:li:sponsoredAccount:123") + mock_request_linkedin.assert_called_once() + + @patch(PATCH_ACCOUNT_LINKEDIN.format("_request_linkedin")) + def test_linkedin_advertising_accounts_error(self, mock_request_linkedin): + mock_response = MagicMock() + mock_response.status_code = 403 + mock_response.json.return_value = {"message": "Unauthorized"} + mock_request_linkedin.return_value = mock_response + with self.assertRaises(Exception) as context: + self.SocialPostAccountLinkedin._linkedin_advertising_accounts() + self.assertIn( + "Error get advertising account in Linkedin", str(context.exception) + ) + + @patch(PATCH_ACCOUNT_LINKEDIN.format("_request_linkedin")) + @patch(PATCH_POST_ACCOUNT_LINKEDIN.format("_linkedin_advertising_accounts")) + def test_existing_campaign_group(self, mock_ad_accounts, mock_request): + mock_ad_accounts.return_value = "urn:li:sponsoredAccount:123" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_request.return_value = mock_response + urn = self.SocialPostAccountCampaignLinkedin._action_campaign_group() + self.assertEqual(urn, "urn:li:sponsoredCampaignGroup:456") + + @patch(PATCH_SOCIAL_BASE_UTILS.format("_generate_timestamps")) + @patch(PATCH_ACCOUNT_LINKEDIN.format("_request_linkedin")) + @patch(PATCH_POST_ACCOUNT_LINKEDIN.format("_linkedin_advertising_accounts")) + def test_create_new_campaign_group( + self, mock_ad_accounts, mock_request, mock_timestamps + ): + mock_ad_accounts.return_value = "urn:li:sponsoredAccount:999" + mock_request.side_effect = [ + MagicMock(status_code=404), + MagicMock(status_code=201, headers={"Location": "/adCampaignGroupsV2/456"}), + ] + mock_timestamps.return_value = (111111, 222222) + urn = self.SocialPostAccountCampaignLinkedin._action_campaign_group() + self.assertEqual(urn, "urn:li:sponsoredCampaignGroup:456") + self.assertEqual( + self.SocialCampaignGroupLinkedin.linkedin_urn, + "urn:li:sponsoredCampaignGroup:456", + ) + + # + @patch(PATCH_ACCOUNT_LINKEDIN.format("_request_linkedin")) + @patch(PATCH_POST_ACCOUNT_LINKEDIN.format("_linkedin_advertising_accounts")) + def test_campaign_group_error(self, mock_ad_accounts, mock_request_linkedin): + mock_ad_accounts.return_value = "urn:li:sponsoredAccount:111" + mock_request_linkedin.side_effect = [ + MagicMock(status_code=404), + MagicMock(status_code=400, json=lambda: {"error": "Invalid request"}), + ] + with self.assertRaises(ValidationError) as e: + self.SocialPostAccountCampaignLinkedin._action_campaign_group() + self.assertIn("Error creating group campaign in Linkedin", str(e.exception)) + mock_ad_accounts.assert_called_once() + self.assertEqual(mock_request_linkedin.call_count, 2) + + @patch(PATCH_POST_ACCOUNT_LINKEDIN.format("_linkedin_advertising_accounts")) + @patch(PATCH_ACCOUNT_LINKEDIN.format("_request_linkedin")) + @patch(PATCH_POST_ACCOUNT_LINKEDIN.format("_action_campaign_group")) + def test_existing_campaign(self, mock_group, mock_request, mock_ad_account): + mock_group.return_value = "urn:li:sponsoredCampaignGroup:123" + mock_ad_account.return_value = "urn:li:sponsoredAccount:999" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_request.return_value = mock_response + + urn = self.SocialPostAccountCampaignLinkedin._action_campaign() + self.assertEqual(urn, "urn:li:sponsoredCampaign:001") + + @patch(PATCH_POST_ACCOUNT_LINKEDIN.format("_linkedin_advertising_accounts")) + @patch(PATCH_ACCOUNT_LINKEDIN.format("_request_linkedin")) + @patch(PATCH_POST_ACCOUNT_LINKEDIN.format("_action_campaign_group")) + def test_create_new_campaign(self, mock_group, mock_request, mock_ad_account): + mock_group.return_value = "urn:li:sponsoredCampaignGroup:123" + mock_ad_account.return_value = "urn:li:sponsoredAccount:999" + mock_request.side_effect = [ + MagicMock(status_code=404), + MagicMock(status_code=201, headers={"Location": "/adCampaignsV2/001"}), + ] + urn = self.SocialPostAccountCampaignLinkedin._action_campaign() + self.assertEqual(urn, "urn:li:sponsoredCampaign:001") + self.assertEqual( + self.SocialCampaignLinkedin.linkedin_urn, "urn:li:sponsoredCampaign:001" + ) + + @patch(PATCH_POST_ACCOUNT_LINKEDIN.format("_linkedin_advertising_accounts")) + @patch(PATCH_ACCOUNT_LINKEDIN.format("_request_linkedin")) + @patch(PATCH_POST_ACCOUNT_LINKEDIN.format("_action_campaign_group")) + def test_error_creating_campaign(self, mock_group, mock_request, mock_ad_account): + mock_group.return_value = "urn:li:sponsoredCampaignGroup:456" + mock_ad_account.return_value = "urn:li:sponsoredAccount:000" + mock_request.side_effect = [ + MagicMock(status_code=404), + MagicMock(status_code=400, json=lambda: {"error": "Bad Request"}), + ] + with self.assertRaises(ValidationError) as ctx: + self.SocialPostAccountCampaignLinkedin._action_campaign() + self.assertIn("Error creating campaign in Linkedin", str(ctx.exception)) + + @patch(PATCH_ACCOUNT_LINKEDIN.format("create_restclient_linkedin")) + @patch(PATCH_POST_ACCOUNT_LINKEDIN.format("_action_post"), autospec=True) + def test_action_post_failure(self, mock_super_post, mock_create_post): + mock_super_post.return_value = True + mock_create_post.return_value = False + with patch.object( + type(self.SocialPostAccountReadyLinkedin), + "filter_by_media_types", + return_value=self.env["social.post.account"].browse( + self.SocialPostAccountReadyLinkedin.id + ), + ): + result = self.SocialPostAccountReadyLinkedin._action_post() + self.assertTrue(result) + self.assertFalse( + self.SocialPostAccountReadyLinkedin.linkedin_post_account_urn + ) + self.assertEqual(self.SocialPostAccountReadyLinkedin.state, "ready") + + @patch(PATCH_ACCOUNT_LINKEDIN.format("_request_linkedin")) + def test_like_success(self, mock_request): + author_urn = "urn:li:person:abc" + mock_response = MagicMock() + mock_response.status_code = 201 + mock_request.return_value = mock_response + result = self.SocialPostAccountLinkedin.action_like_post(author_urn=author_urn) + self.assertTrue(result["success"]) + self.assertEqual(result["message"], "") + + mock_response = MagicMock() + mock_response.status_code = 409 + mock_request.return_value = mock_response + result = self.SocialPostAccountLinkedin.action_like_post(author_urn=author_urn) + self.assertFalse(result["success"]) + self.assertEqual(result["message"], "You have already reacted to this post.") + + mock_response = MagicMock() + mock_response.status_code = 404 + mock_request.return_value = mock_response + result = self.SocialPostAccountLinkedin.action_like_post(author_urn=author_urn) + self.assertFalse(result["success"]) + self.assertEqual( + result["message"], "The post does not exist or has been deleted." + ) + + mock_response = MagicMock() + mock_response.status_code = 500 + mock_response.json.return_value = {"message": "Internal error occurred."} + mock_request.return_value = mock_response + + result = self.SocialPostAccountLinkedin.action_like_post(author_urn=author_urn) + self.assertFalse(result["success"]) + self.assertEqual(result["message"], "Internal error occurred.") + + @patch(PATCH_ACCOUNT_LINKEDIN.format("_request_linkedin")) + def test_get_comments_success(self, mock_request): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "elements": [ + { + "id": "comment1", + "message": {"text": "Great post!"}, + "lastModified": {"actor": {"id": "actor1"}, "time": 1609459200000}, + "content": [{"url": "http://example.com/image1.jpg"}], + } + ] + } + mock_request.return_value = mock_response + result = self.SocialPostAccountLinkedin.get_comments() + data = result["data"] + self.assertEqual(len(data), 1) + self.assertEqual(data[0]["id"], "comment1") + self.assertEqual(data[0]["text"], "Great post!") + self.assertEqual(data[0]["actor"]["id"], "actor1") + self.assertEqual(data[0]["images_url"], ["http://example.com/image1.jpg"]) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"elements": []} + mock_request.return_value = mock_response + result = self.SocialPostAccountLinkedin.get_comments() + self.assertEqual(result["data"], []) + + @patch(PATCH_ACCOUNT_LINKEDIN.format("_request_linkedin")) + @patch(PATCH_ACCOUNT_LINKEDIN.format("_prepare_images_for_post")) + def test_create_linkedin_comment_success(self, mock_prepare_images, mock_request): + mock_prepare_images.return_value = [{"media": "asset_123"}] + mock_response = MagicMock() + mock_response.status_code = 201 + mock_response.json.return_value = {"message": "Comment created successfully"} + mock_request.return_value = mock_response + post_data = { + "body": "Great post!", + "attachment_ids": [1], + } + result = self.SocialPostAccountLinkedin.create_linkedin_comment(post_data) + self.assertEqual(result["success"], True) + + mock_response = MagicMock() + mock_response.status_code = 201 + mock_response.json.return_value = {"message": "Comment created successfully"} + mock_request.return_value = mock_response + result = self.SocialPostAccountLinkedin.create_linkedin_comment(post_data) + self.assertEqual(result["success"], True) + + mock_response = MagicMock() + mock_response.status_code = 403 + mock_response.json.return_value = {"message": "Unauthorized"} + mock_request.return_value = mock_response + post_data.update({"attachment_ids": []}) + result = self.SocialPostAccountLinkedin.create_linkedin_comment(post_data) + self.assertEqual(result["success"], False) + self.assertEqual( + result["message"], + "An error occurred while commenting, please try again later.", + ) + + @patch(PATCH_ACCOUNT_LINKEDIN.format("_request_linkedin")) + def test_delete_linkedin_comment_success(self, mock_request): + mock_response = MagicMock() + mock_response.status_code = 204 + mock_request.return_value = mock_response + comment_id = "123456" + actor_urn = "urn:li:person:abc123" + result = self.SocialPostAccountLinkedin.delete_linkedin_comment( + comment_id, actor_urn + ) + self.assertEqual(result["success"], True) + + mock_response = MagicMock() + mock_response.status_code = 500 # Código de error para DELETE + mock_response.json.return_value = {"message": "Internal Server Error"} + mock_request.return_value = mock_response + result = self.SocialPostAccountLinkedin.delete_linkedin_comment( + comment_id, actor_urn + ) + self.assertEqual(result["success"], False) + + mock_response = MagicMock() + mock_response.status_code = 404 + mock_response.json.return_value = {"message": "Not Found"} + mock_request.return_value = mock_response + result = self.SocialPostAccountLinkedin.delete_linkedin_comment( + comment_id, actor_urn + ) + self.assertEqual(result["success"], False) + + @patch(PATCH_ACCOUNT_LINKEDIN.format("_request_linkedin")) + def test_get_linkedin_comment_success(self, mock_request): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_request.return_value = mock_response + result = self.SocialPostAccountLinkedin.get_linkedin_comment() + self.assertEqual(result, True) + + mock_response = MagicMock() + mock_response.status_code = 404 + mock_request.return_value = mock_response + result = self.SocialPostAccountReadyLinkedin.get_linkedin_comment() + self.assertEqual(result, False) + self.assertEqual( + self.SocialPostAccountReadyLinkedin.linkedin_post_account_urn, False + ) + + mock_response = MagicMock() + mock_response.status_code = 500 + mock_response.json.return_value = {"message": "Internal Server Error"} + mock_request.return_value = mock_response + result = self.SocialPostAccountReadyLinkedin.get_linkedin_comment() + self.assertEqual(result, False) + self.assertEqual( + self.SocialPostAccountReadyLinkedin.linkedin_post_account_urn, False + ) + + def test_check_daily_budget(self): + with self.assertRaises(ValidationError): + self.SocialCampaignLinkedin.daily_budget = 5000 + self.SocialCampaignLinkedin2.daily_budget = 5001 + + @patch(PATCH_ACCOUNT_LINKEDIN.format("_request_linkedin")) + @patch(PATCH_POST_ACCOUNT_LINKEDIN.format("_action_campaign")) + def test_action_campaign_post(self, mock_action_campaign, mock_request_linkedin): + mock_action_campaign.return_value = "urn:li:sponsoredCampaign:001" + mock_request_linkedin.side_effect = [ + MagicMock( + status_code=201, headers={"Location": "/adCampaignGroupsV2/123456"} + ), + ] + res = self.SocialPostAccountCampaignLinkedin._action_campaign_post( + self.SocialPostAccountCampaignLinkedin.id + ) + self.assertEqual(res, "123456") + + mock_request_linkedin.side_effect = [ + MagicMock( + status_code=404, headers={"Location": "/adCampaignGroupsV2/123456"} + ), + ] + with self.assertRaises(ValidationError): + self.SocialPostAccountCampaignLinkedin._action_campaign_post( + self.SocialPostAccountCampaignLinkedin.id + ) + + self.assertEqual(mock_action_campaign.call_count, 2) + + def test_action_like_comment(self): + result = self.SocialPostAccountLinkedin.action_like_comment() + self.assertEqual(result, {"success": False, "message": ""}) + + def test_action_like_linkedin_comment(self): + result = self.SocialPostAccountLinkedin._action_like_linkedin_comment(None) + self.assertEqual(result, {"success": False}) + + @patch(PATCH_POST_ACCOUNT_LINKEDIN.format("create_linkedin_comment")) + @patch(PATCH_ACCOUNT_LINKEDIN.format("_request_linkedin")) + def test_create_comment(self, mock_request_linkedin, mock_create_linkedin_comment): + mock_response = MagicMock() + mock_response.status_code = 201 + mock_request_linkedin.return_value = mock_response + result = self.SocialPostAccountLinkedin.create_comment( + {"body": "Test comment", "attachment_ids": [1]} + ) + self.assertTrue(result["success"]) + mock_create_linkedin_comment.assert_called_once() diff --git a/social_media_linkedin/views/social_account_views.xml b/social_media_linkedin/views/social_account_views.xml new file mode 100644 index 0000000000..3433f4e08f --- /dev/null +++ b/social_media_linkedin/views/social_account_views.xml @@ -0,0 +1,54 @@ + + + + + social.account.view.form.inherit.linkedin + social.account + + + +
+
+ + + + + + + + + +
+
+
+
diff --git a/social_media_linkedin/views/social_post_account_views.xml b/social_media_linkedin/views/social_post_account_views.xml new file mode 100644 index 0000000000..790bf89cee --- /dev/null +++ b/social_media_linkedin/views/social_post_account_views.xml @@ -0,0 +1,19 @@ + + + + + social.network.stream.post.view.kanban.inherit + social.post.account + + + + + + + + + diff --git a/social_media_linkedin/views/utm_campaign_views.xml b/social_media_linkedin/views/utm_campaign_views.xml new file mode 100644 index 0000000000..97f90937dc --- /dev/null +++ b/social_media_linkedin/views/utm_campaign_views.xml @@ -0,0 +1,30 @@ + + + + + utm.campaign.view.form.inherit.social.media.linkedin + utm.campaign + + + + + + + + + + + + utm.campaign.view.list.inherit.social.media.linkedin + utm.campaign + + + + + + + + + + diff --git a/social_media_linkedin/views/utm_group_campaign_views.xml b/social_media_linkedin/views/utm_group_campaign_views.xml new file mode 100644 index 0000000000..05c8037de9 --- /dev/null +++ b/social_media_linkedin/views/utm_group_campaign_views.xml @@ -0,0 +1,16 @@ + + + + + utm.group.campaign.list.social.media.linkedin + utm.group.campaign + + + + + + + + + diff --git a/social_media_linkedin/wizards/__init__.py b/social_media_linkedin/wizards/__init__.py new file mode 100644 index 0000000000..7c0be31321 --- /dev/null +++ b/social_media_linkedin/wizards/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import wizard_social_account diff --git a/social_media_linkedin/wizards/wizard_social_account.py b/social_media_linkedin/wizards/wizard_social_account.py new file mode 100644 index 0000000000..3c6b0d6c7c --- /dev/null +++ b/social_media_linkedin/wizards/wizard_social_account.py @@ -0,0 +1,129 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import random +import string +from datetime import date, datetime, timedelta + +from werkzeug.urls import url_encode, url_join + +from odoo import fields, models +from odoo.tools import hmac + +from ..social_linkedin_utils import ( + _SCOPE_LINKEDIN, + _URL_AUTH_V2_LINKEDIN, +) + + +class WizardSocialAccount(models.TransientModel): + _inherit = "wizard.social.account" + + linkedin_client = fields.Char(string="Client ID") + linkedin_secret = fields.Char(string="Client Secret") + csrf_state_token = fields.Char() + + def _get_url_redirect(self): + if self.media_type == "linkedin": + return url_join(self.get_base_url(), "/linkedin/callback") + else: + return super()._get_url_redirect() + + def _generate_code(self, length=10): + caracteres = string.ascii_letters + string.digits + return "".join(random.choices(caracteres, k=length)) + + def _action_add_account(self): + result = super()._action_add_account() + context = dict(self.env.context) + if self.media_type == "linkedin": + self.csrf_state_token = hmac( + self.env(su=True), + f"{self.media_type}-account-{self._generate_code()}-csrf-token", + self.media_id.id, + ) + params = { + "response_type": "code", + "client_id": self.linkedin_client, + "redirect_uri": self._get_url_redirect(), + "state": self.csrf_state_token, + "scope": " ".join(_SCOPE_LINKEDIN), + } + url_aut = f"{_URL_AUTH_V2_LINKEDIN}/authorization?{url_encode(params)}" + if not context.get("only_url", False): + return { + "type": "ir.actions.act_url", + "url": url_aut, + "target": "self", + } + return url_aut + else: + return result + + def _action_valid_add_account(self): + result = super()._action_valid_add_account() + if self.media_type == "linkedin": + self.env["social.account"].sudo().unique_account( + self.linkedin_client, self.linkedin_secret + ) + return result + + def _update_account(self): + if self.media_type == "linkedin": + if self.update_keys or self.update_token: + if self.update_keys: + self.account_id.write( + { + "linkedin_client_id": self.linkedin_client, + "linkedin_secret": self.linkedin_secret, + } + ) + return { + "type": "ir.actions.act_url", + "url": self.with_context(only_url=True)._action_add_account(), + "target": "self", + } + if self.update_token: + token = self.account_id._refresh_token() + self.account_id.write( + { + "access_token": token.get("access_token", False), + "refresh_access_token": token.get("refresh_token", False), + "expire_access_token_date": date.today() + + timedelta(days=token.get("expires_in", 0) / 86400), + "refresh_token_expires_in": date.today() + + timedelta( + days=token.get("refresh_token_expires_in", 0) / 86400 + ), + } + ) + # Notifying the user + if not self.env.context.get("not_notify", False): + self._notify_user_client( + notif_type="social_form_success", + notif_message=self.env._( + "The token was updated successfully" + ), + media="linkedin", + account_name=self.account_id.name, + ) + else: + organizations = self.account_id.get_account_linkedin( + self.account_id.access_token + ) + + for organization in organizations: + self.account_id.write( + { + "name": organization.get("localizedName", False), + "username": organization.get("vanityName", False), + "image_1920": organization.get("logo", False), + } + ) + self.account_id.write( + { + "last_update_account": datetime.now(), + } + ) + else: + return super()._update_account() diff --git a/social_media_linkedin/wizards/wizard_social_account.xml b/social_media_linkedin/wizards/wizard_social_account.xml new file mode 100644 index 0000000000..06d17a8771 --- /dev/null +++ b/social_media_linkedin/wizards/wizard_social_account.xml @@ -0,0 +1,31 @@ + + + + + Associate Social Account + wizard.social.account + + + + + + + + + + diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000000..5c02184e90 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1 @@ +odoo-addon-social_media_base @ git+https://github.com/OCA/social.git@refs/pull/1769/head#subdirectory=social_media_base