diff --git a/.pylintrc b/.pylintrc index f3d017a8f5..a7aec2a055 100644 --- a/.pylintrc +++ b/.pylintrc @@ -92,20 +92,11 @@ enable=anomalous-backslash-in-string, no-write-in-compute, # messages that do not cause the lint step to fail consider-merging-classes-inherited, - create-user-wo-reset-password, - dangerous-filter-wo-user, deprecated-module, - file-not-used, invalid-commit, - missing-manifest-dependency, - missing-newline-extrafiles, missing-readme, - no-utf8-coding-comment, odoo-addons-relative-import, - old-api7-method-defined, redefined-builtin, - too-complex, - unnecessary-utf8-coding-comment, manifest-external-assets diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000..8a9e4160cd --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +# generated from manifests external_dependencies +linkedin-api-client diff --git a/social_media_linkedin/README.rst b/social_media_linkedin/README.rst new file mode 100644 index 0000000000..6863aea37a --- /dev/null +++ b/social_media_linkedin/README.rst @@ -0,0 +1,311 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +===================== +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/license-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/19.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-19-0/social-19-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=19.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/OCA/social/19.0/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/19.0/social_media_linkedin/static/img/readme/FORM_CREATE_APP.png +.. |PRODUCTS| image:: https://raw.githubusercontent.com/OCA/social/19.0/social_media_linkedin/static/img/readme/PRODUCTS.png +.. |CONFIGURE_URL_CALLBACK| image:: https://raw.githubusercontent.com/OCA/social/19.0/social_media_linkedin/static/img/readme/CONFIGURE_URL_CALLBACK.png +.. |ASSOCIATE_ACCOUNT| image:: https://raw.githubusercontent.com/OCA/social/19.0/social_media_linkedin/static/img/readme/ASSOCIATE_ACCOUNT.png +.. |WIZARD_ASSOCIATE_ACCOUNT| image:: https://raw.githubusercontent.com/OCA/social/19.0/social_media_linkedin/static/img/readme/WIZARD_ASSOCIATE_ACCOUNT.png +.. |AUTHORIZE_ACCOUNT| image:: https://raw.githubusercontent.com/OCA/social/19.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/OCA/social/19.0/social_media_linkedin/static/img/readme/CREATE_POST.png +.. |BUTTON_UPDATE_ACCOUNT| image:: https://raw.githubusercontent.com/OCA/social/19.0/social_media_linkedin/static/img/readme/BUTTON_UPDATE_ACCOUNT.png +.. |UPDATE_KEYS| image:: https://raw.githubusercontent.com/OCA/social/19.0/social_media_linkedin/static/img/readme/UPDATE_KEYS.png +.. |UPDATE_TOKEN| image:: https://raw.githubusercontent.com/OCA/social/19.0/social_media_linkedin/static/img/readme/UPDATE_TOKEN.png +.. |ARCHIVE_ACCOUNT| image:: https://raw.githubusercontent.com/OCA/social/19.0/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. + +Post with video or image +------------------------ + +- The API currently only supports creating a post with either an image + or a video, not both at the same time. + +- Only one shareMediaCategory can be sent per post. + + https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/ugc-post-api?view=li-lms-2025-11&tabs=http#mediarichcontent + + |shareMediaCategory| + +.. |shareMediaCategory| image:: https://raw.githubusercontent.com/OCA/social/19.0/social_media_linkedin/static/img/readme/shareMediaCategory.png + +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..43928a52ba --- /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": "19.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/i18n/es_ES.po b/social_media_linkedin/i18n/es_ES.po new file mode 100644 index 0000000000..76c19ba70e --- /dev/null +++ b/social_media_linkedin/i18n/es_ES.po @@ -0,0 +1,247 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * social_media_linkedin +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-12-07 06:02+0000\n" +"PO-Revision-Date: 2025-12-07 06:02+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: social_media_linkedin +#: model:ir.model.fields,help:social_media_linkedin.field_utm_group_campaign__total_budget +msgid "" +"\n" +" Maximum budget that the campaign can spend over its entire duration\n" +" " +msgstr "" +"\n" +"Presupuesto máximo que la campaña puede gastar durante toda su duración\n" +" " + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "" +"An account with this information already exists; please also check archived " +"accounts." +msgstr "" +"Ya existe una cuenta con esta información; por favor, revise también las cuentas archivadas." + +#. module: social_media_linkedin +#: model:ir.model,name:social_media_linkedin.model_wizard_social_account +msgid "Associate Social Media Account" +msgstr "Asociar cuenta de red social" + +#. module: social_media_linkedin +#: model:ir.model.fields,field_description:social_media_linkedin.field_social_post__campaign_id +#: model:ir.model.fields,field_description:social_media_linkedin.field_utm_group_campaign__campaign_ids +msgid "Campaign" +msgstr "Campaña" + +#. module: social_media_linkedin +#: model:ir.model.fields,field_description:social_media_linkedin.field_social_account__linkedin_client_id +#: model:ir.model.fields,field_description:social_media_linkedin.field_wizard_social_account__linkedin_client +msgid "Client ID" +msgstr "ID de cliente" + +#. module: social_media_linkedin +#: model:ir.model.fields,field_description:social_media_linkedin.field_social_account__linkedin_secret +#: model:ir.model.fields,field_description:social_media_linkedin.field_wizard_social_account__linkedin_secret +msgid "Client Secret" +msgstr "Llave secreta de cliente" + +#. module: social_media_linkedin +#: model:ir.model.fields,help:social_media_linkedin.field_utm_campaign__unit_cost +msgid "Cost per post" +msgstr "Costo por publicación" + +#. module: social_media_linkedin +#: model:ir.model.fields,field_description:social_media_linkedin.field_social_post_account__creative_urn +msgid "Creative Urn" +msgstr "" + +#. module: social_media_linkedin +#: model:ir.model.fields,field_description:social_media_linkedin.field_wizard_social_account__csrf_state_token +msgid "Csrf State Token" +msgstr "" + +#. module: social_media_linkedin +#: model:ir.model.fields,field_description:social_media_linkedin.field_utm_campaign__currency_id +#: model:ir.model.fields,field_description:social_media_linkedin.field_utm_group_campaign__currency_id +msgid "Currency" +msgstr "Moneda" + +#. module: social_media_linkedin +#: model:ir.model.fields,field_description:social_media_linkedin.field_utm_campaign__daily_budget +msgid "Daily Budget" +msgstr "Presupuesto diario" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "ERROR NEDD UPDATE %(error)s" +msgstr "" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "Error obtaining information from the organization" +msgstr "Error al obtener información de la organización" + +#. module: social_media_linkedin +#: model:ir.model.fields,field_description:social_media_linkedin.field_social_account__refresh_token_expires_in +msgid "Expire Refresh Token" +msgstr "Fecha de expiración del token de actualización" + +#. module: social_media_linkedin +#: model:ir.model.fields.selection,name:social_media_linkedin.selection__social_media__media_type__linkedin +msgid "Linkedin" +msgstr "" + +#. module: social_media_linkedin +#: model:ir.model.fields,field_description:social_media_linkedin.field_social_account__linkedin_account_id +msgid "Linkedin Account" +msgstr "Cuenta de Linkedin" + +#. module: social_media_linkedin +#: model:ir.model.fields,field_description:social_media_linkedin.field_social_account__linkedin_account_urn +#: model:ir.model.fields,field_description:social_media_linkedin.field_social_post_account__linkedin_account_urn +msgid "Linkedin Account Urn" +msgstr "" + +#. module: social_media_linkedin +#: model:ir.model.fields,field_description:social_media_linkedin.field_social_post_account__linkedin_post_account_urn +msgid "Linkedin Post Account Urn" +msgstr "" + +#. module: social_media_linkedin +#: model:ir.model.fields,field_description:social_media_linkedin.field_utm_campaign__linkedin_urn +#: model:ir.model.fields,field_description:social_media_linkedin.field_utm_group_campaign__linkedin_urn +msgid "Linkedin URN" +msgstr "" + +#. module: social_media_linkedin +#: model:ir.model.fields,help:social_media_linkedin.field_utm_campaign__daily_budget +msgid "Maximum daily campaign spending" +msgstr "Gasto máximo diario de campaña" + +#. module: social_media_linkedin +#: model:ir.model.fields,field_description:social_media_linkedin.field_social_media__media_type +msgid "Media Type" +msgstr "Tipo de red social" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "REFRESH TOKEN: %s" +msgstr "Token de actualización: %s" + +#. module: social_media_linkedin +#: model:ir.model,name:social_media_linkedin.model_social_account +msgid "Social Account" +msgstr "Cuenta de red social" + +#. module: social_media_linkedin +#: model:ir.model,name:social_media_linkedin.model_social_media +msgid "Social Media" +msgstr "Red social" + +#. module: social_media_linkedin +#: model:ir.model,name:social_media_linkedin.model_social_post +msgid "Social Post" +msgstr "Publicación de red social" + +#. module: social_media_linkedin +#: model:ir.model,name:social_media_linkedin.model_social_post_account +msgid "Social Post Account" +msgstr "Publicación de red social" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/utm_campaign.py:0 +#, python-format +msgid "" +"The amount you want to add exceeds\n" +" the campaign group limit." +msgstr "" +"La cantidad que desea agregar excede el límite del grupo de campañas." + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "The token is %(token_valid)s valid." +msgstr "El token es %(token_valid)s válido." + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "The token is valid." +msgstr "El token es válido." + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/wizards/wizard_social_account.py:0 +#, python-format +msgid "The token was updated successfully" +msgstr "El token se actualizó correctamente" + +#. module: social_media_linkedin +#: model:ir.model.fields,field_description:social_media_linkedin.field_utm_group_campaign__total_budget +msgid "Total Budget" +msgstr "Presupuesto total" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "UPLOADING VIDEO: %(error_video)s" +msgstr "" + +#. module: social_media_linkedin +#: model:ir.model,name:social_media_linkedin.model_utm_campaign +msgid "UTM Campaign" +msgstr "" + +#. module: social_media_linkedin +#: model:ir.model,name:social_media_linkedin.model_utm_group_campaign +msgid "UTM Group Campaign" +msgstr "" + +#. module: social_media_linkedin +#: model:ir.model.fields,field_description:social_media_linkedin.field_utm_campaign__unit_cost +msgid "Unit Cost" +msgstr "Costo unitario" + +#. module: social_media_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_linkedin.social_account_kanban_view_inherit +msgid "Validate token" +msgstr "Validar token" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_post.py:0 +#, python-format +msgid "" +"You have selected images and videos for this post. However, the social media" +" Linkedin does not allow combining both types of content in the same post. " +"Therefore, only the images will be published. If you wish to publish a " +"video, please remove the images from this post or create a separate post." +msgstr "" +"Has seleccionado imágenes y vídeos para esta publicación. Sin embargo, las red social " +"Linkedin no permite combinar ambos tipos de contenido en la misma publicación." +"Por lo tanto, solo se publicarán las imágenes. Si deseas publicar un vídeo, elimina las imágenes de esta publicación o crea una publicación aparte." diff --git a/social_media_linkedin/i18n/social_media_linkedin.pot b/social_media_linkedin/i18n/social_media_linkedin.pot new file mode 100644 index 0000000000..76c19ba70e --- /dev/null +++ b/social_media_linkedin/i18n/social_media_linkedin.pot @@ -0,0 +1,247 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * social_media_linkedin +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-12-07 06:02+0000\n" +"PO-Revision-Date: 2025-12-07 06:02+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: social_media_linkedin +#: model:ir.model.fields,help:social_media_linkedin.field_utm_group_campaign__total_budget +msgid "" +"\n" +" Maximum budget that the campaign can spend over its entire duration\n" +" " +msgstr "" +"\n" +"Presupuesto máximo que la campaña puede gastar durante toda su duración\n" +" " + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "" +"An account with this information already exists; please also check archived " +"accounts." +msgstr "" +"Ya existe una cuenta con esta información; por favor, revise también las cuentas archivadas." + +#. module: social_media_linkedin +#: model:ir.model,name:social_media_linkedin.model_wizard_social_account +msgid "Associate Social Media Account" +msgstr "Asociar cuenta de red social" + +#. module: social_media_linkedin +#: model:ir.model.fields,field_description:social_media_linkedin.field_social_post__campaign_id +#: model:ir.model.fields,field_description:social_media_linkedin.field_utm_group_campaign__campaign_ids +msgid "Campaign" +msgstr "Campaña" + +#. module: social_media_linkedin +#: model:ir.model.fields,field_description:social_media_linkedin.field_social_account__linkedin_client_id +#: model:ir.model.fields,field_description:social_media_linkedin.field_wizard_social_account__linkedin_client +msgid "Client ID" +msgstr "ID de cliente" + +#. module: social_media_linkedin +#: model:ir.model.fields,field_description:social_media_linkedin.field_social_account__linkedin_secret +#: model:ir.model.fields,field_description:social_media_linkedin.field_wizard_social_account__linkedin_secret +msgid "Client Secret" +msgstr "Llave secreta de cliente" + +#. module: social_media_linkedin +#: model:ir.model.fields,help:social_media_linkedin.field_utm_campaign__unit_cost +msgid "Cost per post" +msgstr "Costo por publicación" + +#. module: social_media_linkedin +#: model:ir.model.fields,field_description:social_media_linkedin.field_social_post_account__creative_urn +msgid "Creative Urn" +msgstr "" + +#. module: social_media_linkedin +#: model:ir.model.fields,field_description:social_media_linkedin.field_wizard_social_account__csrf_state_token +msgid "Csrf State Token" +msgstr "" + +#. module: social_media_linkedin +#: model:ir.model.fields,field_description:social_media_linkedin.field_utm_campaign__currency_id +#: model:ir.model.fields,field_description:social_media_linkedin.field_utm_group_campaign__currency_id +msgid "Currency" +msgstr "Moneda" + +#. module: social_media_linkedin +#: model:ir.model.fields,field_description:social_media_linkedin.field_utm_campaign__daily_budget +msgid "Daily Budget" +msgstr "Presupuesto diario" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "ERROR NEDD UPDATE %(error)s" +msgstr "" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "Error obtaining information from the organization" +msgstr "Error al obtener información de la organización" + +#. module: social_media_linkedin +#: model:ir.model.fields,field_description:social_media_linkedin.field_social_account__refresh_token_expires_in +msgid "Expire Refresh Token" +msgstr "Fecha de expiración del token de actualización" + +#. module: social_media_linkedin +#: model:ir.model.fields.selection,name:social_media_linkedin.selection__social_media__media_type__linkedin +msgid "Linkedin" +msgstr "" + +#. module: social_media_linkedin +#: model:ir.model.fields,field_description:social_media_linkedin.field_social_account__linkedin_account_id +msgid "Linkedin Account" +msgstr "Cuenta de Linkedin" + +#. module: social_media_linkedin +#: model:ir.model.fields,field_description:social_media_linkedin.field_social_account__linkedin_account_urn +#: model:ir.model.fields,field_description:social_media_linkedin.field_social_post_account__linkedin_account_urn +msgid "Linkedin Account Urn" +msgstr "" + +#. module: social_media_linkedin +#: model:ir.model.fields,field_description:social_media_linkedin.field_social_post_account__linkedin_post_account_urn +msgid "Linkedin Post Account Urn" +msgstr "" + +#. module: social_media_linkedin +#: model:ir.model.fields,field_description:social_media_linkedin.field_utm_campaign__linkedin_urn +#: model:ir.model.fields,field_description:social_media_linkedin.field_utm_group_campaign__linkedin_urn +msgid "Linkedin URN" +msgstr "" + +#. module: social_media_linkedin +#: model:ir.model.fields,help:social_media_linkedin.field_utm_campaign__daily_budget +msgid "Maximum daily campaign spending" +msgstr "Gasto máximo diario de campaña" + +#. module: social_media_linkedin +#: model:ir.model.fields,field_description:social_media_linkedin.field_social_media__media_type +msgid "Media Type" +msgstr "Tipo de red social" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "REFRESH TOKEN: %s" +msgstr "Token de actualización: %s" + +#. module: social_media_linkedin +#: model:ir.model,name:social_media_linkedin.model_social_account +msgid "Social Account" +msgstr "Cuenta de red social" + +#. module: social_media_linkedin +#: model:ir.model,name:social_media_linkedin.model_social_media +msgid "Social Media" +msgstr "Red social" + +#. module: social_media_linkedin +#: model:ir.model,name:social_media_linkedin.model_social_post +msgid "Social Post" +msgstr "Publicación de red social" + +#. module: social_media_linkedin +#: model:ir.model,name:social_media_linkedin.model_social_post_account +msgid "Social Post Account" +msgstr "Publicación de red social" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/utm_campaign.py:0 +#, python-format +msgid "" +"The amount you want to add exceeds\n" +" the campaign group limit." +msgstr "" +"La cantidad que desea agregar excede el límite del grupo de campañas." + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "The token is %(token_valid)s valid." +msgstr "El token es %(token_valid)s válido." + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "The token is valid." +msgstr "El token es válido." + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/wizards/wizard_social_account.py:0 +#, python-format +msgid "The token was updated successfully" +msgstr "El token se actualizó correctamente" + +#. module: social_media_linkedin +#: model:ir.model.fields,field_description:social_media_linkedin.field_utm_group_campaign__total_budget +msgid "Total Budget" +msgstr "Presupuesto total" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "UPLOADING VIDEO: %(error_video)s" +msgstr "" + +#. module: social_media_linkedin +#: model:ir.model,name:social_media_linkedin.model_utm_campaign +msgid "UTM Campaign" +msgstr "" + +#. module: social_media_linkedin +#: model:ir.model,name:social_media_linkedin.model_utm_group_campaign +msgid "UTM Group Campaign" +msgstr "" + +#. module: social_media_linkedin +#: model:ir.model.fields,field_description:social_media_linkedin.field_utm_campaign__unit_cost +msgid "Unit Cost" +msgstr "Costo unitario" + +#. module: social_media_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_linkedin.social_account_kanban_view_inherit +msgid "Validate token" +msgstr "Validar token" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_post.py:0 +#, python-format +msgid "" +"You have selected images and videos for this post. However, the social media" +" Linkedin does not allow combining both types of content in the same post. " +"Therefore, only the images will be published. If you wish to publish a " +"video, please remove the images from this post or create a separate post." +msgstr "" +"Has seleccionado imágenes y vídeos para esta publicación. Sin embargo, las red social " +"Linkedin no permite combinar ambos tipos de contenido en la misma publicación." +"Por lo tanto, solo se publicarán las imágenes. Si deseas publicar un vídeo, elimina las imágenes de esta publicación o crea una publicación aparte." 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..5d033e4b0d --- /dev/null +++ b/social_media_linkedin/models/social_account.py @@ -0,0 +1,1269 @@ +# 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 linkedin_api.clients.restli.client import RestliClient +from werkzeug.urls import url_join, url_quote + +from odoo import Command, 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, + 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(string="Expire Refresh Token") + 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, + ): + try: + 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 + except requests.ConnectionError as ex: + raise ValidationError(str(ex)) from ex + except requests.exceptions.ReadTimeout as ex: + raise ValidationError(str(ex)) from ex + + 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 + 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 + ) + ) + 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" + if 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"}, + } + + 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_fetch( + [("csrf_state_token", "=", kwargs.get("state", ""))], + ["linkedin_client", "linkedin_secret"], + limit=1, + ) + ) + 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))" + }, + ) + if isinstance(response_organizations, dict): + logo_binary = None + logo_elements = ( + response_organizations.get("logoV2", {}) + .get("original~", {}) + .get("elements", []) + ) + if logo_elements: + complete_url = list( + filter( + lambda x: "logo_400_400" in x.get("artifact", ""), + logo_elements, + ) + ) + if not complete_url and logo_elements: + complete_url = logo_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, + } + ) + else: + account_id = ( + self.env["social.account"] + .sudo() + .search( + [ + ("linkedin_account_urn", "=", organization_id), + ], + limit=1, + ) + ) + account_id.message_post( + body=response_organizations.json().get( + "message", + self.env._("Error obtaining information from the organization"), + ), + ) + 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, + ) + for organization in organizations: + social_account = self.sudo().search( + [ + ("username", "=", organization.get("vanityName", False)), + ("media_type", "=", "linkedin"), + ] + ) + 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 not social_account: + 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) + else: + social_account.write(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.media_type == "linkedin": + 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() + or self.refresh_token_expires_in < 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 + token_valid = "not " if not is_valid_token_access else "" + 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=token_valid, + ), + media="linkedin", + account_name=self.name or "LINKEDIN", + ) + elif not ctx.get("not_notify", False): + self._notify_user_client( + notif_type="social_form_success", + notif_message=self.env._("The token is valid."), + 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", {} + ), + "firstPublishedAt": post.get("firstPublishedAt", {}), + } + for post in response_ugc_posts.get("elements", []) + ] + return ugc_posts + raise ValidationError(self.env._("GET UGC POSTS: %s", response.json())) + + 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: x.get("id", False) is not False + and "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( + self.env._("GET SHARE POSTS STATISTICS: %s", 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: x.get("id", False) is not False + and "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 + ), + 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( + self.env._("GET UGC POSTS STATISTICS: %s", response.json()) + ) + return data + + def get_entity_statistics( + self, + posts=None, + params_fields=None, + params_values=None, + params_values_char_ignore=None, + format_quote=None, + ): + if not self.media_type == "linkedin": + return {} + 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 = {} + data_share = {} + if posts: + data_share = 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") + if "timeIntervals" in params_fields: + params_fields.remove("timeIntervals") + params_values.pop("shares") + params_values.pop("q") + params_values.pop("organizationalEntity") + if "timeIntervals" in params_fields: + params_values.remove("timeIntervals") + 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 | data_share + + 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_statistics(posts=ugc_posts) + post_data_reactions = {} + post_ids = [] + 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")), + ], + limit=1, + ) + share_content = ugc_post.get("share_content", {}) + post_id = ugc_post.get("id") + post_ids.append({"id": post_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": post_data_reaction[4] + 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) + if attach_images: + data.update({"image_ids": attach_images}) + if not post_account: + post_accounts.append(Command.create(data)) + else: + post_accounts.append(Command.update(post_account.id, data)) + update_account_data = { + "post_account_ids": post_accounts, + "need_update": False, + } + if len(post_reactions) > 0: + update_account_data.update( + account._filter_statistics(post_reactions) + ) + 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_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_statistics = account.get_entity_statistics( + posts=account.post_account_ids.mapped( + lambda x: {"id": x.linkedin_post_account_urn} + ), + 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" + if account_statistics: + data_linkedin += account._map_chart_statistics( + account_statistics, + **{"freq": freq, "start_date": start_date, "end_date": end_date}, + ) + 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", []) + return campaigns + raise ValidationError(self.env._("GET CAMPAIGNS: %s", response.json())) + + 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])})" + ) + date_statistics_range = 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": date_statistics_range, + "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", []) + return statistics + raise ValidationError( + self.env._("GET CAMPAIGNS STATISTICS: %s", response.json()) + ) + + 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(self.env._("GET ADS: %s", response.json())) + + # STATISTICS + ads_parse = [] + if ads: + 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 _run_check_media_updates(self): + update = super()._run_check_media_updates() + + if update: + return 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 not post_ids: + continue + latest_urn = post_ids[0].get("id") + if not latest_urn: + continue + exists = bool( + PostAccount.search( + [ + ("account_id", "=", account.id), + ("linkedin_post_account_urn", "=", latest_urn), + ], + limit=1, + ) + ) + if not exists: + account.need_update = True + return self._need_update() + post_reactions = account._get_entity_share_statistics(posts=post_ids) + share_urns = [ + post_reaction.get("share", "-") + for post_reaction in post_reactions + if post_reaction.get("share") + ] + if not share_urns: + continue + post_accounts = PostAccount.search_fetch( + [ + ("linkedin_post_account_urn", "in", share_urns), + ("account_id", "=", account.id), + ], + [ + "linkedin_post_account_urn", + "comment_count", + "like_count", + "click_count", + "share_count", + ], + ) + post_account_by_urn = { + rec.linkedin_post_account_urn: rec for rec in post_accounts + } + + for post_reaction in post_reactions: + statistic = post_reaction.get("totalShareStatistics", {}) + if not statistic: + continue + share_urn = post_reaction.get("share", "-") + post_account = post_account_by_urn.get(share_urn) + if not post_account: + continue + if ( + post_account.comment_count != statistic.get("commentCount", 0) + or post_account.like_count != statistic.get("likeCount", 0) + or post_account.click_count != statistic.get("clickCount", 0) + or post_account.share_count != statistic.get("shareCount", 0) + ): + 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..ad953eea5b --- /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.copy() + 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..cb578914ce --- /dev/null +++ b/social_media_linkedin/models/social_post.py @@ -0,0 +1,47 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import itertools + +from odoo import api, 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 + + @api.depends("account_ids") + def _compute_message_info(self): + message = super()._compute_message_info() + for post in self: + if ( + post.image_ids + and post.video_ids + and "linkedin" in post.account_ids.mapped("media_type") + ): + message_info = self.env._( + "You have selected images and videos for this post. " + "However, the social media Linkedin does not allow " + "combining both types of content in the same post. Therefore, " + "only the images will be published. If you wish to publish a " + "video, please remove the images from this post or create a" + " separate post." + ) + post.message_info = ( + message + "\n" + message_info if post.message_info else message_info + ) + else: + post.message_info = message + return message 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..e1ca19a2da --- /dev/null +++ b/social_media_linkedin/models/social_post_account.py @@ -0,0 +1,519 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import itertools +import logging +from urllib.parse import quote + +from odoo import fields, models +from odoo.exceptions import ValidationError + +from odoo.addons.social_media_base.social_utils import ( + _generate_timestamps, + convert_date_in_time, +) + +_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_fetch( + [ + ("name", "in", media_ids), + ], + ["name"], + ) + .mapped("name") + ) + attachments = [] + for media in medias: + if media.get("media", False) not in medias_exist and media.get( + "originalUrl", False + ): + attachments.append( + self._map_medias_account( + **{ + "name": media.get("media", False), + "url": media.get("originalUrl", False), + }, + ) + ) + return attachments + + def _linkedin_advertising_accounts(self): + advertising_account_id = self.account_id.advertising_account_id + if advertising_account_id: + return 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 + ) + return advertising_account_id + + raise ValidationError( + self.env._("Error get advertising account in Linkedin: %s", response.json()) + ) + + def _action_campaign_group(self): + advertising_account_id = self._linkedin_advertising_accounts() + group_campaign = False + if advertising_account_id: + campaign_id = self.post_id.campaign_id + if campaign_id.campaign_group_id.linkedin_urn: + group_campaign = self.account_id._request_linkedin( + endpoint="/adCampaignGroupsV2/{}".format( + 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 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": campaign_id.campaign_group_id.name, + "runSchedule": { + "start": start, + "end": end, + }, + "status": "ACTIVE", + "totalBudget": { + "amount": f"{campaign_id.campaign_group_id.total_budget}", + "currencyCode": 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] + ) + campaign_id.campaign_group_id.linkedin_urn = group_campaign + else: + raise ValidationError( + self.env._( + "Error creating group campaign in Linkedin: %s", + response.json(), + ) + ) + else: + raise ValidationError( + self.env._( + "Error creating group campaign in Linkedin: %s", + 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: + campaign_id = self.post_id.campaign_id + if campaign_id.linkedin_urn: + campaign = self.account_id._request_linkedin( + endpoint="/adCampaignsV2/{}".format( + 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 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"{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"{campaign_id.unit_cost}", + "currencyCode": campaign_id.currency_id.name, + }, + "dailyBudget": { + "amount": f"{campaign_id.daily_budget}", + "currencyCode": 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] + ) + campaign_id.linkedin_urn = campaign + else: + raise ValidationError( + self.env._( + "Error creating campaign in Linkedin: %s", response.json() + ) + ) + else: + raise ValidationError( + self.env._( + "Error creating group campaign in Linkedin: %s", + 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._( + "Error creating campaign post in Linkedin: %s", + 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, post_id): + res = super()._action_post(post_id) + if any(account.media_type == "linkedin" for account in post_id.account_ids): + post_accounts = post_id.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]}, + } + ) + attach_images = None + 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 = self.env._( + "ERROR GET COMMENTS LINKEDIN: %(error)s", + error=response.json(), + ) + 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 = self.env._( + "ERROR CREATE COMMENT LINKEDIN: %(error)s", + error=response.json().get("message", ""), + ) + 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) + 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"/posts/{quote(self.linkedin_post_account_urn)}", + headers=self.account_id.media_id._get_linkedin_headers( + self.account_id.access_token + ), + return_json=False, + ) + 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" and self.linkedin_post_account_urn: + delete_post = self.account_id._request_linkedin( + method="DELETE", + endpoint=f"/posts/{quote(self.linkedin_post_account_urn)}", + headers=self.media_id._get_linkedin_headers( + self.account_id.access_token + ), + return_json=False, + ) + if delete_post.status_code != 204: + error_message = delete_post.json().get( + "message", + self.env._( + "The post could not be deleted, please try again later." + ), + ) + 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..97d7317c18 --- /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](../static/img/readme/STATISTICS_ACCOUNT.png) \ No newline at end of file diff --git a/social_media_linkedin/readme/ROADMAP.md b/social_media_linkedin/readme/ROADMAP.md new file mode 100644 index 0000000000..21978fc862 --- /dev/null +++ b/social_media_linkedin/readme/ROADMAP.md @@ -0,0 +1,27 @@ +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. + +Post with video or image +------------------------ + +- The API currently only supports creating a post with either an image or a video, + not both at the same time. +- Only one shareMediaCategory can be sent per post. + + https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/ugc-post-api?view=li-lms-2025-11&tabs=http#mediarichcontent + + ![shareMediaCategory](../static/img/readme/shareMediaCategory.png) \ No newline at end of file diff --git a/social_media_linkedin/readme/USAGE.md b/social_media_linkedin/readme/USAGE.md new file mode 100644 index 0000000000..9b138c9a2b --- /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](../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](../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](../static/img/readme/UPDATE_KEYS.png) + +- Selecting the *Update token* checkbox will update the current token. + + ![UPDATE_TOKEN](../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](../static/img/readme/ARCHIVE_ACCOUNT.png) + +- Please note that all data associated with this account will be archived. \ No newline at end of file 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..e4a375979d --- /dev/null +++ b/social_media_linkedin/static/description/index.html @@ -0,0 +1,639 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

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.
  • +
+
+
+

Post with video or image

+ +
+

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/img/readme/shareMediaCategory.png b/social_media_linkedin/static/img/readme/shareMediaCategory.png new file mode 100644 index 0000000000..df8c8e96b6 Binary files /dev/null and b/social_media_linkedin/static/img/readme/shareMediaCategory.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..8d0881f315 --- /dev/null +++ b/social_media_linkedin/tests/__init__.py @@ -0,0 +1,9 @@ +# 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 +from . import test_social_campaign 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..1fcaec19e0 --- /dev/null +++ b/social_media_linkedin/tests/test_account_linkedin.py @@ -0,0 +1,930 @@ +# 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.tests.test_social_common import ( + PATCH_ACCOUNT, + PATCH_SOCIAL_BASE_MIXIN, + PATCH_WIZARD_ACCOUNT, +) +from odoo.addons.social_media_linkedin.tests.test_common_linkedin import ( + PATCH_ACCOUNT_LINKEDIN, + PATCH_WIZARD_ACCOUNT_LINKEDIN, + TestSocialCommonLinkedin, +) + + +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" + } + }, + } + } + + patch_request_linkedin = self.get_patch_exceptions_linkedin(fake_response) + with patch_request_linkedin 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", + } + } + + patch_request_linkedin = self.get_patch_exceptions_linkedin(fake_response) + + with patch_request_linkedin 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 = self.generate_magic_mock( + **{ + "status_code": 201, + } + ) + + mock_upload_image = patch.object( + type(self.SocialAccountLinkedin), + "_prepare_url_upload_asset", + return_value=mock_upload_asset_image, + ) + + patch_request_linkedin = self.get_patch_exceptions_linkedin(mock_response) + + with mock_upload_image, patch_request_linkedin: + 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")) + + mock_upload_video = patch.object( + type(self.SocialAccountLinkedin), + "_prepare_url_upload_asset", + return_value=mock_upload_asset_video, + ) + + with mock_upload_video, patch_request_linkedin: + 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")) + + def test_get_posts(self): + mock_response = self.generate_magic_mock( + **{ + "status_code": 200, + "json_return_value": { + "elements": [ + { + "id": "123", + "specificContent": { + "com.linkedin.ugc.ShareContent": {"text": "Post 1"} + }, + }, + { + "id": "456", + "specificContent": { + "com.linkedin.ugc.ShareContent": {"text": "Post 2"} + }, + }, + ] + }, + } + ) + + patch_request_linkedin = self.get_patch_exceptions_linkedin(mock_response) + + with patch_request_linkedin as mock_request_linkedin: + posts = self.SocialAccountLinkedin._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() + + mock_response_failed = self.generate_magic_mock(**{"status_code": 400}) + patch_request_linkedin_failed = self.get_patch_exceptions_linkedin( + mock_response_failed + ) + with patch_request_linkedin_failed as mock_request_linkedin_failed: + with self.assertRaises(ValidationError): + self.SocialAccountLinkedin._get_posts() + mock_request_linkedin_failed.assert_called_once() + + def test_get_chart_account_statistics(self): + patch_get_default_filter_date = self.generate_patch( + **{ + "type_object": True, + "model_patch": self.SocialAccountLinkedin, + "method_patch": "_get_default_filter_date", + "return_value": ( + "2025-01-01T00:00:00", + "2025-01-07T23:59:59", + ), + } + ) + patch_get_entity_statistics = self.generate_patch( + **{ + "type_object": True, + "model_patch": self.SocialAccountLinkedin, + "method_patch": "get_entity_statistics", + "return_value": { + "urn:li:ugcPost:0119424": (100, 30, 50, 0, 0, 0), + "urn:li:ugcPost:0115624": (200, 70, 100, 0, 0, 0), + }, + } + ) + + with patch_get_default_filter_date, patch_get_entity_statistics: + result = self.SocialAccountLinkedin._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"], [0, 0]) + self.assertEqual(result[0]["datasets"][2]["data"], [30, 70]) + self.assertEqual(result[0]["datasets"][3]["data"], [50, 100]) + self.assertEqual(result[0]["datasets"][4]["data"], [0, 0]) + self.assertEqual(result[0]["datasets"][5]["data"], [0, 0]) + + def test_get_campaigns(self): + mock_response = self.generate_magic_mock( + **{ + "status_code": 200, + "json_return_value": { + "elements": [ + {"id": "123", "name": "Campaign A"}, + {"id": "456", "name": "Campaign B"}, + ] + }, + } + ) + + patch_request_linkedin = self.generate_patch( + **{ + "type_object": True, + "model_patch": self.SocialAccountLinkedin, + "method_patch": "_request_linkedin", + "return_value": mock_response, + } + ) + + with patch_request_linkedin as mock_request_linkedin: + result = self.SocialAccountLinkedin._get_campaigns( + start_date=self.start_datetime, + end_date=self.end_datetime, + campaign_ids=["123"], + ) + self.assertEqual(len(result), 2) + self.assertEqual(result[0]["id"], "123") + self.assertEqual(result[1]["id"], "456") + mock_request_linkedin.assert_called_once() + + patch_request_linkedin_failed = self.generate_patch( + **{ + "type_object": True, + "model_patch": self.SocialAccountLinkedin, + "method_patch": "_request_linkedin", + "return_value": self.generate_magic_mock( + **{ + "status_code": 403, + } + ), + } + ) + with patch_request_linkedin_failed as mock_request_linkedin_failed: + with self.assertRaises(ValidationError): + self.SocialAccountLinkedin._get_campaigns( + start_date=self.start_datetime, + end_date=self.end_datetime, + campaign_ids=["420"], + ) + mock_request_linkedin_failed.assert_called_once() + + def test_get_statistics(self): + mock_response = self.generate_magic_mock( + **{ + "status_code": 200, + "json_return_value": { + "elements": [ + { + "campaign": "123", + "statistics": {"clickCount": 100, "impressionCount": 500}, + }, + { + "campaign": "456", + "statistics": {"clickCount": 200, "impressionCount": 600}, + }, + ] + }, + } + ) + patch_request_linkedin = self.generate_patch( + **{ + "type_object": True, + "model_patch": self.SocialAccountLinkedin, + "method_patch": "_request_linkedin", + "return_value": mock_response, + } + ) + with patch_request_linkedin as mock_request_linkedin: + result = self.SocialAccountLinkedin._get_statistics( + ads_ids=["123", "456"], + start_date=self.start_datetime, + end_date=self.end_datetime, + ) + self.assertEqual(len(result), 2) + self.assertEqual(result[0]["campaign"], "123") + self.assertEqual(result[1]["campaign"], "456") + mock_request_linkedin.assert_called_once() + + patch_request_linkedin_failed = self.generate_patch( + **{ + "type_object": True, + "model_patch": self.SocialAccountLinkedin, + "method_patch": "_request_linkedin", + "return_value": self.generate_magic_mock( + **{ + "status_code": 403, + } + ), + } + ) + with patch_request_linkedin_failed as mock_request_linkedin_failed: + with self.assertRaises(ValidationError): + self.SocialAccountLinkedin._get_statistics( + ads_ids=["423", "756"], + start_date=self.start_datetime, + end_date=self.end_datetime, + ) + mock_request_linkedin_failed.assert_called_once() + + def test_get_statistics_ads(self): + ads_ids = [123, 456] + expected_result = [{"mock": "data"}] + patch_get_statistics = self.generate_patch( + **{ + "type_object": True, + "model_patch": self.SocialAccount, + "method_patch": "_get_statistics", + "return_value": expected_result, + } + ) + + with patch_get_statistics as mock_get_statistics: + result = self.SocialAccountLinkedin._get_statistics_ads( + ads_ids, self.start_datetime, self.end_datetime + ) + self.assertEqual(result, expected_result) + mock_get_statistics.assert_called_once() + + def test_load_ads(self): + patch_request_linkedin = self.generate_patch( + **{ + "type_object": True, + "model_patch": self.SocialAccountLinkedin, + "method_patch": "_request_linkedin", + "return_value": self.generate_magic_mock( + **{ + "status_code": 200, + "json_return_value": { + "elements": [ + { + "id": 1, + "reference": "ref1", + "campaign": "urn:li:sponsoredCampaign:123", + "changeAuditStamps": { + "created": {"time": 1735689600000} + }, + "servingStatuses": ["ACTIVE"], + } + ] + }, + } + ), + } + ) + patch_get_posts = self.generate_patch( + **{ + "model_patch": PATCH_ACCOUNT_LINKEDIN.format("_get_posts"), + "return_value": { + "ref1": { + "id": "ref1", + "specificContent": { + "com.linkedin.ugc.ShareContent": { + "shareCommentary": {"text": "Test post"} + } + }, + } + }, + } + ) + patch_get_campaigns = self.generate_patch( + **{ + "model_patch": PATCH_ACCOUNT_LINKEDIN.format("_get_campaigns"), + "return_value": [ + { + "id": 123, + "account": "urn:li:sponsoredAccount:999", + } + ], + } + ) + patch_get_statistics_ads = self.generate_patch( + **{ + "model_patch": PATCH_ACCOUNT_LINKEDIN.format("_get_statistics_ads"), + "return_value": [ + { + "pivotValues": ["urn:li:sponsoredAccount:1"], + "clicks": 10, + } + ], + } + ) + patch_get_default_filter_date = self.generate_patch( + **{ + "model_patch": PATCH_ACCOUNT.format("_get_default_filter_date"), + "method_patch": "_get_default_filter_date", + "side_effect": ( + lambda s, e, time_date=False: ( + self.start_datetime, + self.end_datetime, + ) + if not time_date + else (self.start_datetime, self.end_datetime) + ), + } + ) + with ( + patch_request_linkedin as mock_request_linkedin, + patch_get_posts as mock_get_posts, + patch_get_campaigns as mock_get_campaigns, + patch_get_statistics_ads as mock_get_statistics_ads, + patch_get_default_filter_date as mock_get_default_filter_date, + ): + result = self.SocialAccountLinkedin._load_ads( + start_date=self.start_datetime, end_date=self.end_datetime + ) + 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]) + mock_request_linkedin.assert_called_once() + mock_get_statistics_ads.assert_called_once() + mock_get_campaigns.assert_called_once() + mock_get_posts.assert_called_once() + mock_get_default_filter_date.assert_called_once() + + patch_request_linkedin_failed = self.generate_patch( + **{ + "type_object": True, + "model_patch": self.SocialAccountLinkedin, + "method_patch": "_request_linkedin", + "return_value": self.generate_magic_mock(**{"status_code": 403}), + } + ) + with patch_request_linkedin_failed as mock_request_linkedin_failed: + with self.assertRaises(ValidationError): + self.SocialAccountLinkedin._load_ads( + start_date=self.start_datetime, end_date=self.end_datetime + ) + mock_request_linkedin_failed.assert_called_once() + + 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") + + def test_get_account_linkedin(self): + organization_request_linkedin = { + "elements": [{"organization": "organization:123456789"}] + } + organization_logo_mock = MagicMock() + organization_logo_mock.status_code = 200 + organization_logo_mock.content = b"fake image data" + organization_name = "Organization Test" + organization_id = "organization123456789" + response_organization = { + "id": organization_id, + "vanityName": organization_name, + "name": {"localized": {"es_ES": organization_name}}, + "logoV2": { + "original~": { + "elements": [ + { + "artifact": "logo_400_400/image_organization123456789", + "identifiers": [ + { + "identifier": "https://www.medias.com/logo_400_400/image_organization123456789" + } + ], + } + ] + } + }, + } + patch_request_linkedin = self.get_patch_exceptions_linkedin( + side_effect=[ + organization_request_linkedin, + response_organization, + organization_logo_mock, + ] + ) + with patch_request_linkedin as mock_request: + res = self.SocialAccount.get_account_linkedin("fake-access-token") + self.assertEqual(res[0]["id"], organization_id) + self.assertEqual(res[0]["localizedName"], organization_name) + self.assertEqual(res[0]["vanityName"], organization_name) + self.assertTrue(res[0]["logo"]) + self.assertEqual(mock_request.call_count, 3) + + def test_get_url_redirect(self): + with patch( + "odoo.models.BaseModel.get_base_url", + autospec=True, + return_value=self.url_callback, + ) as base_url: + result = self.wizard_account_id._get_url_redirect() + self.assertEqual(result, self.url_callback) + base_url.assert_called_once() + + with patch( + PATCH_WIZARD_ACCOUNT.format("_get_url_redirect"), autospec=True + ) as redirect_super: + self.WizardAccount._get_url_redirect() + redirect_super.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() + + def test_get_csrf_state_token(self): + fake_code_hmac = "fake-hmac-code" + with ( + patch.object( + type(self.wizard_account_id), "_generate_code", autospec=True + ) as mock_fake_code, + patch( + PATCH_WIZARD_ACCOUNT_LINKEDIN.format("hmac"), + autospec=True, + return_value=fake_code_hmac, + ) as mock_hmac, + ): + result = self.wizard_account_id._get_csrf_state_token() + self.assertEqual(result, fake_code_hmac) + mock_hmac.assert_called_once() + mock_fake_code.assert_called_once() + + with patch( + PATCH_WIZARD_ACCOUNT.format("_get_csrf_state_token"), autospec=True + ) as mock_hmac_super: + self.WizardAccount._get_csrf_state_token() + mock_hmac_super.assert_called_once() + + def test_compute_csrf_state_token(self): + expected_token = "fake-csrf-token" + with patch.object( + type(self.wizard_account_id), + "_get_csrf_state_token", + autospec=True, + return_value=expected_token, + ) as mocked_get_token: + self.wizard_account_id._compute_csrf_state_token() + value = self.wizard_account_id.csrf_state_token + mocked_get_token.assert_called_once_with(self.wizard_account_id) + self.assertEqual(value, expected_token) + + def test_action_associate_social_account(self): + action_fake_url = { + "type": "ir.actions.act_url", + "url": "https://test.example/redirect", + "target": "self", + } + with ( + patch.object( + type(self.wizard_account_id), + "_action_valid_add_account", + autospec=True, + ) as mocked_valid, + patch.object( + type(self.wizard_account_id), + "_action_add_account", + autospec=True, + return_value=action_fake_url, + ) as mocked_add, + ): + result = self.wizard_account_id.action_associate_social_account() + mocked_valid.assert_called_once_with(self.wizard_account_id) + mocked_add.assert_called_once_with(self.wizard_account_id) + self.assertEqual(result, action_fake_url) + + def test_create_account_linkedin_failed(self): + with self.assertRaises(ValidationError) as ctx: + self.SocialAccount.create_account_linkedin( + "fake-client-id", + "fake-secret", + MagicMock(text="Error token"), + ) + self.assertIn("Creating account", str(ctx.exception)) + + def test_create_account_linkedin(self): + fake_organization = self.generate_magic_mock( + **{ + "return_value": { + "vanityName": "Vanity X", + } + } + ) + + def search_side_effect(recordset, domain=None, *args, **kwargs): + if recordset._name == "wizard.social.account": + return self.wizard_account_id + return self.SocialAccount + + with ( + patch( + "odoo.models.BaseModel.search", + autospec=True, + side_effect=search_side_effect, + ) as mock_search, + patch( + "odoo.models.BaseModel.create", + autospec=True, + return_value=self.SocialAccountLinkedin, + ) as mock_create, + patch("odoo.models.BaseModel.unlink", autospec=True) as mock_unlink, + patch.object( + type(self.SocialAccountLinkedin), + "get_account_linkedin", + autospec=True, + return_value=[fake_organization], + ) as mock_account_linkedin, + ): + self.SocialAccount.create_account_linkedin( + "fake-client-id", + "fake-secret", + {"access_token": "fake-access-token"}, + ) + self.assertEqual(mock_search.call_count, 2) + mock_account_linkedin.assert_called_once() + mock_create.assert_called_once() + mock_unlink.assert_called_once() + + def test_validate_access_token(self): + patch_notify_user = patch(PATCH_SOCIAL_BASE_MIXIN.format("_notify_user_client")) + self.SocialAccountLinkedin.expire_access_token_date = ( + datetime.now() + timedelta(days=-10) + ).date() + with ( + patch(PATCH_ACCOUNT.format("validate_access_token")) as mock_super, + patch.object( + type(self.SocialAccount), + "validate_linkedin_access_token", + autospec=True, + return_value=True, + ) as mock_validate_token, + patch_notify_user as mock_notify_user, + ): + self.SocialAccountLinkedin.validate_access_token() + mock_super.assert_called_once() + mock_validate_token.assert_called_once() + mock_notify_user.assert_called_once() + + self.SocialAccountLinkedin.expire_access_token_date = ( + datetime.now() + timedelta(days=1) + ).date() + self.SocialAccountLinkedin.refresh_token_expires_in = ( + datetime.now() + timedelta(days=1) + ).date() + with ( + patch(PATCH_ACCOUNT.format("validate_access_token")) as mock_super_failed, + patch_notify_user as mock_notify_user_failed, + ): + self.SocialAccountLinkedin.validate_access_token() + mock_super_failed.assert_called_once() + mock_notify_user_failed.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..1fa47bcedc --- /dev/null +++ b/social_media_linkedin/tests/test_common_linkedin.py @@ -0,0 +1,175 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +from unittest.mock import patch + +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_WIZARD_LINKEDIN = "odoo.addons.social_media_linkedin.wizards.{}" +PATCH_WIZARD_ACCOUNT_LINKEDIN = PATCH_WIZARD_LINKEDIN.format("wizard_social_account.{}") + +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.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, + ) + + def get_patch_exceptions_linkedin(self, fake_client=False, side_effect=False): + if side_effect: + return patch.object( + type(self.SocialAccountLinkedin), + "_request_linkedin", + autospec=True, + side_effect=side_effect, + ) + return patch.object( + type(self.SocialAccountLinkedin), + "_request_linkedin", + autospec=True, + return_value=fake_client, + ) diff --git a/social_media_linkedin/tests/test_controller.py b/social_media_linkedin/tests/test_controller.py new file mode 100644 index 0000000000..5047f593c4 --- /dev/null +++ b/social_media_linkedin/tests/test_controller.py @@ -0,0 +1,105 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from unittest.mock import patch + +from odoo.tests.common import HttpCase, tagged + +from odoo.addons.social_media_base.tests.test_social_common import ( + PATCH_SOCIAL_BASE_MIXIN, +) +from odoo.addons.social_media_linkedin.controllers.social_media_linkedin import ( + SocialMediaLinkedin, +) +from odoo.addons.social_media_linkedin.tests.test_common_linkedin import ( + PATCH_ACCOUNT_LINKEDIN, + TestSocialCommonLinkedin, +) + + +@tagged("post_install", "-at_install") +class TestSocialController(HttpCase, TestSocialCommonLinkedin): + def setUp(cls): + super().setUp() + cls.controller = SocialMediaLinkedin() + cls.authenticate("admin", "admin") + + def test_social_linkedin_webhook(self): + controller = SocialMediaLinkedin() + result = controller.social_linkedin_webhook() + self.assertTrue(result) + + def test_callback_with_access_token_skips_exchange_and_creates_account(self): + token = "ACCESS_TOKEN" + with ( + patch( + PATCH_ACCOUNT_LINKEDIN.format("get_access_token_linkedin"), + autospec=True, + ) as mocked_exchange, + patch( + PATCH_ACCOUNT_LINKEDIN.format("create_account_linkedin"), + autospec=True, + ) as mocked_create, + ): + resp = self.url_open(f"/linkedin/callback?access_token={token}") + mocked_exchange.assert_not_called() + mocked_create.assert_called_once() + _, args, _kwargs = mocked_create.mock_calls[0] + self.assertIsNone(args[1]) + self.assertIsNone(args[2]) + self.assertEqual(args[3], token) + self.assertEqual(resp.status_code, 200) + self.assertIn("/web", resp.url) + + def test_callback_without_access_token_exchanges_code_and_creates_account(self): + code = "AUTH_CODE" + client_id = "CID" + client_secret = "CSEC" + token = "NEW_TOKEN" + with ( + patch( + PATCH_ACCOUNT_LINKEDIN.format("get_access_token_linkedin"), + autospec=True, + return_value=(client_id, client_secret, token), + ) as mocked_exchange, + patch( + PATCH_ACCOUNT_LINKEDIN.format("create_account_linkedin"), + autospec=True, + ) as mocked_create, + ): + resp = self.url_open(f"/linkedin/callback?code={code}") + mocked_exchange.assert_called_once() + mocked_create.assert_called_once() + _, args, _kwargs = mocked_create.mock_calls[0] + self.assertEqual(args[1], client_id) + self.assertEqual(args[2], client_secret) + self.assertEqual(args[3], token) + self.assertEqual(resp.status_code, 200) + self.assertIn("/web", resp.url) + + def test_callback_exception_notifies_user_and_redirects(self): + with ( + patch( + PATCH_ACCOUNT_LINKEDIN.format("get_access_token_linkedin"), + autospec=True, + side_effect=Exception("boom"), + ), + patch( + PATCH_SOCIAL_BASE_MIXIN.format("_notify_user_client"), + autospec=True, + ) as mocked_notify, + patch( + "odoo.addons.social_media_linkedin.controllers.social_media_linkedin._logger", + autospec=True, + ) as mocked_logger, + ): + resp = self.url_open("/linkedin/callback?code=ANY") + + mocked_notify.assert_called_once() + _, _args, kwargs = mocked_notify.mock_calls[0] + self.assertEqual(kwargs["notif_type"], "social_kanban_danger") + self.assertEqual(kwargs["media"], "linkedin") + self.assertIn("notif_message", kwargs) + mocked_logger.error.assert_called_once() + self.assertEqual(resp.status_code, 200) + self.assertIn("/web", resp.url) 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..e753b3b33e --- /dev/null +++ b/social_media_linkedin/tests/test_media_linkedin.py @@ -0,0 +1,60 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +from odoo.addons.social_media_linkedin.tests.test_common_linkedin import ( + TestSocialCommonLinkedin, +) + +from ..social_linkedin_utils import _HEADERS_LINKEDIN + +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() + + def test_get_linkedin_headers(self): + headers = self.media_linkedin_id._get_linkedin_headers() + self.assertEqual(headers, _HEADERS_LINKEDIN) + self.assertIsNot(headers, _HEADERS_LINKEDIN) + headers = self.media_linkedin_id._get_linkedin_headers(x_restli_method="PATCH") + self.assertEqual(headers["X-RestLi-Method"], "PATCH") + for key, value in _HEADERS_LINKEDIN.items(): + self.assertEqual(headers[key], value) + token = "test_access_token" + headers = self.media_linkedin_id._get_linkedin_headers(access_token=token) + self.assertEqual(headers["Authorization"], f"Bearer {token}") + headers = self.media_linkedin_id._get_linkedin_headers( + content_type="application/json" + ) + self.assertEqual(headers["Content-Type"], "application/json") + token = "test_access_token" + headers = self.media_linkedin_id._get_linkedin_headers( + access_token=token, + content_type="application/json", + x_restli_method="POST", + ) + self.assertEqual(headers["Authorization"], f"Bearer {token}") + self.assertEqual(headers["Content-Type"], "application/json") + self.assertEqual(headers["X-RestLi-Method"], "POST") + + for key, value in _HEADERS_LINKEDIN.items(): + self.assertEqual(headers[key], value) + self.media_linkedin_id._get_linkedin_headers( + access_token="abc", + content_type="application/json", + x_restli_method="PUT", + ) + self.assertEqual(_HEADERS_LINKEDIN, _HEADERS_LINKEDIN.copy()) + + def test_open_action_account_media_linkedin(self): + action = self.media_linkedin_id.open_action_account() + self.valid_open_action_account_media(self.media_linkedin_id, action) + + def test_not_open_action_account_media_linkedin(self): + self.valid_not_open_action_account_media() 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..b4317797d0 --- /dev/null +++ b/social_media_linkedin/tests/test_post_linkedin.py @@ -0,0 +1,598 @@ +# 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 import Command +from odoo.exceptions import ValidationError + +from odoo.addons.social_media_base.tests.test_social_common import ( + PATCH_POST_ACCOUNT, + 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() + + def create_attachment(self, attach_name="test_exist_image.jpg"): + return self.env["ir.attachment"].create( + { + "name": attach_name, + "type": "binary", + "datas": base64.b64encode(b"existing").decode(), + } + ) + + @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.create_attachment() + share_content = { + "media": [ + media_1, + { + "media": "test_exist_image.jpg", + "originalUrl": "https://fake-url.com/test_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")) + def test_action_like_post(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.") + + def test_action_like_post_failed(self): + with patch(PATCH_POST_ACCOUNT.format("action_like_post")) as mock_like_super: + self.SocialPostAccount.action_like_post() + mock_like_super.assert_called_once() + + @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")) + def test_get_comments_failed(self, mock_request): + mock_response = MagicMock() + mock_response.status_code = 400 + mock_request.return_value = mock_response + result = self.SocialPostAccountLinkedin.get_comments() + self.assertFalse(result["success"]) + self.assertIn("ERROR GET COMMENTS LINKEDIN", result["message"]) + + @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.assertFalse(result["success"]) + self.assertIn("ERROR CREATE COMMENT LINKEDIN", result["message"]) + + @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 + 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) + + @patch(PATCH_ACCOUNT_LINKEDIN.format("_request_linkedin")) + def test_get_linkedin_comment_failed(self, mock_request): + mock_response = MagicMock() + mock_response.status_code = 404 + mock_request.return_value = mock_response + result = self.SocialPostAccountLinkedin.get_linkedin_comment() + self.assertFalse(result) + self.assertFalse(self.SocialPostAccountLinkedin.linkedin_post_account_urn) + + 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 + ) + + mock_action_campaign.return_value = False + with self.assertRaises(ValidationError): + self.SocialPostAccountCampaignLinkedin._action_campaign_post( + self.SocialPostAccountCampaignLinkedin.id + ) + + self.assertEqual(mock_action_campaign.call_count, 3) + + def test_action_like_comment(self): + result = self.SocialPostAccountLinkedin.action_like_comment() + self.assertEqual(result, {"success": False, "message": ""}) + + @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() + + def test_compute_message_info(self): + post_message_info = self.SocialPost.create( + { + "message": self.test_message, + "account_ids": [Command.set(self.SocialAccountLinkedin.ids)], + "image_ids": [Command.set([self.create_attachment().id])], + "video_ids": [ + Command.set([self.create_attachment("test_video.mp4").id]) + ], + } + ) + self.assertTrue(post_message_info.message_info) + self.assertIn( + "You have selected images and videos for this post", + post_message_info.message_info, + ) + + post = self.SocialPost.create( + { + "message": self.test_message, + "account_ids": [Command.set(self.SocialAccountLinkedin.ids)], + "image_ids": [Command.set([self.create_attachment().id])], + } + ) + self.assertFalse(post.message_info) + + def test_post_schedule(self): + post_hide = self.SocialPost.create( + { + "message": self.test_message, + "send_post": "schedule", + "account_ids": [Command.set(self.SocialAccountLinkedin.ids)], + } + ) + self.assertEqual(post_hide.state, "planned") + self.assertTrue(post_hide.hide_post) + post_hide.action_draft() + self.assertEqual(post_hide.state, "draft") + self.assertFalse(post_hide.hide_post) + post_hide.send_post = "schedule" + post_hide.action_cancel() + self.assertEqual(post_hide.state, "cancelled") + + @patch(PATCH_ACCOUNT_LINKEDIN.format("_request_linkedin")) + def test_delete_post_account(self, mock_request_linkedin): + mock_response = MagicMock() + mock_response.status_code = 204 + mock_request_linkedin.return_value = mock_response + self.SocialPostAccountLinkedin._delete_post_account() + + mock_failed_response = MagicMock() + mock_failed_response.status_code = 404 + mock_request_linkedin.return_value = mock_failed_response + with self.assertRaises(ValidationError): + self.SocialPostAccountLinkedin._delete_post_account() + self.assertEqual(mock_request_linkedin.call_count, 2) + + def get_patch_advertising(self, advertising=False): + return patch.object( + type(self.SocialPostAccountLinkedin), + "_linkedin_advertising_accounts", + autospec=True, + return_value=advertising, + ) + + def test_not_action_campaign_group(self): + patch_advertising = self.get_patch_advertising() + with patch_advertising as mock_advertising_accounts: + self.SocialPostAccountLinkedin._action_campaign_group() + mock_advertising_accounts.assert_called_once() + + def test_action_exist_campaign_group(self): + patch_advertising = self.get_patch_advertising(True) + fake_campaign_group = MagicMock() + fake_campaign_group.status_code = 200 + patch_request_linkedin = self.get_patch_exceptions_linkedin(fake_campaign_group) + with ( + patch_advertising as mock_advertising_accounts, + patch_request_linkedin as mock_request_linkedin, + ): + res = self.SocialPostAccountCampaignLinkedin._action_campaign_group() + self.assertEqual( + self.SocialPostCampaignLinkedin.campaign_id.campaign_group_id.linkedin_urn, + res, + ) + mock_request_linkedin.assert_called_once() + mock_advertising_accounts.assert_called_once() + + def test_create_new_campaign_group(self): + patch_advertising = self.get_patch_advertising(True) + patch_request_linkedin = self.get_patch_exceptions_linkedin( + side_effect=[ + MagicMock(status_code=404), + MagicMock( + status_code=201, headers={"Location": "/adCampaignGroupsV2/456"} + ), + ] + ) + fake_timestamps = (111111, 222222) + with ( + patch( + PATCH_SOCIAL_BASE_UTILS.format("_generate_timestamps"), + autospec=True, + return_value=fake_timestamps, + ), + patch_request_linkedin as mock_request, + patch_advertising as mock_ad_accounts, + ): + urn = self.SocialPostAccountCampaignLinkedin._action_campaign_group() + self.assertEqual(urn, "urn:li:sponsoredCampaignGroup:456") + self.assertEqual( + self.SocialCampaignGroupLinkedin.linkedin_urn, + "urn:li:sponsoredCampaignGroup:456", + ) + self.assertEqual(mock_request.call_count, 2) + mock_ad_accounts.assert_called_once() + + def test_campaign_group_error(self): + patch_advertising = self.get_patch_advertising(True) + patch_request_linkedin = self.get_patch_exceptions_linkedin( + side_effect=[ + MagicMock(status_code=404), + MagicMock(status_code=400, headers={"error": "Invalid request"}), + ] + ) + with ( + patch_advertising as mock_ad_accounts, + patch_request_linkedin as mock_request_linkedin, + ): + 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) + + def test_not_create_campaign_group_error(self): + patch_advertising = self.get_patch_advertising(True) + patch_request_linkedin = self.get_patch_exceptions_linkedin( + side_effect=[MagicMock(status_code=400)] + ) + with ( + patch_advertising as mock_ad_accounts, + patch_request_linkedin as mock_request_linkedin, + ): + 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() + mock_request_linkedin.assert_called_once() + + def get_patch_campaign_group(self, campaign_group=False): + return patch.object( + type(self.SocialPostAccountLinkedin), + "_action_campaign_group", + autospec=True, + return_value=campaign_group, + ) + + def test_not_action_campaign(self): + patch_campaign_group = self.get_patch_campaign_group() + with patch_campaign_group as mock_campaign_group: + self.SocialPostAccountLinkedin._action_campaign() + mock_campaign_group.assert_called_once() + + def test_action_exist_campaign(self): + patch_campaign_group = self.get_patch_campaign_group(True) + fake_campaign = MagicMock() + fake_campaign.status_code = 200 + patch_request_linkedin = self.get_patch_exceptions_linkedin(fake_campaign) + with ( + patch_campaign_group as mock_campaign_group, + patch_request_linkedin as mock_request_linkedin, + ): + res = self.SocialPostAccountCampaignLinkedin._action_campaign() + self.assertEqual( + self.SocialPostCampaignLinkedin.campaign_id.linkedin_urn, + res, + ) + mock_request_linkedin.assert_called_once() + mock_campaign_group.assert_called_once() + + def test_create_new_campaign(self): + patch_campaign_group = self.get_patch_campaign_group(True) + patch_advertising = self.get_patch_advertising("urn:li:sponsoredAccount:999") + patch_request_linkedin = self.get_patch_exceptions_linkedin( + side_effect=[ + MagicMock(status_code=404), + MagicMock(status_code=201, headers={"Location": "/adCampaignV2/456"}), + ] + ) + fake_timestamps = (111111, 222222) + with ( + patch( + PATCH_SOCIAL_BASE_UTILS.format("_generate_timestamps"), + autospec=True, + return_value=fake_timestamps, + ), + patch_request_linkedin as mock_request, + patch_campaign_group as mock_campaign_group, + patch_advertising as mock_ad_accounts, + ): + urn = self.SocialPostAccountCampaignLinkedin._action_campaign() + self.assertEqual(urn, "urn:li:sponsoredCampaign:456") + self.assertEqual( + self.SocialCampaignLinkedin.linkedin_urn, + "urn:li:sponsoredCampaign:456", + ) + self.assertEqual(mock_request.call_count, 2) + mock_campaign_group.assert_called_once() + mock_ad_accounts.assert_called_once() + + def test_create_new_campaign_error(self): + patch_campaign_group = self.get_patch_campaign_group(True) + patch_advertising = self.get_patch_advertising("urn:li:sponsoredAccount:999") + patch_request_linkedin = self.get_patch_exceptions_linkedin( + side_effect=[ + MagicMock(status_code=404), + MagicMock(status_code=400, headers={"error": "Bad request"}), + ] + ) + with ( + patch_request_linkedin as mock_request, + patch_campaign_group as mock_campaign_group, + patch_advertising as mock_ad_accounts, + ): + with self.assertRaises(ValidationError) as ctx: + self.SocialPostAccountCampaignLinkedin._action_campaign() + self.assertIn("Error creating campaign in Linkedin", str(ctx.exception)) + self.assertEqual(mock_request.call_count, 2) + mock_campaign_group.assert_called_once() + mock_ad_accounts.assert_called_once() + + def test_action_post(self): + self.SocialPostAccountLinkedin.write({"state": "ready"}) + post_account_id = "122809890045" + expected_urn = f"urn:li:share:{post_account_id}" + fake_response = MagicMock(return_value=[{"share_content": self.image_base64}]) + with ( + patch.object( + type(self.SocialPostLinkedin), + "filter_by_media_types", + autospec=True, + return_value=self.SocialPostAccountLinkedin, + ) as mock_filter_by_media_types, + patch.object( + type(self.SocialPostAccountLinkedin.account_id), + "create_restclient_linkedin", + autospec=True, + return_value=post_account_id, + ) as mock_create_restclient_linkedin, + patch.object( + type(self.SocialPostAccountLinkedin.account_id), + "_get_posts", + autospec=True, + return_value=fake_response, + ) as mock_get_posts, + patch.object( + type(self.SocialPostAccountLinkedin), + "_get_assets_save", + autospec=True, + return_value=[12], + ) as mock_get_assets_save, + ): + self.SocialPostAccountLinkedin._action_post(self.SocialPostLinkedin) + self.assertEqual( + self.SocialPostAccountLinkedin.linkedin_post_account_urn, + expected_urn, + ) + self.assertEqual(self.SocialPostAccountLinkedin.state, "posted") + self.assertIn( + expected_urn, + self.SocialPostAccountLinkedin.post_account_url, + ) + mock_filter_by_media_types.assert_called_once() + mock_create_restclient_linkedin.assert_called_once() + mock_get_posts.assert_called_once() + mock_get_assets_save.assert_called_once() + + def test_action_post_failed(self): + self.SocialPostAccountLinkedin.write({"state": "ready"}) + with ( + patch.object( + type(self.SocialPostLinkedin), + "filter_by_media_types", + autospec=True, + return_value=self.SocialPostAccountLinkedin, + ) as mock_filter_by_media_types, + patch.object( + type(self.SocialPostAccountLinkedin.account_id), + "create_restclient_linkedin", + autospec=True, + return_value=False, + ) as mock_create_restclient_linkedin, + ): + self.SocialPostAccountLinkedin._action_post(self.SocialPostLinkedin) + self.assertEqual(self.SocialPostAccountLinkedin.state, "failed") + mock_filter_by_media_types.assert_called_once() + mock_create_restclient_linkedin.assert_called_once() diff --git a/social_media_linkedin/tests/test_social_campaign.py b/social_media_linkedin/tests/test_social_campaign.py new file mode 100644 index 0000000000..f5076e7d14 --- /dev/null +++ b/social_media_linkedin/tests/test_social_campaign.py @@ -0,0 +1,28 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +from odoo.addons.social_media_linkedin.tests.test_common_linkedin import ( + TestSocialCommonLinkedin, +) + + +class TestSocialCampaign(TestSocialCommonLinkedin): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.campaign_group_id = cls.UtmGroupCampaign.create( + {"name": "Test Group Campaign"} + ) + + def test_compute_media_id(self): + self.campaign_id = self.UtmCampaign.create( + { + "name": "Test Campaign", + "account_id": self.SocialAccountLinkedin.id, + "campaign_group_id": self.campaign_group_id.id, + } + ) + self.assertIn( + self.SocialAccountLinkedin.media_id, self.campaign_id.allow_media_ids + ) 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..12faabfd31 --- /dev/null +++ b/social_media_linkedin/views/social_account_views.xml @@ -0,0 +1,58 @@ + + + + + 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..dcd0e2f7ce --- /dev/null +++ b/social_media_linkedin/wizards/wizard_social_account.py @@ -0,0 +1,133 @@ +# 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") + 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 _get_csrf_state_token(self): + if self.media_type == "linkedin": + return hmac( + self.env(su=True), + f"{self.media_type}-account-{self._generate_code()}-csrf-token", + self.media_id.id, + ) + else: + return super()._get_csrf_state_token() + + def _action_add_account(self): + result = super()._action_add_account() + context = dict(self.env.context) + if self.media_type == "linkedin": + 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 + 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 + ) + else: + 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..bbe57d65b0 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1 @@ +odoo-addon-social_media_base @ git+https://github.com/OCA/social.git@refs/pull/1788/head#subdirectory=social_media_base