diff --git a/social_media_linkedin/README.rst b/social_media_linkedin/README.rst new file mode 100644 index 0000000000..c10568e31e --- /dev/null +++ b/social_media_linkedin/README.rst @@ -0,0 +1,784 @@ +===================== +Social Media Linkedin +===================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:5b02242f5dbe90213a92d0d2632daa7f4c6b7553a8057302a5ab4f1f79657f90 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fsocial-lightgray.png?logo=github + :target: https://github.com/OCA/social/tree/17.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-17-0/social-17-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=17.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module provides the necessary functionality for basic interaction +with the LinkedIn social media. + +Main features: + +- Integration of the LinkedIn company pages (organizations) the user + administrates; personal profiles are not supported. +- Post creation, with images or a video: LinkedIn publishes either the + images or the video, never both. +- Post reactions (likes, comments). +- Full resync of a page, which reads the whole feed and reports the + publications deleted on LinkedIn. +- Reports and graphs with agnostic metrics. + +Liking a comment is not available: LinkedIn grants the scopes it needs +only as special permissions, see the roadmap. + +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 interactions (clicks, likes, comments and + shares) accumulated by the posts created historically on the account. + +3. The star icon: the engagement of the publications. LinkedIn reports + an engagement rate for each publication, and the account adds them + up, so the figure is a sum of rates and not a percentage of the + account. + + |STATISTICS_ACCOUNT| + +.. |STATISTICS_ACCOUNT| image:: https://raw.githubusercontent.com/OCA/social/17.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. + +Only **company pages (organizations)** can be linked, never personal +profiles: a post is always published on behalf of the organization. Odoo +only reads the organizations where the member who authorizes is an +``ADMINISTRATOR`` whose invitation has already been accepted +(``APPROVED`` state). If your role is still pending, LinkedIn does not +return the organization and the wizard ends without creating any +account. + +The connector needs no additional Python library: it uses ``requests``, +which is already shipped with Odoo, so there is nothing to install on +the server besides the module itself. + +- Go to https://developer.linkedin.com/ + +- Create a new Developer App with the *Create app* button. + + |BUTTON_CREATE_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: + + - Sign In with LinkedIn using OpenID Connect, which grants ``profile`` + and ``email`` and is self-serve. + - Share on LinkedIn, which grants ``w_member_social`` and is + self-serve. + - Advertising API, which is the product granting the organization + scopes the connector needs (``r_organization_social``, + ``w_organization_social``, ``r_organization_admin``, + ``rw_organization_admin``, ``r_basicprofile`` and + ``r_1st_connections_size``). It is not self-serve: LinkedIn reviews + the request, and until it is approved the authorization cannot be + completed. + + |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. The products, the scopes they grant and the versions + of the API are described in the `LinkedIn Marketing API + documentation `__ + and in its `versioning + guide `__. + +- After requesting the aforementioned Products, go to the Auth tab and + you will see all the enabled scopes at the bottom. + + A first authorization requests these scopes: ``profile``, ``email``, + ``r_basicprofile``, ``r_organization_social``, + ``r_organization_admin``, ``rw_organization_admin``, + ``w_member_social``, ``w_organization_social`` and + ``r_1st_connections_size``, plus ``r_ads``, ``rw_ads`` and + ``r_ads_reporting`` when the advertising module is installed. All of + them have to appear enabled in the *Auth* tab of your App; if one is + missing, LinkedIn refuses the whole authorization and the Product that + grants it has to be requested. LinkedIn does not let the member pick + part of them either: the consent screen is all or nothing. + + What LinkedIn granted is stored on the account, in the *Granted + Scopes* field of its *Configuration* tab, so a permission error can be + read there instead of in the logs. That same field is what the + **next** authorization requests, and it can be edited: + + - Enable the new Product on your LinkedIn App and wait for it to be + approved. + - Add the scopes it grants to the *Granted Scopes* field of the + account. + - Open *Update account*, tick **Update keys** and authorize again on + LinkedIn. + + Editing the field on its own changes nothing, and ticking only *Update + token* is not enough either: refreshing a token keeps the scopes it + was granted, only a new authorization can ask for more. Go straight to + *Update account* after editing the field, because *Validate token* and + any token refresh rewrite it with the scopes LinkedIn reports, and the + additions typed by hand are lost. Beware that a scope the Products of + your App do not grant makes LinkedIn refuse the whole authorization, + and the account cannot be authorized again until it is removed from + the field. + + Every call is sent with the ``LinkedIn-Version: 202607`` and + ``X-Restli-Protocol-Version: 2.0.0`` headers. LinkedIn retires each + version of the API about a year after publishing it, so the module has + to be updated periodically: once the version is no longer supported, + LinkedIn answers every request with a version error. + +- 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* > Configuration > Social medias + +- Click on the *Associate Account* button for the desired social media. + + |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, LinkedIn + brings you back to Odoo: if the association succeeded you land + directly on the *Social Media* Dashboard with a success notification, + and if it failed you come back to the start screen with the + notification of the error. + + |AUTHORIZE_ACCOUNT| + +- Once you have completed these steps and everything is working + correctly, you can see your account in *Social Media* > Configuration + > Accounts + +- After the account is associated, an initial synchronization of its + posts and statistics is triggered automatically. + +- Note that the account credentials (Client ID, Client Secret and + tokens) are masked and only visible to administrator users. + +- The authorization flow is bound to the session that started it: the + state token sent to LinkedIn only works for the user who opened the + wizard, and it is discarded once the flow ends, so the same + authorization cannot be replayed. If the state does not match, the + association is refused with *Invalid OAuth state token. Please restart + the account association process.* The flow itself is the one described + in the `authorization code + flow `__ + of LinkedIn. + +- If the LinkedIn organization is already linked to an account of + another Odoo user, the association is refused and nothing is + overwritten: only the responsible user of the account and the *Social + Media / Administrator* group can relink it. + +- Every account has to use its own credentials: if the Client ID and the + Client Secret are already registered on another account, the + association is refused with *An account with this information already + exists; please also check archived accounts.* The check includes the + archived accounts, which keep their credentials, so check the + *Archived* filter as well before trying again. To reauthorize an + existing account, use its *Update account* button instead of the + wizard. + +.. |BUTTON_CREATE_APP| image:: https://raw.githubusercontent.com/OCA/social/17.0/social_media_linkedin/static/img/readme/BUTTON_CREATE_APP.png +.. |FORM_CREATE_APP| image:: https://raw.githubusercontent.com/OCA/social/17.0/social_media_linkedin/static/img/readme/FORM_CREATE_APP.png +.. |PRODUCTS| image:: https://raw.githubusercontent.com/OCA/social/17.0/social_media_linkedin/static/img/readme/PRODUCTS.png +.. |CONFIGURE_URL_CALLBACK| image:: https://raw.githubusercontent.com/OCA/social/17.0/social_media_linkedin/static/img/readme/CONFIGURE_URL_CALLBACK.png +.. |ASSOCIATE_ACCOUNT| image:: https://raw.githubusercontent.com/OCA/social/17.0/social_media_linkedin/static/img/readme/ASSOCIATE_ACCOUNT.png +.. |WIZARD_ASSOCIATE_ACCOUNT| image:: https://raw.githubusercontent.com/OCA/social/17.0/social_media_linkedin/static/img/readme/WIZARD_ASSOCIATE_ACCOUNT.png +.. |AUTHORIZE_ACCOUNT| image:: https://raw.githubusercontent.com/OCA/social/17.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* > Posts + +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* > Posts > New or Go to *Social Media* > Dashboard + > Add Post +- Fill in the required fields +- When the post is created, every LinkedIn account of the active company + is selected by default in *Accounts*; remove the ones you do not want + to use before publishing. |CREATE_POST| +- Save +- Click on the *Post* button +- LinkedIn publishes either images or a video, never both: when the post + carries a video, its images are left out. The preview of the post says + so by showing the video alone, so what is previewed is what LinkedIn + receives, and a banner on the form explains it as well. +- Posts deleted directly on LinkedIn are marked as *Deleted on LinkedIn* + in Odoo, so the dashboard keeps their history. Only the *Full resync* + button and the weekly scheduled action notice them, not the ordinary + statistics synchronization, so a deletion can take up to a week to be + reported. +- The images of the published post are attached to it right away. + LinkedIn is asked for them first, and when it has not exposed them yet + the local attachments of the post are copied instead, so the dashboard + card never waits for the next statistics synchronization to show its + image. +- The publication mirrors what is online: an image removed from the post + on LinkedIn is dropped from the dashboard card on the next statistics + synchronization. Only the medias downloaded from LinkedIn are managed + this way, so a file attached by hand in Odoo is never removed. + +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 is + proposed to the administrator users, the only ones allowed to read it, + and the Client Secret has to be typed again: the stored secret is + never sent to the browser. Authentication is then performed again + through LinkedIn, and the keys are only written on the account once + LinkedIn has accepted them, so an authorization left halfway keeps the + credentials that still work. + + |UPDATE_KEYS| + +- Selecting the *Update token* checkbox will update the current token. + + |UPDATE_TOKEN| + +Validate the token +------------------ + +- Go to *Social Media* > Configuration > Accounts + +- Select the account and open the *Configuration* tab + +- Click on the *Validate token* button. It always asks LinkedIn whether + the token is still active, because a token can be revoked there long + before the stored expiry dates: a notification confirms that it is + valid, and if it is not, the token is renewed. Outside the renewal + window, the check made before every call to LinkedIn uses the stored + dates, so it costs no extra request. + + |VALIDATE_TOKEN| + +Full resync of a page +--------------------- + +- Go to *Social Media* > Configuration > Accounts, select the account + and click on the *Full resync* button of the header. +- The *Update* button of the dashboard reads one page of the feed, the + one sorted by last modification, and refreshes the figures of every + publication by its identifier. That is enough for everything except + one thing: it cannot notice that a publication was **deleted** on + LinkedIn, because a publication missing from the page it read is not + necessarily gone. +- *Full resync* is what reads the whole feed and marks as *Deleted on + LinkedIn* what is no longer there. It is also the expensive pass, one + call per hundred publications against the daily quota, so it asks for + confirmation. +- The scheduled action *Social: Full resync of the accounts* does the + same weekly for every account, so pressing the button is only needed + to see a deletion reported sooner. +- A publication edited on LinkedIn long enough ago to have fallen off + that first page keeps the text Odoo already had until a full resync. + Its figures are refreshed all the same. + +Archive Account Linkedin +------------------------ + +- Go to *Social Media* > Configuration > Accounts + +- Select the account + +- Click on the *Archive account* button + + |ARCHIVE_ACCOUNT| + +- Please note that all data associated with this account will be + archived. + +- If you associate the same LinkedIn account again later, the archived + account and its related data will be reactivated instead of creating a + duplicate. + +- An archived account can be deleted permanently with the *Delete + permanently* button, only available to a social media administrator. + The LinkedIn publications stay online, only the Odoo history is + removed. + +Uninstalling the module +----------------------- + +Uninstalling *Social Media Linkedin* does not delete the accounts nor +their publication history: + +- The access token and the refresh token are cleared, so no credential + outlives the module. +- The LinkedIn accounts are archived, together with their posts. +- The LinkedIn specific data is lost, because Odoo drops the columns of + an uninstalled module: the application Client ID and Client Secret. +- The identifier of each account and publication on LinkedIn is kept, so + installing the module back and associating the account again + reactivates the archived history and updates it, instead of importing + everything as duplicated records. + +Statistics of the charts +------------------------ + +- The charts of *Social Media* > Charts group the LinkedIn statistics by + **day** or by **month** only. The + ```organizationalEntityShareStatistics`` `__ + endpoint documents those two as the values of its + ``timeGranularityType``, so no other one is offered for a LinkedIn + account. +- The date range has to cover at least one whole day or one whole month, + depending on the granularity selected. LinkedIn answers an error for a + shorter range, so it is refused before calling it. + +LinkedIn tokens +--------------- + +- The access token of LinkedIn lasts two months and its refresh token a + year, as stated in the `refresh tokens + documentation `__. + Within the week before the expiry date, and on every run of the + scheduled action *Social: Checking social media updates* and before + publishing, Odoo asks LinkedIn whether the token is still active + (``introspectToken`` endpoint) and only renews it when LinkedIn + answers that it is not. Outside of that window the check is answered + with the stored dates and costs no request. Nothing has to be done for + a post planned weeks ahead. +- If LinkedIn refuses the token anyway, it is renewed and the + publication is sent again straight away. +- Once the refresh token expires, or the authorization is revoked from + LinkedIn, no renewal is possible: the account shows the update warning + and has to be authorized again with *Update account*, which is the + only step that needs the browser. + +LinkedIn limits and validations +------------------------------- + +- The text of the post is **not** checked in Odoo: the ``commentary`` + field of the `Posts + API `__ + limits the body of a publication to 3.000 characters. A longer text + fails when it is sent and the line is left as *Failed* with the + validation error LinkedIn answers, so check the length before + publishing. +- The number of images is not checked either: one image is published as + a single image and two or more as a `multi-image + post `__. + Its limits are applied by LinkedIn when it receives the post, so a + number of images it does not accept is only detected when publishing. +- LinkedIn publishes **JPG, PNG and GIF** images and **MP4** videos. Any + other format is announced on the post as soon as it is attached and + refuses the publication of that line, which is left as *Failed* naming + the file. The images are only checked when the post carries no video, + because a video leaves them out anyway. +- Every call to LinkedIn has a timeout of **10 seconds**, not + configurable. If LinkedIn or the connection take longer, the operation + fails with *Error connecting to LinkedIn* and has to be retried; a + publication is left as *Failed* and can be sent again with the *Post* + button. +- An account without an access token does not publish: this is what + happens after uninstalling and installing the module back, which + clears the credentials. The line is left as *Failed* stating that the + account has no access token, and the account shows the update warning. + Authorize it again with *Update account*. +- The `Reactions + API `__ + only accepts **one reaction per account**: liking a publication that + was already liked answers *You have already reacted to this post.*, + and the reaction cannot be withdrawn from Odoo. If the publication was + deleted on LinkedIn, the message is *The post does not exist or has + been deleted.* +- Deleting a publication from the dashboard deletes it on LinkedIn + first. If LinkedIn does not confirm the deletion, the operation is + cancelled with *Error deleting LinkedIn post* and the record is kept + in Odoo, so the two sides never get out of sync. + +Errors reported by LinkedIn +--------------------------- + +- When LinkedIn rejects a request because of its content, its answer + only says that the validation failed and lists the rejected fields + apart, in the shape described in its `error handling + guide `__. + Those explanations are the ones shown in Odoo, one per line, so the + message names the field and the rule that was broken instead of the + generic summary. + +.. |CREATE_POST| image:: https://raw.githubusercontent.com/OCA/social/17.0/social_media_linkedin/static/img/readme/CREATE_POST.png +.. |BUTTON_UPDATE_ACCOUNT| image:: https://raw.githubusercontent.com/OCA/social/17.0/social_media_linkedin/static/img/readme/BUTTON_UPDATE_ACCOUNT.png +.. |UPDATE_KEYS| image:: https://raw.githubusercontent.com/OCA/social/17.0/social_media_linkedin/static/img/readme/UPDATE_KEYS.png +.. |UPDATE_TOKEN| image:: https://raw.githubusercontent.com/OCA/social/17.0/social_media_linkedin/static/img/readme/UPDATE_TOKEN.png +.. |VALIDATE_TOKEN| image:: https://raw.githubusercontent.com/OCA/social/17.0/social_media_linkedin/static/img/readme/VALIDATE_TOKEN.png +.. |ARCHIVE_ACCOUNT| image:: https://raw.githubusercontent.com/OCA/social/17.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, see the products of the `LinkedIn Marketing + API `__ + +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 `Comments + API `__, + in practice it doesn't work, even in paid versions. + +Post with video or image +------------------------ + +- The Posts API only supports creating a post with either images or a + video, not both at the same time: the ``content`` of a post holds a + single ``media`` entry. When a post carries a video, its images are + ignored. + + https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/posts-api?view=li-lms-2026-07&tabs=http#post-schema + +Images removed from a publication +--------------------------------- + +- The statistics synchronization mirrors the medias of the posts that + are still online: an image removed on LinkedIn leaves the publication + in Odoo too. A post deleted on LinkedIn is not touched, it keeps its + medias as history together with its *Deleted* state. +- The Posts API answers a post without media with an empty ``content``, + which is indistinguishable from a response that did not carry the + content this time. The medias themselves are the ones of the `Images + API `__. + An incomplete answer therefore drops the images of that publication. + The ones downloaded from LinkedIn come back on the next + synchronization, but a local copy attached at publishing time, kept + because LinkedIn never exposed its media, cannot be downloaded again + and is lost. + +Video of a publication +---------------------- + +- The video of a published post is not attached to the publication + itself: only the *has video* flag is kept, and the dashboard shows a + camera icon. The video stays available on the post it was published + from. + +Video upload +------------ + +- LinkedIn decides how a video is split: ``initializeUpload`` answers + one instruction per part, of 4 MiB each except the last one, and every + part is uploaded with its own request. The identifiers LinkedIn + returns for the parts are sent back to ``finalizeUpload`` in the same + order, so the video is put together as it was cut. A 22 MB video takes + 6 parts and around 25 seconds, upload and processing included. + + https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/videos-api + +- A post publishes a single video, so a post carrying several is refused + before uploading anything: LinkedIn would only keep the first one and + the others would be transferred and processed for nothing. + +- Odoo does not check the size nor the duration of the video before + uploading it: those limits are the ones of the `Videos + API `__ + and LinkedIn applies them while processing. A video out of limits is + transferred whole and rejected afterwards, in the processing phase, + with *LinkedIn could not process the video*. + +Publishing options +------------------ + +- Every publication is created **public**, in the main feed, with no + targeting by country, language or industry, and letting it be shared: + ``visibility``, ``feedDistribution``, ``targetEntities`` and + ``thirdPartyDistributionChannels`` are fixed in the code and cannot be + configured from Odoo. Scheduling is not delegated to LinkedIn either: + the Odoo scheduled action is the one that publishes when the date + arrives. + + https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/posts-api + +Rate limits +----------- + +- The module does not handle the `throttle + limits `__ + of LinkedIn, applied per day and per application. When LinkedIn + answers with a limit error, the operation is recorded as failed like + any other error and has to be retried later by hand; only a credential + rejection triggers an automatic retry, and only once. + +- Those limits are counted **per endpoint**, so what matters is not the + total number of calls but how they spread. A full synchronization + spends up to 50 calls on ``/rest/posts`` alone, plus one per batch of + identifiers on each of the statistics endpoints. + +- The scheduled action checking for updates costs **two calls per + account and run**, whatever the number of publications, one when it + does find something, and none at all for an account already announcing + updates or with no organization linked. It reads the figures LinkedIn + reports for the whole page day by day and compares them against the + ones the last import left: no publication is read one by one to decide + whether the dashboard should announce updates. Running every two + hours, that is around 24 calls a day per account. + +- The **Update** button no longer walks the feed. The statistics are + asked for by publication identifier, and those identifiers are already + stored in Odoo, so one page of the feed is enough: the one sorted by + last modification, which is what brings the publications created or + edited on LinkedIn. That is one call instead of one per hundred + publications. + +- **Asking LinkedIn for only the publications that changed is not + possible** with these endpoints, so the statistics still cost one call + per batch of identifiers whatever moved: + + - The ``/posts`` finder takes ``author``, ``start``, ``count``, + ``sortBy`` and ``viewContext``. There is no ``since`` of any kind, + so there is no equivalent to what the X connector does with + ``since_id``. + - The statistics of specific publications cannot be restricted to a + period: *"Time-bound statistics is not supported for specific share + queries"*. + - The daily figures of the page say that something moved, not which + publication moved. + +- There **is** a stream of engagement events, and it is not used. It + would tell which publication changed, and with it the refresh could + ask about that one alone. It is not implemented because of three + limits worth weighing first: it only reports reactions, comments and + reshares, never impressions or clicks, which would still need the + ordinary refresh; the publication it points at comes as an + ``urn:li:activity:`` and only the webhook payload carries the matching + ``urn:li:share:``, the pull finder does not, so pulling alone cannot + attribute an event to a publication; and the webhook needs a publicly + reachable HTTPS URL validated by LinkedIn plus a subscription per + member and organization. The permission it needs, + ``rw_organization_admin``, is already requested by this module. + + https://learn.microsoft.com/en-us/linkedin/marketing/community-management/organizations/organization-social-action-notifications + +Synchronization scope +--------------------- + +- **Only the full resync notices a publication deleted on LinkedIn.** + The ordinary refresh cannot: it does not read the whole feed any more, + so a publication missing from what it read says nothing. The *Full + resync* button of the account does it on demand, and a scheduled + action does it weekly on its own, so the *Deleted on LinkedIn* ribbon + may take up to a week to appear. + +- The full resync is also the expensive pass, one call per hundred + publications of every account, and every account runs in the same + weekly run. On a database with several large pages that run alone + could exhaust the daily quota and make the manual refresh fail with + it. Spreading the accounts over the days of the week is the way out + and is not implemented. + +- The synchronization walks the feed of the organization page by page + (``count=100``) up to **50 pages**, that is 5000 publications. A page + is only taken as the last one when it comes back empty, because + LinkedIn documents that a page may carry fewer publications than asked + while more are left. On an account whose feed is longer than that, the + answer is incomplete and the sweep that marks as *Deleted* what is no + longer on LinkedIn is skipped for that run: reporting nothing is + preferable to marking a publication that is still online. + +- The statistics of the publications are asked in as many calls as the 4 + KB limit of the query string needs, since those endpoints take every + identifier in the URL and none of them paginates. Around a hundred + publications fit in one call, so a full feed takes several. + + https://learn.microsoft.com/en-us/linkedin/marketing/community-management/organizations/share-statistics + +- The check for updates only tells that *something* on the page moved, + not which publication moved. It does not need to: the only thing it + decides is whether the dashboard shows the notice inviting to + synchronize. A change that cancels itself out between two runs, a + reaction removed and another one added, leaves the figures of the page + where they were and goes unnoticed until the next synchronization. + +- The check watches the **daily** figures of the page, over a window of + the last seven days, and not the lifetime totals the same endpoint + answers when no time interval is given. Those lifetime totals lag + behind: measured against a real account, a reaction was already + counted in the daily buckets while the lifetime figures still ignored + it an hour and a half later, and the sum of the twelve monthly buckets + matched the lifetime answer on every figure except that one reaction. + Two consequences worth knowing: + + - Activity older than the seven-day window is not announced. It is + imported all the same when the user synchronizes, because the import + reads the publications themselves and not this window. + - Impressions reach the daily buckets later than reactions do, so a + publication that only gained views may be announced a run or two + later than one that gained a reaction. + +- The engagement is not compared. It is a ratio of the clicks, + reactions, comments and shares over the impressions, so it cannot move + without one of those moving, and it is the only non-integer figure of + the set. + +- The statistics endpoint only answers activity of the **last 12 + months**, on a rolling window. A publication older than that stops + being counted, which can move the figures of the page on its own, with + nobody having interacted with anything. The check announces updates + once when that happens and the next import reconciles it. + +Video processing +---------------- + +- LinkedIn processes an uploaded video before it can be published, so + publishing a post with a video waits until the video is available. The + wait can be tuned with the + ``social_media_linkedin.video_poll_attempts`` and + ``social_media_linkedin.video_poll_delay`` system parameters (30 + attempts every 2 seconds by default). A long video may need more than + that. + +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 `__: + + - Edilio Escalona Almira e.escalona@binhex.cloud + +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. + +.. |maintainer-edescalona| image:: https://github.com/edescalona.png?size=40px + :target: https://github.com/edescalona + :alt: edescalona + +Current `maintainer `__: + +|maintainer-edescalona| + +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..7bc5850f4c --- /dev/null +++ b/social_media_linkedin/__init__.py @@ -0,0 +1,7 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import controllers +from . import models +from . import wizards +from .hooks import uninstall_hook diff --git a/social_media_linkedin/__manifest__.py b/social_media_linkedin/__manifest__.py new file mode 100644 index 0000000000..8d24a058fd --- /dev/null +++ b/social_media_linkedin/__manifest__.py @@ -0,0 +1,31 @@ +# 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": "17.0.1.0.0", + "category": "Social Network", + "development_status": "Beta", + "license": "AGPL-3", + "uninstall_hook": "uninstall_hook", + "author": "BinhexTeam,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/social", + "maintainers": ["edescalona"], + "depends": [ + "social_media_base", + ], + "data": [ + "data/social_media_data.xml", + "views/social_account_views.xml", + "wizards/wizard_social_account.xml", + ], + "assets": { + "web.assets_backend": [ + "social_media_linkedin/static/src/components/**/*.js", + "social_media_linkedin/static/src/js/services/**/*.js", + "social_media_linkedin/static/src/js/views/**/*.js", + ], + }, + "installable": True, +} diff --git a/social_media_linkedin/controllers/__init__.py b/social_media_linkedin/controllers/__init__.py new file mode 100644 index 0000000000..8da6ef3e17 --- /dev/null +++ b/social_media_linkedin/controllers/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +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..6c59517f89 --- /dev/null +++ b/social_media_linkedin/controllers/social_media_linkedin.py @@ -0,0 +1,66 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging + +from odoo import _, http +from odoo.exceptions import AccessError, UserError +from odoo.http import request, route + +_logger = logging.getLogger(__name__) + + +class SocialMediaLinkedin(http.Controller): + """Endpoint called by LinkedIn at the end of the OAuth flow.""" + + @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, + client_secret, + access_token, + ) = SocialAccount._get_access_token_linkedin( + code, request.httprequest.path, kwargs + ) + SocialAccount._create_account_linkedin( + client_id, client_secret, access_token + ) + SocialAccount._notify_user_session( + SocialAccount._format_user_notification( + _("The account was associated successfully"), + media="linkedin", + message_type="success", + ), + message_type="success", + ) + return request.redirect(SocialAccount._get_social_dashboard_url()) + except (AccessError, UserError) as e: + SocialAccount._consume_linkedin_oauth_wizard(kwargs.get("state", "")) + # These messages are written for the end user and explain what to + # do, so they are the only useful feedback of the association flow. + SocialAccount._notify_user_session( + SocialAccount._format_user_notification(str(e), media="linkedin") + ) + _logger.warning("Error in the LinkedIn OAuth callback: %s", e) + return request.redirect("/web") + except Exception: # noqa: BLE001 - the provider may fail in any way + SocialAccount._consume_linkedin_oauth_wizard(kwargs.get("state", "")) + # The exception may carry the raw provider response, so the user + # only gets a generic message and the detail stays in the log. + SocialAccount._notify_user_session( + SocialAccount._format_user_notification( + _( + "The account could not be associated. " + "Check the server log for details." + ), + media="linkedin", + ) + ) + _logger.exception("Error in the LinkedIn OAuth callback") + return request.redirect("/web") 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..a4fbaa0a5a --- /dev/null +++ b/social_media_linkedin/data/social_media_data.xml @@ -0,0 +1,17 @@ + + + + + LinkedIn + linkedin + Manage your LinkedIn account and schedule posts + + + diff --git a/social_media_linkedin/hooks.py b/social_media_linkedin/hooks.py new file mode 100644 index 0000000000..6f5d4eb79d --- /dev/null +++ b/social_media_linkedin/hooks.py @@ -0,0 +1,9 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.addons.social_media_base.hooks import remove_social_media + + +def uninstall_hook(env): + """Remove the LinkedIn data when the connector is uninstalled.""" + remove_social_media(env, "linkedin") diff --git a/social_media_linkedin/i18n/es.po b/social_media_linkedin/i18n/es.po new file mode 100644 index 0000000000..9d03da30b0 --- /dev/null +++ b/social_media_linkedin/i18n/es.po @@ -0,0 +1,544 @@ +# 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" +"Last-Translator: \n" +"Language-Team: \n" +"Language: es\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 +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "%(label)s: %(error)s" +msgstr "%(label)s: %(error)s" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "%(prefix)s: %(error)s" +msgstr "%(prefix)s: %(error)s" + +#. 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; revise también las cuentas archivadas." +"Ya existe una cuenta con esta información; revise también las cuentas " +"archivadas." + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_post_account.py:0 +#, python-format +msgid "" +"An error occurred while deleting the comment or it no longer exists, please " +"try again later." +msgstr "Se produjo un error al eliminar el comentario o el comentario ya no existe; vuelva a intentarlo más tarde." +"Se ha producido un error al eliminar el comentario o este ya no existe; " +"inténtelo de nuevo más tarde." + +#. 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_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 "Secreto de cliente" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "Creating account: %(error)s" +msgstr "Creando la cuenta: %(error)s" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "Creating account: LinkedIn answered without an access token. %(error)s" +msgstr "Creación de la cuenta: LinkedIn respondió sin token de acceso. %(error)s" +"Creando la cuenta: LinkedIn ha respondido sin token de acceso. %(error)s" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "Error connecting to LinkedIn: %(error)s" +msgstr "Error al conectar con LinkedIn: %(error)s" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_post_account.py:0 +#, python-format +msgid "Error deleting LinkedIn post: %(error)s" +msgstr "Error al eliminar la publicación de LinkedIn: %(error)s" + +#. 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 la información de la organización" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "Error reading the statistics of the LinkedIn account: %(error)s" +msgstr "Error al leer las estadísticas de la cuenta de LinkedIn: %(error)s" + +#. 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 "Caducidad del token de actualización" + +#. module: social_media_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_linkedin.social_account_view_form_inherit +msgid "Full resync" +msgstr "Resincronización completa" + +#. module: social_media_linkedin +#: model:ir.model.fields,field_description:social_media_linkedin.field_social_account__linkedin_granted_scopes +msgid "Granted Scopes" +msgstr "Permisos concedidos" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "" +"Invalid OAuth state token. Please restart the account association process." +msgstr "El token de estado de OAuth no es válido. Reinicie el proceso de asociación de la cuenta." +"Token de estado de OAuth no válido. Reinicie el proceso de asociación de la " +"cuenta." + +#. module: social_media_linkedin +#: model:ir.model.fields.selection,name:social_media_linkedin.selection__social_media__media_type__linkedin +msgid "LinkedIn" +msgstr "LinkedIn" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "LinkedIn could not process the video: %(reason)s" +msgstr "LinkedIn no ha podido procesar el vídeo: %(reason)s" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "" +"LinkedIn did not return the identifier of the part %(part)s of %(total)s, so " +"the upload of the video cannot be finalized." +msgstr "LinkedIn no devolvió el identificador de la parte %(part)s de %(total)s, así que la subida del vídeo no se puede finalizar." + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "LinkedIn did not return the reference of the uploaded image." +msgstr "LinkedIn no devolvió la referencia de la imagen subida." + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "" +"LinkedIn is still processing the video after %(seconds)s seconds. Please try " +"to publish the post again later." +msgstr "LinkedIn sigue procesando el vídeo después de %(seconds)s segundos. Vuelva a publicar más tarde." +"LinkedIn sigue procesando el vídeo después de %(seconds)s segundos. Intente " +"publicar de nuevo más tarde." + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_post_account.py:0 +#, python-format +msgid "" +"LinkedIn only publishes JPG, PNG and GIF images, so %(name)s (%(mimetype)s) " +"cannot be published. Convert it or remove it from the post." +msgstr "LinkedIn solo publica imágenes JPG, PNG y GIF, así que %(name)s (%(mimetype)s) no se puede publicar. Conviértala o quítela de la publicación." +"LinkedIn solo publica imágenes JPG, PNG y GIF, por lo que %(name)s " +"(%(mimetype)s) no se puede publicar. Conviértala o quítela de la publicación." + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_post.py:0 +#, python-format +msgid "" +"LinkedIn only publishes JPG, PNG and GIF images, so %(names)s will not be " +"published." +msgstr "LinkedIn solo publica imágenes JPG, PNG y GIF, así que %(names)s no se publicará." +"LinkedIn solo publica imágenes JPG, PNG y GIF, por lo que %(names)s no se " +"publicará." + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_post_account.py:0 +#, python-format +msgid "" +"LinkedIn only publishes MP4 videos, so %(name)s (%(mimetype)s) cannot be " +"published. Convert it to MP4 or remove it from the post." +msgstr "LinkedIn solo publica vídeos MP4, así que %(name)s (%(mimetype)s) no se puede publicar. Conviértalo a MP4 o quítelo de la publicación." +"LinkedIn solo publica vídeos MP4, por lo que %(name)s (%(mimetype)s) no se " +"puede publicar. Conviértalo a MP4 o quítelo de la publicación." + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_post.py:0 +#, python-format +msgid "LinkedIn only publishes MP4 videos, so %(names)s will not be published." +msgstr "LinkedIn solo publica vídeos MP4, así que %(names)s no se publicará." +"LinkedIn solo publica vídeos MP4, por lo que %(names)s no se publicará." + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_post_account.py:0 +#, python-format +msgid "" +"LinkedIn publishes a single video per post, so this post carrying %(videos)s " +"videos cannot be published. Leave one video or create a separate post for " +"each of them." +msgstr "LinkedIn publica un único vídeo por publicación, así que esta publicación con %(videos)s vídeos no se puede publicar. Deje un solo vídeo o cree una publicación para cada uno." +"LinkedIn publica un único vídeo por publicación, por lo que esta " +"publicación, que lleva %(videos)s vídeos, no se puede publicar. Deje un solo " +"vídeo o cree una publicación independiente para cada uno." + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_post.py:0 +#, python-format +msgid "" +"LinkedIn publishes a single video per post. Leave one video or create a " +"separate post for each of them." +msgstr "LinkedIn publica un único vídeo por publicación. Deje un solo vídeo o cree una publicación para cada uno." +"LinkedIn publica un único vídeo por publicación. Deje un solo vídeo o cree " +"una publicación independiente para cada uno." + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/tests/test_account_linkedin.py:0 +#, python-format +msgid "LinkedIn refused the page statistics" +msgstr "LinkedIn ha rechazado las estadísticas de la página" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/tests/test_account_linkedin.py:0 +#, python-format +msgid "LinkedIn refused the statistics" +msgstr "LinkedIn ha rechazado las estadísticas" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "" +"LinkedIn rejected the credentials of the App. Check the Client ID and the " +"Client Secret of your LinkedIn App." +msgstr "LinkedIn rechazó las credenciales de la App. Revise el Client ID y el Client Secret de su App de LinkedIn." +"LinkedIn ha rechazado las credenciales de la App. Revise el ID de cliente y " +"el secreto de cliente de su App de LinkedIn." + +#. 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_statistics_checkpoint +msgid "Linkedin Statistics Checkpoint" +msgstr "Punto de control de estadísticas de LinkedIn" + +#. 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 +#: model_terms:ir.ui.view,arch_db:social_media_linkedin.social_account_view_form_inherit +msgid "" +"Read the whole feed again and mark as deleted what is no longer on LinkedIn. " +"The Update button of the dashboard already refreshes the statistics, so this " +"is only needed to notice the publications deleted on LinkedIn, and the " +"scheduled action does it weekly on its own." +msgstr "Vuelve a leer el feed completo y marca como eliminado lo que ya no está en LinkedIn. El botón Actualizar del tablero ya refresca las estadísticas, así que esto solo hace falta para detectar las publicaciones eliminadas en LinkedIn, y la acción planificada lo hace cada semana por su cuenta." + +#. module: social_media_linkedin +#: model:ir.model.fields,help:social_media_linkedin.field_social_account__linkedin_granted_scopes +msgid "" +"Scopes LinkedIn granted to the token of this account, comma separated. The " +"next authorization asks for the scopes the installed modules need plus the " +"ones listed here, so a scope of a product enabled on the LinkedIn " +"application afterwards can be added by hand. The change takes effect only " +"once the account is authorized again with Update account and Update keys, " +"since refreshing the token alone keeps the scopes the current token was " +"granted. A scope that the products of the application do not grant makes " +"LinkedIn refuse the whole authorization." +msgstr "Permisos que LinkedIn concedió al token de esta cuenta, separados por comas. La siguiente autorización pide los permisos que necesitan los módulos instalados más los que se indiquen aquí, así que un permiso de un producto habilitado en la aplicación de LinkedIn después de asociar la cuenta se puede añadir a mano. El cambio surte efecto solo cuando la cuenta se vuelve a autorizar con Actualizar cuenta y Actualizar claves, ya que renovar el token conserva los permisos que el token actual tenía concedidos. Un permiso que los productos de la aplicación no conceden hace que LinkedIn rechace toda la autorización." + +#. module: social_media_linkedin +#: model:ir.model,name:social_media_linkedin.model_social_account +msgid "Social Account" +msgstr "Cuenta 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 social" + +#. module: social_media_linkedin +#: model:ir.model,name:social_media_linkedin.model_social_post_account +msgid "Social Post Account" +msgstr "Publicación por cuenta social" + +#. module: social_media_linkedin +#: model:ir.model.fields,help:social_media_linkedin.field_social_account__linkedin_statistics_checkpoint +msgid "" +"Technical field: the daily figures LinkedIn reported for the whole page over " +"the last days, as of the last time the publications were imported. The check " +"for updates compares the page against it instead of reading the statistics " +"of every publication." +msgstr "Campo técnico: las cifras diarias que LinkedIn reportó para toda la página en los últimos días, tal como estaban en la última importación de las publicaciones. La comprobación de actualizaciones compara la página contra ellas en vez de leer las estadísticas de cada publicación." +"Campo técnico: las cifras diarias que LinkedIn informó para toda la página " +"durante los últimos días, en el momento de la última importación de las " +"publicaciones. La comprobación de novedades compara la página con ellas en " +"lugar de leer las estadísticas de cada publicación." + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "" +"The LinkedIn account %(account)s was not authorized for %(scopes)s. Enable " +"the matching product on the LinkedIn application and associate the account " +"again." +msgstr "La cuenta de LinkedIn %(account)s no fue autorizada para %(scopes)s. Habilite el producto correspondiente en la aplicación de LinkedIn y vuelva a asociar la cuenta." +"La cuenta de LinkedIn %(account)s no ha sido autorizada para %(scopes)s. " +"Habilite el producto correspondiente en la aplicación de LinkedIn y asocie " +"la cuenta de nuevo." + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "The LinkedIn token could not be refreshed" +msgstr "No se pudo renovar el token de LinkedIn" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "" +"The access token of LinkedIn was revoked. Update the account to authorize it " +"again." +msgstr "El token de acceso de LinkedIn fue revocado. Actualice la cuenta para volver a autorizarla." +"El token de acceso de LinkedIn ha sido revocado. Actualice la cuenta para " +"autorizarla de nuevo." + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/controllers/social_media_linkedin.py:0 +#, python-format +msgid "The account could not be associated. Check the server log for details." +msgstr "No se pudo asociar la cuenta. Consulte el registro del servidor para ver los detalles." +"No se ha podido asociar la cuenta. Consulte el registro del servidor para " +"más detalles." + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_post_account.py:0 +#, python-format +msgid "" +"The account has no LinkedIn access token. Update the account to authorize it " +"again." +msgstr "La cuenta no tiene token de acceso de LinkedIn. Actualice la cuenta para volver a autorizarla." +"La cuenta no tiene token de acceso de LinkedIn. Actualice la cuenta para " +"autorizarla de nuevo." + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/controllers/social_media_linkedin.py:0 +#, python-format +msgid "The account was associated successfully" +msgstr "La cuenta se ha asociado correctamente" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "" +"The authorization of LinkedIn is no longer valid. Please restart the account " +"association process." +msgstr "La autorización de LinkedIn ya no es válida. Reinicie el proceso de asociación de la cuenta." +"La autorización de LinkedIn ya no es válida. Reinicie el proceso de " +"asociación de la cuenta." + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_post_account.py:0 +#, python-format +msgid "The comment could not be published on LinkedIn: %(error)s" +msgstr "No se pudo publicar el comentario en LinkedIn: %(error)s" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_post_account.py:0 +#, python-format +msgid "The comments could not be read from LinkedIn: %(error)s" +msgstr "No se pudieron leer los comentarios de LinkedIn: %(error)s" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "The image could not be uploaded to LinkedIn" +msgstr "No se pudo subir la imagen a LinkedIn" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "" +"The likes and the comments of the publications could not be read: %(error)s" +msgstr "No se pudieron leer los me gusta ni los comentarios de las publicaciones: %(error)s" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_post_account.py:0 +#, python-format +msgid "The post could not be deleted, please try again later." +msgstr "No se ha podido eliminar la publicación; inténtelo de nuevo más tarde." + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "The post could not be published on LinkedIn" +msgstr "No se pudo publicar la publicación en LinkedIn" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_post_account.py:0 +#, python-format +msgid "The post does not exist or has been deleted." +msgstr "La publicación no existe o ha sido eliminada." + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "The publications could not be read from LinkedIn: %(error)s" +msgstr "No se pudieron leer las publicaciones de LinkedIn: %(error)s" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "The statistics of the publications could not be read" +msgstr "No se pudieron leer las estadísticas de las publicaciones" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "The statistics of the shared publications could not be read" +msgstr "No se pudieron leer las estadísticas de las publicaciones compartidas" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "The status of the video could not be read" +msgstr "No se pudo leer el estado del vídeo" + +#. 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 ha actualizado correctamente" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "The video could not be uploaded to LinkedIn" +msgstr "No se pudo subir el vídeo a LinkedIn" + +#. module: social_media_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_linkedin.social_account_view_form_inherit +msgid "" +"The whole feed of the page will be read again, which costs one call per " +"hundred publications against the daily quota of LinkedIn. Continue?" +msgstr "Se volverá a leer el feed completo de la página, lo que cuesta una llamada por cada cien publicaciones contra la cuota diaria de LinkedIn. ¿Continuar?" + +#. module: social_media_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_linkedin.social_account_view_form_inherit +msgid "Validate token" +msgstr "Validar token" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_post_account.py:0 +#, python-format +msgid "You have already reacted to this post." +msgstr "Ya ha reaccionado a esta publicación." + +#. 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 video will be published. If you wish to publish the " +"images, please remove the video from this post or create a separate post." +msgstr "Ha seleccionado imágenes y vídeos para esta publicación. Sin embargo, la red social LinkedIn no permite combinar los dos tipos de contenido en la misma publicación. Por eso solo se publicará el vídeo. Si desea publicar las imágenes, quite el vídeo de esta publicación o cree una publicación aparte." +"Ha seleccionado imágenes y vídeos para esta publicación. Sin embargo, la red " +"social LinkedIn no permite combinar ambos tipos de contenido en la misma " +"publicación. Por lo tanto, solo se publicará el vídeo. Si desea publicar las " +"imágenes, quite el vídeo de esta publicación o cree una publicación aparte." + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_post_account.py:0 +#, python-format +msgid "the account has no access token" +msgstr "la cuenta no tiene token de acceso" diff --git a/social_media_linkedin/i18n/es_ES.po b/social_media_linkedin/i18n/es_ES.po new file mode 100644 index 0000000000..b230e0bebe --- /dev/null +++ b/social_media_linkedin/i18n/es_ES.po @@ -0,0 +1,544 @@ +# 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" +"Last-Translator: \n" +"Language-Team: \n" +"Language: es_ES\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 +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "%(label)s: %(error)s" +msgstr "%(label)s: %(error)s" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "%(prefix)s: %(error)s" +msgstr "%(prefix)s: %(error)s" + +#. 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; revise también las cuentas archivadas." +"Ya existe una cuenta con esta información; revise también las cuentas " +"archivadas." + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_post_account.py:0 +#, python-format +msgid "" +"An error occurred while deleting the comment or it no longer exists, please " +"try again later." +msgstr "Se produjo un error al eliminar el comentario o el comentario ya no existe; vuelva a intentarlo más tarde." +"Se ha producido un error al eliminar el comentario o este ya no existe; " +"inténtelo de nuevo más tarde." + +#. 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_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 "Secreto de cliente" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "Creating account: %(error)s" +msgstr "Creando la cuenta: %(error)s" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "Creating account: LinkedIn answered without an access token. %(error)s" +msgstr "Creación de la cuenta: LinkedIn respondió sin token de acceso. %(error)s" +"Creando la cuenta: LinkedIn ha respondido sin token de acceso. %(error)s" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "Error connecting to LinkedIn: %(error)s" +msgstr "Error al conectar con LinkedIn: %(error)s" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_post_account.py:0 +#, python-format +msgid "Error deleting LinkedIn post: %(error)s" +msgstr "Error al eliminar la publicación de LinkedIn: %(error)s" + +#. 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 la información de la organización" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "Error reading the statistics of the LinkedIn account: %(error)s" +msgstr "Error al leer las estadísticas de la cuenta de LinkedIn: %(error)s" + +#. 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 "Caducidad del token de actualización" + +#. module: social_media_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_linkedin.social_account_view_form_inherit +msgid "Full resync" +msgstr "Resincronización completa" + +#. module: social_media_linkedin +#: model:ir.model.fields,field_description:social_media_linkedin.field_social_account__linkedin_granted_scopes +msgid "Granted Scopes" +msgstr "Permisos concedidos" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "" +"Invalid OAuth state token. Please restart the account association process." +msgstr "El token de estado de OAuth no es válido. Reinicie el proceso de asociación de la cuenta." +"Token de estado de OAuth no válido. Reinicie el proceso de asociación de la " +"cuenta." + +#. module: social_media_linkedin +#: model:ir.model.fields.selection,name:social_media_linkedin.selection__social_media__media_type__linkedin +msgid "LinkedIn" +msgstr "LinkedIn" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "LinkedIn could not process the video: %(reason)s" +msgstr "LinkedIn no ha podido procesar el vídeo: %(reason)s" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "" +"LinkedIn did not return the identifier of the part %(part)s of %(total)s, so " +"the upload of the video cannot be finalized." +msgstr "LinkedIn no devolvió el identificador de la parte %(part)s de %(total)s, así que la subida del vídeo no se puede finalizar." + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "LinkedIn did not return the reference of the uploaded image." +msgstr "LinkedIn no devolvió la referencia de la imagen subida." + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "" +"LinkedIn is still processing the video after %(seconds)s seconds. Please try " +"to publish the post again later." +msgstr "LinkedIn sigue procesando el vídeo después de %(seconds)s segundos. Vuelva a publicar más tarde." +"LinkedIn sigue procesando el vídeo después de %(seconds)s segundos. Intente " +"publicar de nuevo más tarde." + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_post_account.py:0 +#, python-format +msgid "" +"LinkedIn only publishes JPG, PNG and GIF images, so %(name)s (%(mimetype)s) " +"cannot be published. Convert it or remove it from the post." +msgstr "LinkedIn solo publica imágenes JPG, PNG y GIF, así que %(name)s (%(mimetype)s) no se puede publicar. Conviértala o quítela de la publicación." +"LinkedIn solo publica imágenes JPG, PNG y GIF, por lo que %(name)s " +"(%(mimetype)s) no se puede publicar. Conviértala o quítela de la publicación." + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_post.py:0 +#, python-format +msgid "" +"LinkedIn only publishes JPG, PNG and GIF images, so %(names)s will not be " +"published." +msgstr "LinkedIn solo publica imágenes JPG, PNG y GIF, así que %(names)s no se publicará." +"LinkedIn solo publica imágenes JPG, PNG y GIF, por lo que %(names)s no se " +"publicará." + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_post_account.py:0 +#, python-format +msgid "" +"LinkedIn only publishes MP4 videos, so %(name)s (%(mimetype)s) cannot be " +"published. Convert it to MP4 or remove it from the post." +msgstr "LinkedIn solo publica vídeos MP4, así que %(name)s (%(mimetype)s) no se puede publicar. Conviértalo a MP4 o quítelo de la publicación." +"LinkedIn solo publica vídeos MP4, por lo que %(name)s (%(mimetype)s) no se " +"puede publicar. Conviértalo a MP4 o quítelo de la publicación." + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_post.py:0 +#, python-format +msgid "LinkedIn only publishes MP4 videos, so %(names)s will not be published." +msgstr "LinkedIn solo publica vídeos MP4, así que %(names)s no se publicará." +"LinkedIn solo publica vídeos MP4, por lo que %(names)s no se publicará." + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_post_account.py:0 +#, python-format +msgid "" +"LinkedIn publishes a single video per post, so this post carrying %(videos)s " +"videos cannot be published. Leave one video or create a separate post for " +"each of them." +msgstr "LinkedIn publica un único vídeo por publicación, así que esta publicación con %(videos)s vídeos no se puede publicar. Deje un solo vídeo o cree una publicación para cada uno." +"LinkedIn publica un único vídeo por publicación, por lo que esta " +"publicación, que lleva %(videos)s vídeos, no se puede publicar. Deje un solo " +"vídeo o cree una publicación independiente para cada uno." + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_post.py:0 +#, python-format +msgid "" +"LinkedIn publishes a single video per post. Leave one video or create a " +"separate post for each of them." +msgstr "LinkedIn publica un único vídeo por publicación. Deje un solo vídeo o cree una publicación para cada uno." +"LinkedIn publica un único vídeo por publicación. Deje un solo vídeo o cree " +"una publicación independiente para cada uno." + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/tests/test_account_linkedin.py:0 +#, python-format +msgid "LinkedIn refused the page statistics" +msgstr "LinkedIn ha rechazado las estadísticas de la página" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/tests/test_account_linkedin.py:0 +#, python-format +msgid "LinkedIn refused the statistics" +msgstr "LinkedIn ha rechazado las estadísticas" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "" +"LinkedIn rejected the credentials of the App. Check the Client ID and the " +"Client Secret of your LinkedIn App." +msgstr "LinkedIn rechazó las credenciales de la App. Revise el Client ID y el Client Secret de su App de LinkedIn." +"LinkedIn ha rechazado las credenciales de la App. Revise el ID de cliente y " +"el secreto de cliente de su App de LinkedIn." + +#. 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_statistics_checkpoint +msgid "Linkedin Statistics Checkpoint" +msgstr "Punto de control de estadísticas de LinkedIn" + +#. 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 +#: model_terms:ir.ui.view,arch_db:social_media_linkedin.social_account_view_form_inherit +msgid "" +"Read the whole feed again and mark as deleted what is no longer on LinkedIn. " +"The Update button of the dashboard already refreshes the statistics, so this " +"is only needed to notice the publications deleted on LinkedIn, and the " +"scheduled action does it weekly on its own." +msgstr "Vuelve a leer el feed completo y marca como eliminado lo que ya no está en LinkedIn. El botón Actualizar del tablero ya refresca las estadísticas, así que esto solo hace falta para detectar las publicaciones eliminadas en LinkedIn, y la acción planificada lo hace cada semana por su cuenta." + +#. module: social_media_linkedin +#: model:ir.model.fields,help:social_media_linkedin.field_social_account__linkedin_granted_scopes +msgid "" +"Scopes LinkedIn granted to the token of this account, comma separated. The " +"next authorization asks for the scopes the installed modules need plus the " +"ones listed here, so a scope of a product enabled on the LinkedIn " +"application afterwards can be added by hand. The change takes effect only " +"once the account is authorized again with Update account and Update keys, " +"since refreshing the token alone keeps the scopes the current token was " +"granted. A scope that the products of the application do not grant makes " +"LinkedIn refuse the whole authorization." +msgstr "Permisos que LinkedIn concedió al token de esta cuenta, separados por comas. La siguiente autorización pide los permisos que necesitan los módulos instalados más los que se indiquen aquí, así que un permiso de un producto habilitado en la aplicación de LinkedIn después de asociar la cuenta se puede añadir a mano. El cambio surte efecto solo cuando la cuenta se vuelve a autorizar con Actualizar cuenta y Actualizar claves, ya que renovar el token conserva los permisos que el token actual tenía concedidos. Un permiso que los productos de la aplicación no conceden hace que LinkedIn rechace toda la autorización." + +#. module: social_media_linkedin +#: model:ir.model,name:social_media_linkedin.model_social_account +msgid "Social Account" +msgstr "Cuenta 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 social" + +#. module: social_media_linkedin +#: model:ir.model,name:social_media_linkedin.model_social_post_account +msgid "Social Post Account" +msgstr "Publicación por cuenta social" + +#. module: social_media_linkedin +#: model:ir.model.fields,help:social_media_linkedin.field_social_account__linkedin_statistics_checkpoint +msgid "" +"Technical field: the daily figures LinkedIn reported for the whole page over " +"the last days, as of the last time the publications were imported. The check " +"for updates compares the page against it instead of reading the statistics " +"of every publication." +msgstr "Campo técnico: las cifras diarias que LinkedIn reportó para toda la página en los últimos días, tal como estaban en la última importación de las publicaciones. La comprobación de actualizaciones compara la página contra ellas en vez de leer las estadísticas de cada publicación." +"Campo técnico: las cifras diarias que LinkedIn informó para toda la página " +"durante los últimos días, en el momento de la última importación de las " +"publicaciones. La comprobación de novedades compara la página con ellas en " +"lugar de leer las estadísticas de cada publicación." + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "" +"The LinkedIn account %(account)s was not authorized for %(scopes)s. Enable " +"the matching product on the LinkedIn application and associate the account " +"again." +msgstr "La cuenta de LinkedIn %(account)s no fue autorizada para %(scopes)s. Habilite el producto correspondiente en la aplicación de LinkedIn y vuelva a asociar la cuenta." +"La cuenta de LinkedIn %(account)s no ha sido autorizada para %(scopes)s. " +"Habilite el producto correspondiente en la aplicación de LinkedIn y asocie " +"la cuenta de nuevo." + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "The LinkedIn token could not be refreshed" +msgstr "No se pudo renovar el token de LinkedIn" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "" +"The access token of LinkedIn was revoked. Update the account to authorize it " +"again." +msgstr "El token de acceso de LinkedIn fue revocado. Actualice la cuenta para volver a autorizarla." +"El token de acceso de LinkedIn ha sido revocado. Actualice la cuenta para " +"autorizarla de nuevo." + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/controllers/social_media_linkedin.py:0 +#, python-format +msgid "The account could not be associated. Check the server log for details." +msgstr "No se pudo asociar la cuenta. Consulte el registro del servidor para ver los detalles." +"No se ha podido asociar la cuenta. Consulte el registro del servidor para " +"más detalles." + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_post_account.py:0 +#, python-format +msgid "" +"The account has no LinkedIn access token. Update the account to authorize it " +"again." +msgstr "La cuenta no tiene token de acceso de LinkedIn. Actualice la cuenta para volver a autorizarla." +"La cuenta no tiene token de acceso de LinkedIn. Actualice la cuenta para " +"autorizarla de nuevo." + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/controllers/social_media_linkedin.py:0 +#, python-format +msgid "The account was associated successfully" +msgstr "La cuenta se ha asociado correctamente" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "" +"The authorization of LinkedIn is no longer valid. Please restart the account " +"association process." +msgstr "La autorización de LinkedIn ya no es válida. Reinicie el proceso de asociación de la cuenta." +"La autorización de LinkedIn ya no es válida. Reinicie el proceso de " +"asociación de la cuenta." + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_post_account.py:0 +#, python-format +msgid "The comment could not be published on LinkedIn: %(error)s" +msgstr "No se pudo publicar el comentario en LinkedIn: %(error)s" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_post_account.py:0 +#, python-format +msgid "The comments could not be read from LinkedIn: %(error)s" +msgstr "No se pudieron leer los comentarios de LinkedIn: %(error)s" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "The image could not be uploaded to LinkedIn" +msgstr "No se pudo subir la imagen a LinkedIn" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "" +"The likes and the comments of the publications could not be read: %(error)s" +msgstr "No se pudieron leer los me gusta ni los comentarios de las publicaciones: %(error)s" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_post_account.py:0 +#, python-format +msgid "The post could not be deleted, please try again later." +msgstr "No se ha podido eliminar la publicación; inténtelo de nuevo más tarde." + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "The post could not be published on LinkedIn" +msgstr "No se pudo publicar la publicación en LinkedIn" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_post_account.py:0 +#, python-format +msgid "The post does not exist or has been deleted." +msgstr "La publicación no existe o ha sido eliminada." + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "The publications could not be read from LinkedIn: %(error)s" +msgstr "No se pudieron leer las publicaciones de LinkedIn: %(error)s" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "The statistics of the publications could not be read" +msgstr "No se pudieron leer las estadísticas de las publicaciones" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "The statistics of the shared publications could not be read" +msgstr "No se pudieron leer las estadísticas de las publicaciones compartidas" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "The status of the video could not be read" +msgstr "No se pudo leer el estado del vídeo" + +#. 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 ha actualizado correctamente" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "The video could not be uploaded to LinkedIn" +msgstr "No se pudo subir el vídeo a LinkedIn" + +#. module: social_media_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_linkedin.social_account_view_form_inherit +msgid "" +"The whole feed of the page will be read again, which costs one call per " +"hundred publications against the daily quota of LinkedIn. Continue?" +msgstr "Se volverá a leer el feed completo de la página, lo que cuesta una llamada por cada cien publicaciones contra la cuota diaria de LinkedIn. ¿Continuar?" + +#. module: social_media_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_linkedin.social_account_view_form_inherit +msgid "Validate token" +msgstr "Validar token" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_post_account.py:0 +#, python-format +msgid "You have already reacted to this post." +msgstr "Ya ha reaccionado a esta publicación." + +#. 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 video will be published. If you wish to publish the " +"images, please remove the video from this post or create a separate post." +msgstr "Ha seleccionado imágenes y vídeos para esta publicación. Sin embargo, la red social LinkedIn no permite combinar los dos tipos de contenido en la misma publicación. Por eso solo se publicará el vídeo. Si desea publicar las imágenes, quite el vídeo de esta publicación o cree una publicación aparte." +"Ha seleccionado imágenes y vídeos para esta publicación. Sin embargo, la red " +"social LinkedIn no permite combinar ambos tipos de contenido en la misma " +"publicación. Por lo tanto, solo se publicará el vídeo. Si desea publicar las " +"imágenes, quite el vídeo de esta publicación o cree una publicación aparte." + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_post_account.py:0 +#, python-format +msgid "the account has no access token" +msgstr "la cuenta no tiene token de acceso" 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..b67624ca2a --- /dev/null +++ b/social_media_linkedin/i18n/social_media_linkedin.pot @@ -0,0 +1,505 @@ +# 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" +"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 +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "%(label)s: %(error)s" +msgstr "" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "%(prefix)s: %(error)s" +msgstr "" + +#. 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 "" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_post_account.py:0 +#, python-format +msgid "" +"An error occurred while deleting the comment or it no longer exists, please " +"try again later." +msgstr "" + +#. module: social_media_linkedin +#: model:ir.model,name:social_media_linkedin.model_wizard_social_account +msgid "Associate Social Media Account" +msgstr "" + +#. 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 "" + +#. 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 "" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "Creating account: %(error)s" +msgstr "" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "Creating account: LinkedIn answered without an access token. %(error)s" +msgstr "" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "Error connecting to LinkedIn: %(error)s" +msgstr "" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_post_account.py:0 +#, python-format +msgid "Error deleting LinkedIn post: %(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 "" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "Error reading the statistics of the LinkedIn account: %(error)s" +msgstr "" + +#. 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 "" + +#. module: social_media_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_linkedin.social_account_view_form_inherit +msgid "Full resync" +msgstr "" + +#. module: social_media_linkedin +#: model:ir.model.fields,field_description:social_media_linkedin.field_social_account__linkedin_granted_scopes +msgid "Granted Scopes" +msgstr "" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "" +"Invalid OAuth state token. Please restart the account association process." +msgstr "" + +#. 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 +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "LinkedIn could not process the video: %(reason)s" +msgstr "" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "" +"LinkedIn did not return the identifier of the part %(part)s of %(total)s, so" +" the upload of the video cannot be finalized." +msgstr "" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "LinkedIn did not return the reference of the uploaded image." +msgstr "" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "" +"LinkedIn is still processing the video after %(seconds)s seconds. Please try" +" to publish the post again later." +msgstr "" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_post_account.py:0 +#, python-format +msgid "" +"LinkedIn only publishes JPG, PNG and GIF images, so %(name)s (%(mimetype)s) " +"cannot be published. Convert it or remove it from the post." +msgstr "" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_post.py:0 +#, python-format +msgid "" +"LinkedIn only publishes JPG, PNG and GIF images, so %(names)s will not be " +"published." +msgstr "" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_post_account.py:0 +#, python-format +msgid "" +"LinkedIn only publishes MP4 videos, so %(name)s (%(mimetype)s) cannot be " +"published. Convert it to MP4 or remove it from the post." +msgstr "" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_post.py:0 +#, python-format +msgid "" +"LinkedIn only publishes MP4 videos, so %(names)s will not be published." +msgstr "" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_post_account.py:0 +#, python-format +msgid "" +"LinkedIn publishes a single video per post, so this post carrying %(videos)s" +" videos cannot be published. Leave one video or create a separate post for " +"each of them." +msgstr "" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_post.py:0 +#, python-format +msgid "" +"LinkedIn publishes a single video per post. Leave one video or create a " +"separate post for each of them." +msgstr "" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/tests/test_account_linkedin.py:0 +#, python-format +msgid "LinkedIn refused the page statistics" +msgstr "" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/tests/test_account_linkedin.py:0 +#, python-format +msgid "LinkedIn refused the statistics" +msgstr "" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "" +"LinkedIn rejected the credentials of the App. Check the Client ID and the " +"Client Secret of your LinkedIn App." +msgstr "" + +#. module: social_media_linkedin +#: model:ir.model.fields,field_description:social_media_linkedin.field_social_account__linkedin_account_id +msgid "Linkedin Account" +msgstr "" + +#. module: social_media_linkedin +#: model:ir.model.fields,field_description:social_media_linkedin.field_social_account__linkedin_statistics_checkpoint +msgid "Linkedin Statistics Checkpoint" +msgstr "" + +#. module: social_media_linkedin +#: model:ir.model.fields,field_description:social_media_linkedin.field_social_media__media_type +msgid "Media Type" +msgstr "" + +#. module: social_media_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_linkedin.social_account_view_form_inherit +msgid "" +"Read the whole feed again and mark as deleted what is no longer on LinkedIn." +" The Update button of the dashboard already refreshes the statistics, so " +"this is only needed to notice the publications deleted on LinkedIn, and the " +"scheduled action does it weekly on its own." +msgstr "" + +#. module: social_media_linkedin +#: model:ir.model.fields,help:social_media_linkedin.field_social_account__linkedin_granted_scopes +msgid "" +"Scopes LinkedIn granted to the token of this account, comma separated. The " +"next authorization asks for the scopes the installed modules need plus the " +"ones listed here, so a scope of a product enabled on the LinkedIn " +"application afterwards can be added by hand. The change takes effect only " +"once the account is authorized again with Update account and Update keys, " +"since refreshing the token alone keeps the scopes the current token was " +"granted. A scope that the products of the application do not grant makes " +"LinkedIn refuse the whole authorization." +msgstr "" + +#. module: social_media_linkedin +#: model:ir.model,name:social_media_linkedin.model_social_account +msgid "Social Account" +msgstr "" + +#. module: social_media_linkedin +#: model:ir.model,name:social_media_linkedin.model_social_media +msgid "Social Media" +msgstr "" + +#. module: social_media_linkedin +#: model:ir.model,name:social_media_linkedin.model_social_post +msgid "Social Post" +msgstr "" + +#. module: social_media_linkedin +#: model:ir.model,name:social_media_linkedin.model_social_post_account +msgid "Social Post Account" +msgstr "" + +#. module: social_media_linkedin +#: model:ir.model.fields,help:social_media_linkedin.field_social_account__linkedin_statistics_checkpoint +msgid "" +"Technical field: the daily figures LinkedIn reported for the whole page over" +" the last days, as of the last time the publications were imported. The " +"check for updates compares the page against it instead of reading the " +"statistics of every publication." +msgstr "" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "" +"The LinkedIn account %(account)s was not authorized for %(scopes)s. Enable " +"the matching product on the LinkedIn application and associate the account " +"again." +msgstr "" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "The LinkedIn token could not be refreshed" +msgstr "" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "" +"The access token of LinkedIn was revoked. Update the account to authorize it" +" again." +msgstr "" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/controllers/social_media_linkedin.py:0 +#, python-format +msgid "The account could not be associated. Check the server log for details." +msgstr "" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_post_account.py:0 +#, python-format +msgid "" +"The account has no LinkedIn access token. Update the account to authorize it" +" again." +msgstr "" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/controllers/social_media_linkedin.py:0 +#, python-format +msgid "The account was associated successfully" +msgstr "" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "" +"The authorization of LinkedIn is no longer valid. Please restart the account" +" association process." +msgstr "" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_post_account.py:0 +#, python-format +msgid "The comment could not be published on LinkedIn: %(error)s" +msgstr "" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_post_account.py:0 +#, python-format +msgid "The comments could not be read from LinkedIn: %(error)s" +msgstr "" + +#. 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 "The image could not be uploaded to LinkedIn" +msgstr "" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "" +"The likes and the comments of the publications could not be read: %(error)s" +msgstr "" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_post_account.py:0 +#, python-format +msgid "The post could not be deleted, please try again later." +msgstr "" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "The post could not be published on LinkedIn" +msgstr "" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_post_account.py:0 +#, python-format +msgid "The post does not exist or has been deleted." +msgstr "" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "The publications could not be read from LinkedIn: %(error)s" +msgstr "" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "The statistics of the publications could not be read" +msgstr "" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "The statistics of the shared publications could not be read" +msgstr "" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "The status of the video could not be read" +msgstr "" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "The token is valid." +msgstr "" + +#. 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 "" + +#. 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 +#: code:addons/social_media_linkedin/models/social_account.py:0 +#, python-format +msgid "The video could not be uploaded to LinkedIn" +msgstr "" + +#. module: social_media_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_linkedin.social_account_view_form_inherit +msgid "" +"The whole feed of the page will be read again, which costs one call per " +"hundred publications against the daily quota of LinkedIn. Continue?" +msgstr "" + +#. module: social_media_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_linkedin.social_account_view_form_inherit +msgid "Validate token" +msgstr "" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_post_account.py:0 +#, python-format +msgid "You have already reacted to this post." +msgstr "" + +#. 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 video will be published. If you wish to publish the " +"images, please remove the video from this post or create a separate post." +msgstr "" + +#. module: social_media_linkedin +#. odoo-python +#: code:addons/social_media_linkedin/models/social_post_account.py:0 +#, python-format +msgid "the account has no access token" +msgstr "" diff --git a/social_media_linkedin/models/__init__.py b/social_media_linkedin/models/__init__.py new file mode 100644 index 0000000000..7b266192c5 --- /dev/null +++ b/social_media_linkedin/models/__init__.py @@ -0,0 +1,7 @@ +# 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 diff --git a/social_media_linkedin/models/social_account.py b/social_media_linkedin/models/social_account.py new file mode 100644 index 0000000000..16aa2bab1a --- /dev/null +++ b/social_media_linkedin/models/social_account.py @@ -0,0 +1,2072 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import base64 +import itertools +import logging +import time +from contextlib import contextmanager +from datetime import date, datetime, timedelta +from urllib.parse import quote, urljoin + +import psycopg2 +import pytz +import requests + +from odoo import Command, _, api, fields, models +from odoo.exceptions import UserError +from odoo.service.model import PG_CONCURRENCY_ERRORS_TO_RETRY + +from odoo.addons.social_media_base.exceptions import SocialCredentialsError +from odoo.addons.social_media_base.social_utils import ( + convert_to_date, + get_chart_period_key, + social_url_encode, +) + +from ..social_linkedin_utils import ( + _CHART_HISTORY_MONTHS_LINKEDIN, + _FINDER_PARAMS_LINKEDIN, + _POSTS_MAX_PAGES_LINKEDIN, + _POSTS_PAGE_SIZE_LINKEDIN, + _TOKEN_MARGIN_DAYS_LINKEDIN, + _UPDATE_CHECK_DAYS_LINKEDIN, + _UPDATE_CHECK_FIGURES_LINKEDIN, + _URL_AUTH_V2_LINKEDIN, + _URL_FEED_UPDATE_LINKEDIN, + _URL_REST_LINKEDIN, + _URL_V2_LINKEDIN, + _URN_ORGANIZATION_LINKEDIN, + _URN_SHARE_LINKEDIN, + _URN_UGC_POST_LINKEDIN, + _URN_VIDEO_LINKEDIN, + _VIDEO_POLL_ATTEMPTS_LINKEDIN, + _VIDEO_POLL_DELAY_LINKEDIN, + _VIDEO_UPLOAD_PART_SIZE_LINKEDIN, + _batch_urns_by_url_size, + _linkedin_error_code, + _linkedin_error_detail, + _linkedin_is_credentials_error, + _linkedin_statistics_checkpoint, + _linkedin_statistics_moved, + _linkedin_statistics_snapshot, +) + +_logger = logging.getLogger(__name__) + + +class SocialAccount(models.Model): + """LinkedIn implementation of the social account API calls.""" + + _inherit = "social.account" + + linkedin_account_id = fields.Char( + compute="_compute_linkedin_account_id", store=True + ) + refresh_token_expires_in = fields.Date(string="Expire Refresh Token") + linkedin_client_id = fields.Char(string="Client ID", groups="base.group_system") + linkedin_secret = fields.Char( + string="Client Secret", + groups="base.group_system", + ) + linkedin_statistics_checkpoint = fields.Char( + copy=False, + help="Technical field: the daily figures LinkedIn reported for the " + "whole page over the last days, as of the last time the publications " + "were imported. The check for updates compares the page against it " + "instead of reading the statistics of every publication.", + ) + linkedin_granted_scopes = fields.Char( + string="Granted Scopes", + groups="base.group_system", + help="Scopes LinkedIn granted to the token of this account, comma " + "separated. The next authorization asks for the scopes the installed " + "modules need plus the ones listed here, so a scope of a product " + "enabled on the LinkedIn application afterwards can be added by " + "hand. The change takes effect only once the account is authorized " + "again with Update account and Update keys, since refreshing the " + "token alone keeps the scopes the current token was granted. A scope " + "that the products of the application do not grant makes LinkedIn " + "refuse the whole authorization.", + ) + + def _fields_account_url(self): + return super()._fields_account_url() + [ + ( + "linkedin", + "https://www.linkedin.com/company/" + f"{self.linkedin_account_id}/admin/dashboard/", + ) + ] + + @api.depends("remote_ref", "media_type") + def _compute_linkedin_account_id(self): + for social_account in self: + if social_account.media_type == "linkedin" and social_account.remote_ref: + social_account.linkedin_account_id = social_account.remote_ref.split( + ":" + )[-1] + else: + social_account.linkedin_account_id = False + + def _unique_account(self, linkedin_client_id=None, linkedin_secret=None): + """Reject a LinkedIn application already used by another account. + + Archived accounts are checked too, as they keep their credentials. + The accounts of ``self`` are excluded, so updating the keys of an + account is not rejected by its own credentials. On the empty recordset + of the association flow nothing is excluded. + """ + account_sudo = self.sudo() + account_count = account_sudo.with_context(active_test=False).search_count( + [ + ("id", "not in", account_sudo.ids), + ( + "linkedin_client_id", + "=", + linkedin_client_id or account_sudo.linkedin_client_id, + ), + ( + "linkedin_secret", + "=", + linkedin_secret or account_sudo.linkedin_secret, + ), + ] + ) + if account_count > 0: + raise UserError( + _( + "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, + ): + """Perform a LinkedIn API request. + + :return: the parsed JSON dict when ``return_json`` is True and the + response status is 200; the raw ``requests.Response`` + otherwise. Callers must check the returned type. + """ + 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.exceptions.RequestException as ex: + raise UserError( + _("Error connecting to LinkedIn: %(error)s", error=ex) + ) from ex + + @api.model + def _linkedin_error_message(self, error): + """Build a message for the user out of what LinkedIn answered. + + The codes that name a problem the user can act on get their own + explanation. Anything else is reported with the explanation that + LinkedIn itself gives, instead of its raw answer. + + :param error: what LinkedIn answered, usually a ``requests.Response``. + :rtype: str + """ + code = _linkedin_error_code(error) + if code == "invalid_client": + return _( + "LinkedIn rejected the credentials of the App. Check the " + "Client ID and the Client Secret of your LinkedIn App." + ) + if code in ("invalid_grant", "invalid_request"): + return _( + "The authorization of LinkedIn is no longer valid. Please " + "restart the account association process." + ) + if code == "REVOKED_ACCESS_TOKEN": + return _( + "The access token of LinkedIn was revoked. Update the " + "account to authorize it again." + ) + return _linkedin_error_detail(error) + + @api.model + def _linkedin_raise_error(self, prefix, response): + """Raise what LinkedIn answered, telling the credentials errors apart. + + A refused authorization is the only failure that a new token can fix, + so it is raised as a ``SocialCredentialsError`` and the publication + can try again. Everything else stays a plain ``UserError``. + + :param prefix: what was being done, shown before the reason. + :param response: what LinkedIn answered. + """ + message = _( + "%(prefix)s: %(error)s", + prefix=prefix, + error=self._linkedin_error_message(response), + ) + if _linkedin_is_credentials_error(response): + raise SocialCredentialsError(message) + raise UserError(message) + + def action_update_account(self): + """Open the update wizard, proposing the Client ID when allowed. + + ``linkedin_client_id`` is restricted to ``base.group_system``, and the + context of an action is serialized to the browser, so the value is + only proposed to the users that may read the field. The others simply + type it again in the wizard, where it is editable and required. + """ + res = super().action_update_account() + if self.media_type == "linkedin" and self.env.user.has_group( + "base.group_system" + ): + ctx = dict(res.get("context", {})) + ctx.update( + { + "default_linkedin_client": self.sudo().linkedin_client_id, + } + ) + res["context"] = ctx + return res + + def _refresh_token(self): + account_sudo = self.sudo() + 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": account_sudo.refresh_access_token, + "client_id": account_sudo.linkedin_client_id, + "client_secret": account_sudo.linkedin_secret, + }, + ) + if isinstance(response, dict) and response.get("access_token"): + return response + self._linkedin_raise_error( + _("The LinkedIn token could not be refreshed"), response + ) + + def _linkedin_store_refreshed_token(self): + """Ask LinkedIn for a new token and store it on the account. + + Shared by the update wizard and by the retry of a publication that + LinkedIn refused, so both keep the same expiry dates. + """ + self.ensure_one() + token = self._refresh_token() + values = { + "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), + } + scopes = self._linkedin_normalize_scopes(token.get("scope")) + if scopes: + values["linkedin_granted_scopes"] = scopes + self.sudo().write(values) + return token + + def _refresh_credentials(self): + """Renew the access token of this LinkedIn account. + + Only the access token is renewed here: once the refresh token is gone + the account has to be authorized again from the browser, which is not + something a publication or a cron can do. + """ + res = super()._refresh_credentials() + if self.media_type != "linkedin": + return res + account_sudo = self.sudo() + if not account_sudo.refresh_access_token or ( + self.refresh_token_expires_in + and self.refresh_token_expires_in < date.today() + ): + return False + try: + self._linkedin_store_refreshed_token() + except UserError: + _logger.exception( + "Could not renew the LinkedIn token of the account %s", self.id + ) + return False + return True + + def _linkedin_prepare_url_upload_image(self): + """Register the upload of an image and return its URN and upload URL. + + :rtype: tuple + """ + image = self._request_linkedin( + method="POST", + endpoint="/images", + headers=self.media_id._get_linkedin_headers(self.sudo().access_token), + params_fields=["action"], + params_values={"action": "initializeUpload"}, + json_data={ + "initializeUploadRequest": { + "owner": self.remote_ref, + } + }, + ) + if not isinstance(image, dict): + self._linkedin_raise_error( + _("The image could not be uploaded to LinkedIn"), image + ) + value_upload_image = image.get("value", {}) + return value_upload_image.get("image", {}), value_upload_image.get("uploadUrl") + + def _linkedin_prepare_images_for_post(self, image_ids=None): + """Upload the images of a post with the Images API. + + :param image_ids: The attachments holding the images. + :return: The URNs of the uploaded images, in the same order as + ``image_ids``. Callers rely on that order to match every + attachment with the media it became on LinkedIn. + :rtype: list + """ + images_upload = [] + for image in image_ids or []: + image_urn, url_upload_image = self._linkedin_prepare_url_upload_image() + if not image_urn: + raise UserError( + _("LinkedIn did not return the reference of the " "uploaded image.") + ) + upload_image = self._request_linkedin( + method="PUT", + complete_url=url_upload_image, + headers=self.media_id._get_linkedin_headers( + self.sudo().access_token, content_type="application/octet-stream" + ), + data=base64.b64decode(image.datas), + return_json=False, + ) + if upload_image.status_code not in (200, 201): + self._linkedin_raise_error( + _("The image could not be uploaded to LinkedIn"), upload_image + ) + images_upload.append(image_urn) + return images_upload + + def _linkedin_initialize_video_upload(self, file_size_bytes): + """Register the upload of a video with the Videos API. + + :param file_size_bytes: The size of the video, which LinkedIn uses to + decide in how many parts it has to be uploaded. + :return: The URN of the video, its upload instructions and the token + that identifies the upload. + :rtype: tuple + """ + video = self._request_linkedin( + method="POST", + endpoint="/videos", + headers=self.media_id._get_linkedin_headers(self.sudo().access_token), + params_fields=["action"], + params_values={"action": "initializeUpload"}, + json_data={ + "initializeUploadRequest": { + "owner": self.remote_ref, + "fileSizeBytes": file_size_bytes, + "uploadCaptions": False, + "uploadThumbnail": False, + } + }, + ) + if not isinstance(video, dict): + self._linkedin_raise_error( + _("The video could not be uploaded to LinkedIn"), video + ) + value_upload_video = video.get("value", {}) + return ( + value_upload_video.get("video"), + value_upload_video.get("uploadInstructions", []), + value_upload_video.get("uploadToken", ""), + ) + + def _linkedin_upload_video_parts(self, video_data, upload_instructions): + """Upload every part of a video and return the ETags of the parts. + + LinkedIn puts the video back together in the order of these ETags, so + they are collected in the order of the upload instructions. + + :rtype: list + """ + part_ids = [] + for number, instruction in enumerate(upload_instructions, start=1): + first_byte = instruction.get("firstByte", 0) + last_byte = instruction.get( + "lastByte", first_byte + _VIDEO_UPLOAD_PART_SIZE_LINKEDIN - 1 + ) + upload_part = self._request_linkedin( + method="PUT", + complete_url=instruction.get("uploadUrl"), + headers=self.media_id._get_linkedin_headers( + self.sudo().access_token, content_type="application/octet-stream" + ), + data=video_data[first_byte : last_byte + 1], + return_json=False, + ) + if upload_part.status_code not in (200, 201): + self._linkedin_raise_error( + _("The video could not be uploaded to LinkedIn"), upload_part + ) + etag = upload_part.headers.get("etag", "").strip('"') + if not etag: + raise UserError( + _( + "LinkedIn did not return the identifier of the part " + "%(part)s of %(total)s, so the upload of the video " + "cannot be finalized.", + part=number, + total=len(upload_instructions), + ) + ) + part_ids.append(etag) + return part_ids + + def _linkedin_finalize_video_upload(self, video_urn, upload_token, part_ids): + """Tell LinkedIn that every part of a video has been uploaded.""" + finalize_video = self._request_linkedin( + method="POST", + endpoint="/videos", + headers=self.media_id._get_linkedin_headers(self.sudo().access_token), + params_fields=["action"], + params_values={"action": "finalizeUpload"}, + json_data={ + "finalizeUploadRequest": { + "video": video_urn, + "uploadToken": upload_token, + "uploadedPartIds": part_ids, + } + }, + return_json=False, + ) + if finalize_video.status_code not in (200, 201): + self._linkedin_raise_error( + _("The video could not be uploaded to LinkedIn"), finalize_video + ) + + def _linkedin_video_poll_settings(self): + """Return how often and how long a video status may be polled. + + :rtype: tuple + """ + get_param = self.env["ir.config_parameter"].sudo().get_param + try: + attempts = int( + get_param( + "social_media_linkedin.video_poll_attempts", + _VIDEO_POLL_ATTEMPTS_LINKEDIN, + ) + ) + delay = float( + get_param( + "social_media_linkedin.video_poll_delay", + _VIDEO_POLL_DELAY_LINKEDIN, + ) + ) + except (TypeError, ValueError): + return _VIDEO_POLL_ATTEMPTS_LINKEDIN, _VIDEO_POLL_DELAY_LINKEDIN + return attempts, delay + + def _linkedin_wait_video_available(self, video_urn): + """Wait until LinkedIn has finished processing a video. + + A video that is still being processed cannot be published, so the post + would be rejected. + + :raise UserError: When LinkedIn fails to process the video or takes + longer than the configured timeout. + """ + attempts, delay = self._linkedin_video_poll_settings() + for attempt in range(attempts): + response = self._request_linkedin( + endpoint=f"/videos/{quote(video_urn)}", + headers=self.media_id._get_linkedin_headers(self.sudo().access_token), + return_json=False, + ) + if response.status_code != 200: + self._linkedin_raise_error( + _("The status of the video could not be read"), response + ) + video = response.json() + status = video.get("status", "") + if status == "AVAILABLE": + return True + if status == "PROCESSING_FAILED": + raise UserError( + _( + "LinkedIn could not process the video: %(reason)s", + reason=video.get("processingFailureReason", status), + ) + ) + if attempt < attempts - 1: + time.sleep(delay) + raise UserError( + _( + "LinkedIn is still processing the video after %(seconds)s " + "seconds. Please try to publish the post again later.", + seconds=int(attempts * delay), + ) + ) + + def _linkedin_prepare_videos_for_post(self, video_ids): + """Upload the videos of a post with the Videos API. + + :return: The URNs of the videos, once LinkedIn has processed them. + :rtype: list + """ + videos_upload = [] + for video in video_ids or []: + video_data = base64.b64decode(video.datas) + ( + video_urn, + upload_instructions, + upload_token, + ) = self._linkedin_initialize_video_upload(len(video_data)) + part_ids = self._linkedin_upload_video_parts( + video_data, upload_instructions + ) + self._linkedin_finalize_video_upload(video_urn, upload_token, part_ids) + self._linkedin_wait_video_available(video_urn) + videos_upload.append(video_urn) + return videos_upload + + def _linkedin_create_post(self, message, image_ids=None, video_ids=None): + """Publish a post with its media through the Posts API. + + LinkedIn does not accept images and videos in the same post, so the + images are ignored when the post carries a video. + + :return: The URN of the published post, or False when the account has + no access token, together with the URNs of the images actually + attached to it. The image URNs let the caller name the local copy + of every attachment after the media it became on LinkedIn. + :rtype: tuple + """ + if not self.sudo().access_token: + return False, [] + video_urns = self._linkedin_prepare_videos_for_post(video_ids) + image_urns = ( + [] if video_urns else self._linkedin_prepare_images_for_post(image_ids) + ) + entity_post = { + "author": f"{_URN_ORGANIZATION_LINKEDIN}{self.linkedin_account_id}", + "commentary": message or "", + "visibility": "PUBLIC", + "distribution": { + "feedDistribution": "MAIN_FEED", + "targetEntities": [], + "thirdPartyDistributionChannels": [], + }, + "lifecycleState": "PUBLISHED", + "isReshareDisabledByAuthor": False, + } + if video_urns: + entity_post["content"] = {"media": {"id": video_urns[0]}} + elif len(image_urns) == 1: + entity_post["content"] = {"media": {"id": image_urns[0]}} + elif image_urns: + entity_post["content"] = { + "multiImage": {"images": [{"id": urn} for urn in image_urns]} + } + response = self._request_linkedin( + method="POST", + endpoint="/posts", + headers=self.media_id._get_linkedin_headers( + self.sudo().access_token, content_type="application/json" + ), + json_data=entity_post, + return_json=False, + ) + if response.status_code != 201: + self._linkedin_raise_error( + _("The post could not be published on LinkedIn"), response + ) + return response.headers.get("x-restli-id"), image_urns + + @api.model + def _get_linkedin_oauth_wizard(self, state): + """Return the association wizard that started this OAuth flow. + + The wizard is looked up by its state token and by its creator, so a + state token that leaks out of the authorization URL cannot be used + from another session to associate an account. + """ + if not state: + return self.env["wizard.social.account"].browse() + return ( + self.env["wizard.social.account"] + .sudo() + .search( + [ + ("csrf_state_token", "=", state), + ("create_uid", "=", self.env.user.id), + ], + limit=1, + ) + ) + + @api.model + def _consume_linkedin_oauth_wizard(self, state): + """Drop the wizard of this OAuth flow so its state cannot be replayed.""" + self._get_linkedin_oauth_wizard(state).unlink() + + def _get_access_token_linkedin( + self, authorization_code, redirect_endpoint_uri, kwargs + ): + """Exchange the authorization code for an access token. + + :return: The client id, the client secret and the token response. + :rtype: tuple + """ + wizard_social_account = self._get_linkedin_oauth_wizard(kwargs.get("state", "")) + if not wizard_social_account: + raise UserError( + _( + "Invalid OAuth state token. Please restart the " + "account association process." + ) + ) + client_id = wizard_social_account.linkedin_client + client_secret = wizard_social_account.linkedin_secret + params = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": urljoin(self.get_base_url(), redirect_endpoint_uri), + "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_linkedin_organization_logo(self, organization): + """Download the logo of an organization, in the largest size answered. + + The projection asks for every playable stream of the logo, so the + square of 400 pixels is preferred and any other is taken rather than + leaving the account without a picture. + + :param organization: the organization as the Organizations API + answered it. + :return: the logo encoded in base64, or ``None`` when LinkedIn + reported none and ``False`` when it could not be downloaded. + """ + logo_elements = ( + organization.get("logoV2", {}).get("original~", {}).get("elements", []) + ) + if not logo_elements: + return None + preferred = [ + element + for element in logo_elements + if "logo_400_400" in element.get("artifact", "") + ] or [logo_elements[0]] + identifiers = preferred[0].get("identifiers", []) + if not identifiers: + return None + media_content = self._request_linkedin( + complete_url=identifiers[0].get("identifier", False), + return_json=False, + ) + if media_content.status_code != 200: + return False + return base64.b64encode(media_content.content) + + def _get_account_linkedin(self, access_token): + """Read the organizations administered with this token. + + On an empty recordset every administered organization is returned; + otherwise only the one of this account. + + :return: Dicts with ``id``, ``localizedName``, ``vanityName`` and + ``logo``. + :rtype: list + """ + 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 = [] + errors = [] + 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 = self._get_linkedin_organization_logo( + response_organizations + ) + localized_name = response_organizations.get("name", {}).get( + "localized", {} + ) + # The organization publishes its name in several languages: + # the one of the user is preferred, and any of them is taken + # rather than leaving the account without a name. + organizations_data.append( + { + "id": response_organizations.get("id", False), + "localizedName": ( + localized_name.get(self.env.user.lang) + or localized_name.get("en_US") + or next(iter(localized_name.values()), False) + ), + "vanityName": response_organizations.get("vanityName", False), + "logo": logo_binary, + } + ) + else: + error_message = self._linkedin_error_message( + response_organizations + ) or _("Error obtaining information from the organization") + account = ( + self.env["social.account"] + .sudo() + .with_context(active_test=False) + .search( + [ + ( + "remote_ref", + "=", + f"{_URN_ORGANIZATION_LINKEDIN}{organization_id}", + ), + ], + limit=1, + ) + ) + if account: + account.message_post(body=error_message) + errors.append(error_message) + if errors and not organizations_data: + raise UserError("\n".join(errors)) + return organizations_data + + def _create_account_linkedin(self, client_id, client_secret, token): + """Create or update the accounts of the organizations of this token. + + An existing account is only reused when the current user is allowed + to associate it, and it is reactivated if it was archived. + """ + if not isinstance(token, dict): + raise UserError( + _( + "Creating account: %(error)s", + error=self._linkedin_error_message(token), + ) + ) + access_token = token.get("access_token", False) + if not access_token: + raise UserError( + _( + "Creating account: LinkedIn answered without an " + "access token. %(error)s", + error=self._linkedin_error_message(token), + ) + ) + # Every wizard of this user holding these credentials is dropped at the + # end, since the flow they started is over; the organizations are read + # from the account they point at, which is the same one for all of them + # because two accounts cannot share an application. + wizards = ( + self.env["wizard.social.account"] + .sudo() + .search( + [ + ("linkedin_client", "=", client_id), + ("linkedin_secret", "=", client_secret), + ("create_uid", "=", self.env.user.id), + ] + ) + ) + organizations = ( + wizards.account_id._get_account_linkedin(access_token) + if wizards + else self._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", 0), + ) + accounts = self.browse() + for organization in organizations: + remote_ref = f"{_URN_ORGANIZATION_LINKEDIN}{organization.get('id')}" + social_account = self._find_account_to_associate( + "linkedin", + remote_ref, + username=organization.get("vanityName", False), + ) + if social_account: + social_account._check_can_associate() + 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, + "remote_ref": remote_ref, + "last_update_account": fields.Datetime.now(), + "linkedin_granted_scopes": self._linkedin_normalize_scopes( + token.get("scope") + ), + } + if not social_account: + values_data.update( + { + "media_id": self.env.ref( + "social_media_linkedin.social_media_linkedin" + ).id, + } + ) + accounts |= self.sudo().create( + dict(values_data, user_id=self.env.user.id) + ) + else: + if not social_account.active: + values_data["active"] = True + social_account.sudo().write(values_data) + accounts |= social_account + wizards.unlink() + accounts._trigger_initial_sync() + + def _linkedin_normalize_scopes(self, raw_scopes): + """Return the scopes of a LinkedIn answer as a comma separated string. + + The token endpoint separates them with spaces and the introspection + one with commas, so both are accepted and stored the same way. They + are stored with a comma because a scope is read as a whole, and a + list of names holding underscores is unreadable when only spaces + separate them. + + :param raw_scopes: the ``scope`` value answered by LinkedIn. + :rtype: str + """ + if not raw_scopes: + return "" + return ", ".join(sorted(str(raw_scopes).replace(",", " ").split())) + + def _get_linkedin_authorization_scopes(self): + """Return the scopes to request when authorizing this account. + + What the installed modules need is always asked for: an account + associated before a module was installed was granted a token that + knows nothing of its scopes, and asking again for what it already + holds would keep it that way forever. What LinkedIn granted it is + added on top, so a scope edited by hand once a new product is + enabled on the application survives the next authorization. + + :rtype: list + """ + self.ensure_one() + scopes = self.media_id._get_linkedin_scopes() + granted = self.sudo().linkedin_granted_scopes or "" + return scopes + [ + scope + for scope in (raw.strip() for raw in granted.split(",")) + if scope and scope not in scopes + ] + + def _has_linkedin_scope(self, scope): + """Whether the token of this account was granted ``scope``. + + An account whose scopes are unknown answers ``True``: they are only + known once LinkedIn reported them, and a check meant to give a clearer + error must not block an account that works. + + :param scope: the LinkedIn scope to look for. + :rtype: bool + """ + self.ensure_one() + granted = self.sudo().linkedin_granted_scopes + if not granted: + return True + return scope in [granted_scope.strip() for granted_scope in granted.split(",")] + + def _check_linkedin_scopes(self, scopes): + """Raise a readable error when a scope needed by a feature is missing. + + LinkedIn answers a bare ``403`` when a call needs a scope the token + was not granted, which says nothing about what to do; the account has + to be associated again once the product is enabled. + + :param scopes: the scopes the feature about to be called needs. + """ + self.ensure_one() + missing = [scope for scope in scopes if not self._has_linkedin_scope(scope)] + if missing: + raise UserError( + _( + "The LinkedIn account %(account)s was not authorized for " + "%(scopes)s. Enable the matching product on the LinkedIn " + "application and associate the account again.", + account=self.name, + scopes=", ".join(missing), + ) + ) + + def _validate_linkedin_access_token(self, access_token): + """Ask LinkedIn whether the given access token is still active. + + The answer also carries the scopes the token was granted, which are + stored on the way: they are the only reliable picture of what this + account may do on LinkedIn. + + :rtype: bool + """ + account_sudo = self.sudo() + data = { + "client_id": account_sudo.linkedin_client_id, + "client_secret": account_sudo.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): + scopes = self._linkedin_normalize_scopes(response.get("scope")) + if scopes and account_sudo.linkedin_granted_scopes != scopes: + account_sudo.linkedin_granted_scopes = scopes + return True + return False + + def validate_access_token(self): + """Renew the token of this account when its dates say it is due. + + A token is treated as expired a few days ahead of its date: the check + runs before every publication and on the schedule of the updates cron, + and a token renewed at the last moment is one that a post planned for + the weekend would not find. + """ + 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) + today = datetime.now(tz=timezone).date() + margin = today + timedelta(days=_TOKEN_MARGIN_DAYS_LINKEDIN) + expired = ( + self.expire_access_token_date and self.expire_access_token_date < margin + ) or ( + self.refresh_token_expires_in and self.refresh_token_expires_in < today + ) + if expired or ctx.get("check_remote_token", False): + is_valid_token_access = self._validate_linkedin_access_token( + self.sudo().access_token + or self.env.context.get("access_token", False) + ) + if not is_valid_token_access: + account_sudo = self.sudo() + self.env["wizard.social.account"].sudo().create( + { + "account_id": self.id, + "media_id": self.media_id.id, + "linkedin_client": account_sudo.linkedin_client_id, + "linkedin_secret": account_sudo.linkedin_secret, + "update_token": True, + } + ).with_context(**ctx)._update_account() + elif not ctx.get("not_notify", False): + self._notify_valid_token_linkedin() + elif not ctx.get("not_notify", False): + self._notify_valid_token_linkedin() + + return res + + def _notify_valid_token_linkedin(self): + self._notify_user_client( + notif_type="social_form_success", + notif_message=_("The token is valid."), + media="linkedin", + account_name=self.name or "LinkedIn", + ) + + def _get_posts(self, params_fields=None, params_values=None, add_values=False): + """Fetch posts, by author without arguments or by URN with ``ids``. + + ``add_values`` merges the author query params into the given ones. + + :return: Dicts with ``id``, ``commentary``, ``content``, + ``publishedAt``, ``createdAt`` and ``author``, whatever the query + mode. + """ + self.ensure_one() + params_field_default = ["q", "author", "count"] + params_value_default = { + "q": "author", + "author": f"{_URN_ORGANIZATION_LINKEDIN}{self.linkedin_account_id}", + "count": _POSTS_PAGE_SIZE_LINKEDIN, + } + if add_values: + params_fields += params_field_default + params_values.update(params_value_default) + elif not params_fields: + params_fields = params_field_default + params_values = params_value_default + is_batch_get = "ids" in params_fields + response = self._request_linkedin( + endpoint="/posts", + headers=self.media_id._get_linkedin_headers( + self.sudo().access_token, + x_restli_method="BATCH_GET" if is_batch_get else "FINDER", + ), + params_fields=params_fields, + params_values=params_values, + return_json=False, + ) + if response.status_code != 200: + raise UserError( + _( + "The publications could not be read from LinkedIn: %(error)s", + error=self._linkedin_error_message(response), + ) + ) + response_posts = response.json() + if is_batch_get: + elements = response_posts.get("results", {}).values() + else: + elements = response_posts.get("elements", []) + return [ + { + "id": post["id"], + "commentary": post.get("commentary", ""), + "content": post.get("content", {}), + "publishedAt": post.get("publishedAt", 0), + "createdAt": post.get("createdAt", 0), + "author": post.get("author", ""), + } + for post in elements + if post.get("id") + ] + + def _get_all_posts(self): + """Read the whole feed of the account, page by page. + + A single page is not the feed: LinkedIn documents that a page may + carry fewer posts than asked while more are left, so the end is only + reached on an empty page. Knowing whether the end was reached is what + makes it safe to conclude that a post missing from the answer was + deleted, hence the flag returned along the posts. + + :return: the posts of the account and whether the feed was read whole. + :rtype: tuple(list, bool) + """ + self.ensure_one() + posts = [] + seen_urns = set() + for page in range(_POSTS_MAX_PAGES_LINKEDIN): + page_posts = self._get_posts( + params_fields=["start"], + params_values={"start": page * _POSTS_PAGE_SIZE_LINKEDIN}, + add_values=True, + ) + if not page_posts: + return posts, True + for post in page_posts: + if post["id"] not in seen_urns: + seen_urns.add(post["id"]) + posts.append(post) + _logger.warning( + "The feed of the LinkedIn account %s is longer than %s pages, it " + "was read partially and the deleted posts are not looked for", + self.name, + _POSTS_MAX_PAGES_LINKEDIN, + ) + return posts, False + + def _get_linkedin_images_download_url(self, image_urns): + """Return the download URL of each image, resolved in a single call. + + The Posts API only answers the URN of the images of a post, so the + Images API is asked for the URL to download them from. A failure is + logged instead of raised: the images are a complement of the post and + must not stop the statistics pass. + + :param image_urns: The URNs of the images to resolve. + :return: The download URL by image URN. + :rtype: dict + """ + if not image_urns: + return {} + response = self._request_linkedin( + endpoint="/images", + headers=self.media_id._get_linkedin_headers( + self.sudo().access_token, x_restli_method="BATCH_GET" + ), + params_fields=["ids"], + params_values={"ids": list(image_urns)}, + return_json=False, + ) + if response.status_code != 200: + _logger.warning( + "Could not read the images of LinkedIn: %s", + self._linkedin_error_message(response), + ) + return {} + return { + urn: image.get("downloadUrl") + for urn, image in response.json().get("results", {}).items() + if image.get("downloadUrl") + } + + def _query_string_bytes( + self, + params_fields, + params_values, + params_values_char_ignore=None, + format_quote=None, + ): + """Return what the given query parameters weigh once encoded. + + Tells how much room is left for the URNs of a statistics call, the + only part of the query string that can be split. + + :rtype: int + """ + return sum( + len( + social_url_encode( + param_field, + params_values, + params_values_char_ignore, + format_quote, + ).encode() + ) + # The "&" joining this parameter to the next one. + + 1 + for param_field in params_fields + ) + + def _filter_urns(self, posts, urn_prefix): + """Return the URNs of the posts of one kind, in the order given. + + :param posts: the posts as the Posts API answered them. + :param urn_prefix: ``urn:li:share:`` or ``urn:li:ugcPost:``. + :rtype: list + """ + return [ + post["id"] + for post in posts + if post.get("id") and post["id"].startswith(urn_prefix) + ] + + def _parse_share_statistics(self, payload, urn_key): + """Read the answer of ``organizationalEntityShareStatistics``. + + The shares and the UGC posts are asked for with a parameter of their + own but answer the very same block, only the key naming the entity + changes. An entity with no activity at all is left out of the answer, + and is therefore left out of the result: its figures are all zero. + + :param payload: the parsed answer of LinkedIn. + :param urn_key: ``share`` or ``ugcPost``. + :return: Statistics tuple by post URN. + :rtype: dict + """ + statistics = {} + for element in payload.get("elements", []): + urn = element.get(urn_key) + if not urn: + continue + totals = element.get("totalShareStatistics", {}) + statistics[urn] = ( + totals.get("clickCount", 0), + totals.get("likeCount", 0), + totals.get("commentCount", 0), + totals.get("shareCount", 0), + totals.get("engagement", 0), + totals.get("impressionCount", 0), + ) + return statistics + + def _get_entity_share_statistics( + self, + urns, + param_field, + urn_key, + error_label, + params_fields=None, + params_values=None, + params_values_char_ignore=None, + format_quote=None, + ): + """Read ``organizationalEntityShareStatistics`` for the given URNs. + + LinkedIn takes every URN in the query string and documents that + endpoint as not paginated, so the URNs are split into as many calls + as the 4 KB limit of the query string needs. + + :param urns: the URNs to read the statistics of. + :param param_field: ``shares`` or ``ugcPosts``. + :param urn_key: the key naming the entity in the answer. + :param error_label: what to call the call in the error message. + :return: Statistics tuple by post URN. + :rtype: dict + """ + data = {} + params_fields = list(params_fields or []) + params_values = dict(params_values or {}) + fixed_bytes = self._query_string_bytes( + params_fields, params_values, params_values_char_ignore, format_quote + ) + for batch in _batch_urns_by_url_size(urns, param_field, fixed_bytes): + response = self._request_linkedin( + endpoint="/organizationalEntityShareStatistics", + headers=self.media_id._get_linkedin_headers( + access_token=self.sudo().access_token, x_restli_method="FINDER" + ), + params_fields=params_fields + [param_field], + params_values={**params_values, param_field: [",".join(batch)]}, + params_values_char_ignore=params_values_char_ignore, + linkedin_v2=True, + return_json=False, + format_quote=format_quote, + ) + if response.status_code != 200: + raise UserError( + _( + "%(label)s: %(error)s", + label=error_label, + error=self._linkedin_error_message(response), + ) + ) + data.update(self._parse_share_statistics(response.json(), urn_key)) + return data + + def _get_linkedin_recent_statistics(self): + """Return the figures LinkedIn reports for the whole page, day by day. + + Asked with ``timeIntervals`` and no URN, the endpoint answers one + bucket per day covering the whole organization instead of one entry per + publication. That is one call whatever the number of publications, + which is what makes it worth watching to know whether anything moved. + + The daily buckets are watched instead of the lifetime totals the same + endpoint answers without ``timeIntervals``, because those totals lag + behind. Verified against the real account: a reaction given an hour and + a half earlier was already counted in the buckets while the lifetime + figures still ignored it, and the sum of the twelve monthly buckets + matched the lifetime answer to the unit on every figure except that one + reaction. The lifetime totals are therefore useless as a signal of + freshness, whatever else they are good for. + + Those figures are not the sum of what Odoo imported and are not meant + to be compared against it: LinkedIn counts publications older than the + import and stops counting the ones that were deleted. They are only + ever compared against the previous reading of themselves. + + The range ends tomorrow, not now: LinkedIn takes the end of a time + interval as exclusive and normalizes it to the day, so asking up to the + current moment leaves the bucket of today out of the answer, which is + the one bucket the check is really after. + + :return: the watched figures by day, empty when LinkedIn reports none. + :rtype: dict + """ + self.ensure_one() + end_date = fields.Datetime.now() + timedelta(days=1) + start_date = end_date - timedelta(days=_UPDATE_CHECK_DAYS_LINKEDIN) + start_time, end_time = self._get_default_filter_date( + start_date, end_date, time_date=True + ) + buckets = self._get_linkedin_chart_statistics(start_time, end_time, "DAY", "D") + return { + period: tuple(figures[index] for index in _UPDATE_CHECK_FIGURES_LINKEDIN) + for period, figures in buckets.items() + } + + def _get_share_statistics( + self, + posts=None, + params_fields=None, + params_values=None, + params_values_char_ignore=None, + format_quote=None, + ): + """Read the statistics of the share posts among the given ones. + + :return: Statistics tuple by share URN. + :rtype: dict + """ + if not posts: + return {} + return self._get_entity_share_statistics( + self._filter_urns(posts, _URN_SHARE_LINKEDIN), + "shares", + "share", + _("The statistics of the shared publications could not be read"), + params_fields=params_fields, + params_values=params_values, + params_values_char_ignore=params_values_char_ignore, + format_quote=format_quote, + ) + + def _get_ugc_share_statistics( + self, + posts=None, + params_fields=None, + params_values=None, + params_values_char_ignore=None, + format_quote=None, + ): + """Read the statistics of the UGC posts among the given ones. + + Same endpoint as the shares, asked with the ``ugcPosts`` parameter. + It is what brings the clicks, the shares, the engagement and the + impressions of a UGC post: ``socialActions`` only knows its likes and + its comments. + + :return: Statistics tuple by UGC post URN. + :rtype: dict + """ + if not posts: + return {} + return self._get_entity_share_statistics( + self._filter_urns(posts, _URN_UGC_POST_LINKEDIN), + "ugcPosts", + "ugcPost", + _("The statistics of the publications could not be read"), + params_fields=params_fields, + params_values=params_values, + params_values_char_ignore=params_values_char_ignore, + format_quote=format_quote, + ) + + def _get_ugc_posts_statistics( + self, + posts=None, + params_fields=None, + params_values=None, + params_values_char_ignore=None, + format_quote=None, + ): + """Read the likes and the comments of the UGC posts of the feed. + + LinkedIn documents ``socialActions`` as the up-to-date source of + those two counts, the ones the feed shows, which is why they are read + apart from the rest of the figures. It is asked for in as many calls + as the 4 KB limit of the query string needs. + + :return: ``(likes, comments)`` by UGC post URN. + :rtype: dict + """ + data = {} + if not posts: + return data + params_fields = list(params_fields or []) + params_values = dict(params_values or {}) + fixed_bytes = self._query_string_bytes( + params_fields, params_values, params_values_char_ignore, format_quote + ) + urns = self._filter_urns(posts, _URN_UGC_POST_LINKEDIN) + for batch in _batch_urns_by_url_size(urns, "ids", fixed_bytes): + response = self._request_linkedin( + endpoint="/socialActions", + headers=self.media_id._get_linkedin_headers( + access_token=self.sudo().access_token + ), + params_fields=params_fields + ["ids"], + params_values={**params_values, "ids": [",".join(batch)]}, + params_values_char_ignore=params_values_char_ignore, + return_json=False, + linkedin_v2=True, + format_quote=format_quote, + ) + if response.status_code != 200: + raise UserError( + _( + "The likes and the comments of the publications could not be " + "read: %(error)s", + error=self._linkedin_error_message(response), + ) + ) + data.update( + { + urn_id: ( + post_reaction.get("likesSummary", {}).get("totalLikes", 0), + post_reaction.get("commentsSummary", {}).get( + "aggregatedTotalComments", 0 + ), + ) + for urn_id, post_reaction in response.json() + .get("results", {}) + .items() + } + ) + return data + + def _get_entity_statistics( + self, + posts=None, + params_fields=None, + params_values=None, + params_values_char_ignore=None, + format_quote=None, + ): + """Merge the statistics of the share posts and of the UGC posts. + + Three calls are needed. ``organizationalEntityShareStatistics`` + answers the whole block of figures, but the shares and the UGC posts + are asked for with a parameter of their own. ``socialActions`` is + read on top of it because LinkedIn documents its likes and its + comments as the up-to-date ones, the ones the feed shows. + + :return: Statistics tuple by post URN. + :rtype: dict + """ + if self.media_type != "linkedin": + return {} + if not posts: + 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}", + } + entity_params = { + "params_fields": list(params_fields), + "params_values": dict(params_values), + "params_values_char_ignore": params_values_char_ignore, + "format_quote": format_quote, + } + data = self._get_share_statistics(posts=posts, **entity_params) + data.update(self._get_ugc_share_statistics(posts=posts, **entity_params)) + # ``socialActions`` takes neither the criteria of the share finder + # nor the organization it is about. + social_actions = self._get_ugc_posts_statistics( + posts=posts, + params_fields=[ + param_field + for param_field in params_fields + if param_field not in _FINDER_PARAMS_LINKEDIN + ], + params_values={ + key: value + for key, value in params_values.items() + if key not in _FINDER_PARAMS_LINKEDIN + }, + params_values_char_ignore=params_values_char_ignore, + format_quote=format_quote, + ) + for urn, (likes, comments) in social_actions.items(): + clicks, __, __, shares, engagement, impressions = data.get( + urn, (0, 0, 0, 0, 0, 0) + ) + data[urn] = (clicks, likes, comments, shares, engagement, impressions) + return data + + def _notify_statistics_failure(self, error): + """Tell the user why the statistics of this account were not read. + + :param error: whatever was raised while refreshing the account. + """ + self.ensure_one() + _logger.exception( + "Error updating the posts statistics of the LinkedIn account %s", + self.name, + ) + self._notify_user_client( + notif_type="social_kanban_danger", + notif_message=self._linkedin_error_message(error), + media="linkedin", + account_name=self.name, + ) + + @contextmanager + def _statistics_guard(self): + """Isolate the refresh of one account in its own savepoint. + + Reading a feed writes on the way: the publications gone from + LinkedIn are marked as deleted before their statistics are even + asked for. An error on what comes after must not leave those writes + behind, nor stop the accounts still to refresh, so each account is + rolled back on its own and the reason is told to the user. + + ``psycopg2.OperationalError`` with a concurrency pgcode is raised + again on purpose, so the retry mechanism of Odoo still sees it. + """ + self.ensure_one() + try: + with self.env.cr.savepoint(): + yield + except psycopg2.OperationalError as error: + if error.pgcode in PG_CONCURRENCY_ERRORS_TO_RETRY: + raise + self._notify_statistics_failure(error) + except Exception as error: # noqa: BLE001 - the API may fail in any way + self._notify_statistics_failure(error) + + def _update_posts_statistics(self, post_id, domain): + statistics = super()._update_posts_statistics(post_id, domain) + 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 + for account in account_ids: + with account._statistics_guard(): + account._refresh_linkedin_posts(post_id=post_id) + return self._get_account_statistics(statistics=statistics) + + def _full_resync(self): + """Read the whole feed of the LinkedIn accounts and reconcile it. + + This is what the ordinary refresh used to do on every run. It is kept + apart because reading a feed of thousands of publications costs one + call per hundred, and the only thing that needs it is reconciling what + was deleted on LinkedIn: the statistics are asked for by URN and Odoo + already knows the URNs. + + :return: whatever the other connectors answer for their own accounts. + """ + linkedin = self.filtered(lambda account: account.media_type == "linkedin") + for account in linkedin: + with account._statistics_guard(): + account._refresh_linkedin_posts(full_feed=True) + return super(SocialAccount, self - linkedin)._full_resync() + + @api.model + def _linkedin_statistics_values(self, figures): + """Return the figures of one publication as its statistics fields. + + :param figures: the statistics tuple as ``_parse_share_statistics`` + builds it, empty when LinkedIn reported none for that URN. + :rtype: dict + """ + clicks, likes, comments, shares, engagement, impressions = figures or ( + 0, + 0, + 0, + 0, + 0, + 0, + ) + return { + "click_count": clicks, + "like_count": likes, + "comment_count": comments, + "share_count": shares, + "engagement": engagement, + "impression_count": impressions, + } + + def _refresh_linkedin_posts(self, post_id=None, full_feed=False): + """Import the publications of this account and their statistics. + + Three ways in, and what the feed is read for is what tells them apart: + + - ``post_id``: one publication, asked for by its URN. + - ``full_feed``: the whole feed, page by page, which is the only way to + know that a publication was deleted on LinkedIn. + - neither: the ordinary refresh. **The feed is not walked.** One page + sorted by last modification brings what is new or edited, and the + statistics are asked for by URN over that page plus the URNs Odoo + already stores for the account. A publication whose content did not + change does not need to be read again: nothing can change it without + LinkedIn moving its last modification date. + + :param post_id: the URN of the only publication to refresh. + :param full_feed: whether to walk the whole feed and reconcile it. + """ + self.ensure_one() + PostAccount = self.env["social.post.account"] + self.with_context(not_notify=True).validate_access_token() + if not self.linkedin_account_id: + return + feed_is_complete = False + if post_id: + ugc_posts = self._get_posts( + params_fields=["ids"], params_values={"ids": [post_id]} + ) + elif full_feed: + ugc_posts, feed_is_complete = self._get_all_posts() + else: + # Fresh literals: with ``add_values`` the finder parameters are + # merged into the given ones in place. + ugc_posts = self._get_posts( + params_fields=["sortBy"], + params_values={"sortBy": "LAST_MODIFIED"}, + add_values=True, + ) + discovered = [post["id"] for post in ugc_posts if post.get("id")] + # A post missing from the answer is only gone when the whole feed was + # read: on a partial answer the same search would mark live + # publications as deleted. + if feed_is_complete: + PostAccount.search( + [ + ("remote_ref", "not in", discovered), + ("remote_ref", "!=", False), + ("account_id", "=", self.id), + ("state", "!=", "deleted"), + ] + ).write({"post_account_url": False, "state": "deleted"}) + # The publications Odoo knows and the answer did not bring. Their + # figures are refreshed all the same, by URN, which is what spares + # reading the feed. ``sudo`` because the publications are scoped to + # their responsible and this also runs from a cron, and ``active_test`` + # off because an archived publication is still online on LinkedIn. + stale_lines = PostAccount + if not post_id: + stale_lines = ( + PostAccount.sudo() + .with_context(active_test=False) + .search( + [ + ("account_id", "=", self.id), + ("remote_ref", "!=", False), + ("remote_ref", "not in", discovered), + ("state", "!=", "deleted"), + ] + ) + ) + post_reactions = self._get_entity_statistics( + posts=[{"id": urn} for urn in discovered + stale_lines.mapped("remote_ref")] + ) + post_accounts = [] + post_accounts_by_urn = {} + for existing in ( + PostAccount.sudo() + .with_context(active_test=False) + .search([("remote_ref", "in", discovered)]) + ): + post_accounts_by_urn.setdefault(existing.remote_ref, existing) + for ugc_post in ugc_posts: + post_account = post_accounts_by_urn.get(ugc_post.get("id"), PostAccount) + content = ugc_post.get("content", {}) + ugc_post_urn = ugc_post.get("id") + data = { + "remote_ref": ugc_post_urn, + "post_account_url": f"{_URL_FEED_UPDATE_LINKEDIN}{ugc_post_urn}", + "message": ugc_post.get("commentary", ""), + "account_id": self.id, + "published_date": convert_to_date( + milliseconds=ugc_post.get("publishedAt") + or int(datetime.now(tz=pytz.UTC).timestamp() * 1000), + expire_date=False, + ), + "actor_urn": ugc_post.get("author", False), + "has_video": str(content.get("media", {}).get("id", "")).startswith( + _URN_VIDEO_LINKEDIN + ), + "state": "posted", + **self._linkedin_statistics_values(post_reactions.get(ugc_post_urn)), + } + attach_images = post_account._get_assets_save(content, account=self) + if post_account: + post_account._remove_assets_deleted(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)) + for line in stale_lines: + post_accounts.append( + Command.update( + line.id, + self._linkedin_statistics_values( + post_reactions.get(line.remote_ref) + ), + ) + ) + update_account_data = { + "post_account_ids": post_accounts, + "need_update": False, + } + # The totals of the account are the sum of every publication, so they + # are only recomputed when every publication was asked about: + # refreshing a single one would otherwise leave the account with the + # statistics of that one alone. + if not post_id and post_reactions: + update_account_data.update(self._filter_statistics(post_reactions)) + # The check for updates compares the daily figures of the page against + # the ones of the last import, so the import is what leaves the mark to + # compare with. Refreshing a single publication says nothing about the + # rest of the page. + if not post_id: + update_account_data[ + "linkedin_statistics_checkpoint" + ] = _linkedin_statistics_checkpoint(self._get_linkedin_recent_statistics()) + self.write(update_account_data) + + 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_granularities(self): + """Drop the week, LinkedIn does not group its statistics by week. + + ``organizationalEntityShareStatistics`` documents ``DAY`` and + ``MONTH`` as the only values of ``timeGranularityType``. + """ + granularities = super()._get_chart_granularities() + if all(account.media_type == "linkedin" for account in self): + granularities = [ + granularity for granularity in granularities if granularity != "WEEK" + ] + return granularities + + def _get_linkedin_chart_statistics(self, start_time, end_time, granularity, freq): + """Return the statistics of this account by chart period. + + The ``organizationalEntity`` finder answers one element per bucket of + ``timeGranularityType`` when it is not restricted to a list of shares, + each one carrying the ``timeRange`` it covers. The bucket of the day + in progress is not reported yet, which is exactly why the statistics + are keyed by period instead of being returned as a plain list. + + :rtype: dict + """ + response = self._request_linkedin( + endpoint="/organizationalEntityShareStatistics", + headers=self.media_id._get_linkedin_headers( + access_token=self.sudo().access_token, x_restli_method="FINDER" + ), + params_fields=["q", "organizationalEntity", "timeIntervals", "count"], + params_values={ + "q": "organizationalEntity", + "organizationalEntity": f"{_URN_ORGANIZATION_LINKEDIN}" + f"{self.linkedin_account_id}", + "timeIntervals": f"(timeRange:(start:{start_time}," + f"end:{end_time})" + f",timeGranularityType:{granularity})", + "count": 100, + }, + params_values_char_ignore={"timeIntervals": [{"all": ":"}]}, + linkedin_v2=True, + return_json=False, + format_quote=True, + ) + if response.status_code != 200: + raise UserError( + _( + "Error reading the statistics of the LinkedIn account: " + "%(error)s", + error=self._linkedin_error_message(response), + ) + ) + statistics = {} + for element in response.json().get("elements", []): + bucket_start = element.get("timeRange", {}).get("start") + if not bucket_start: + continue + share_statistics = element.get("totalShareStatistics", {}) + period = get_chart_period_key( + datetime.fromtimestamp(bucket_start / 1000), freq + ) + bucket = ( + share_statistics.get("clickCount", 0), + share_statistics.get("likeCount", 0), + share_statistics.get("commentCount", 0), + share_statistics.get("shareCount", 0), + share_statistics.get("engagement", 0), + share_statistics.get("impressionCount", 0), + ) + # Several buckets of the same period are added up instead of + # overwriting each other: LinkedIn answers one per granularity + # unit, but a period of the chart may be wider than that unit. + previous = statistics.get(period) + statistics[period] = ( + bucket + if previous is None + else tuple( + before + now for before, now in zip(previous, bucket, strict=True) + ) + ) + return statistics + + def _get_linkedin_chart_totals(self): + """Return the figures of the whole history, not of a period. + + They are read from the same finder as the series, over the months + LinkedIn keeps, so the figure of the period is really a part of this + one. Reading them per post would mix two different measures: the + finder reports the whole organization page, including what was + published before Odoo managed the account or outside of it, while a + post only knows its own lifetime totals. + + :rtype: dict + """ + start_time, end_time = self._get_default_filter_date( + None, None, time_date=True, months=_CHART_HISTORY_MONTHS_LINKEDIN + ) + statistics = self._get_linkedin_chart_statistics( + start_time, end_time, "MONTH", "ME" + ).values() + return { + "impressionCount": sum(period[5] for period in statistics), + "commentCount": sum(period[2] for period in statistics), + "reactionCount": sum(period[1] + period[3] for period in statistics), + } + + def _get_chart_account_statistics( + self, start_date=None, end_date=None, granularity="DAY", with_totals=True + ): + data = super()._get_chart_account_statistics( + start_date, end_date, granularity, with_totals=with_totals + ) + if not self: + account_ids = self.search([("media_type", "=", "linkedin")]) + elif any(account.media_type == "linkedin" for account in self): + account_ids = self + else: + return data + data_linkedin = [] + freq = "ME" if granularity == "MONTH" else "D" + for account in account_ids: + start_time, end_time = account._get_default_filter_date( + start_date, end_date, time_date=True + ) + period_start, period_end = account._get_default_filter_date( + start_date, end_date + ) + statistics = account._get_linkedin_chart_statistics( + start_time, end_time, granularity, freq + ) + if statistics: + data_linkedin += account._map_chart_statistics( + statistics, + freq=freq, + start_date=period_start, + end_date=period_end, + totals=account._get_linkedin_chart_totals() + if with_totals + else None, + ) + return list(itertools.chain(data, data_linkedin)) + + def _run_check_media_updates(self): + """Flag the LinkedIn accounts whose page moved since the last import. + + At most two calls per account, whatever the number of publications. + The daily figures of the whole page are read first, because a change + there is enough to know that something happened and spares the second + call. Only when they did not move is the feed asked for its newest + publication, which is what catches a post published outside Odoo that + nobody has interacted with yet. + + The check never imports anything: it only turns on ``need_update``, + and the import the user asks for is what turns it off and leaves the + new figures to compare with. + + The answer of the previous connectors is not looked at. Each one + checks its own accounts, so a connector that found updates does not + silence the others. + + :return: whether new updates were found, by this connector or before. + :rtype: bool + """ + update = super()._run_check_media_updates() + try: + # The accounts waiting for their initial sync are left out: this + # check writes ``need_update`` on the same row the import writes + # its statistics on, and the import is what brings in the updates + # it looks for. ``sudo`` because the cron record sets no user and + # the accounts belong to every responsible. + # + # An account already announcing updates is left out too, and costs + # no call at all: only the import clears the flag, so asking + # LinkedIn again before the user imports can only confirm what the + # dashboard already says. An account with no organization is left + # out because the feed of one cannot even be asked for, the same + # guard the import makes. + account_ids = self.sudo().search( + [ + ("media_type", "=", "linkedin"), + ("pending_initial_sync", "=", False), + ("need_update", "=", False), + ("linkedin_account_id", "!=", False), + ] + ) + for account in account_ids: + try: + # Each account in its own savepoint: the check writes, so + # a database error on one of them would otherwise abort the + # cursor and take down every account left, including the + # credentials the base already flagged. + with self.env.cr.savepoint(): + update = account._check_linkedin_updates() or update + except psycopg2.OperationalError as error: + if error.pgcode in PG_CONCURRENCY_ERRORS_TO_RETRY: + raise + _logger.exception( + "Error checking the updates of the LinkedIn account %s", + account.id, + ) + except Exception: # noqa: BLE001 - one account cannot stop the rest + _logger.exception( + "Error checking the updates of the LinkedIn account %s", + account.id, + ) + except psycopg2.OperationalError as error: + # Re-raised so Odoo still retries the cron, and before the catch-all + # below, which would otherwise swallow the raise above. + if error.pgcode not in PG_CONCURRENCY_ERRORS_TO_RETRY: + _logger.exception("Error checking the LinkedIn media updates") + else: + raise + except Exception: # noqa: BLE001 - a failed check must not stop the cron + _logger.exception("Error checking the LinkedIn media updates") + return update + + def _check_linkedin_updates(self): + """Flag this account when its page moved since the last import. + + :return: whether the account was flagged. + :rtype: bool + """ + self.ensure_one() + statistics = self._get_linkedin_recent_statistics() + previous = _linkedin_statistics_snapshot(self.linkedin_statistics_checkpoint) + if not previous: + # Nothing to compare with yet, on an account associated before this + # check knew how to. Reading the page is what gives it its mark: + # announcing updates on the very first run would announce them for + # every account at once. + self.linkedin_statistics_checkpoint = _linkedin_statistics_checkpoint( + statistics + ) + return False + if _linkedin_statistics_moved(previous, statistics): + # The mark is left alone on purpose: it belongs to the last + # import, so the notice stays up until the user actually imports + # instead of clearing itself on the next run. + self._flag_linkedin_update() + return True + # The figures did not move, but a publication posted outside Odoo that + # nobody has interacted with yet does not move them either. + post_ids = self._get_posts( + params_fields=["sortBy"], + params_values={"sortBy": "LAST_MODIFIED"}, + add_values=True, + ) + if not post_ids: + return False + # ``sudo`` because the publications are scoped to their responsible and + # this also runs from the cron, and ``active_test`` off because + # archiving a post archives its publications while they stay online on + # LinkedIn: an archived row is known, not new. + known = ( + self.env["social.post.account"] + .sudo() + .with_context(active_test=False) + .search_count( + [ + ("remote_ref", "=", post_ids[0]["id"]), + ("remote_ref", "!=", False), + ("account_id", "=", self.id), + ], + limit=1, + ) + ) + if known: + return False + self._flag_linkedin_update() + return True + + def _flag_linkedin_update(self): + """Announce on the dashboard that the account has updates to import.""" + self.ensure_one() + if self.need_update: + # Already announced. Writing again would touch the very row the + # statistics import writes on, and push the bus message a second + # time for something the dashboard already shows. + return + self.sudo().write({"need_update": True}) + self._need_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..673db08693 --- /dev/null +++ b/social_media_linkedin/models/social_media.py @@ -0,0 +1,83 @@ +# 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, _SCOPE_LINKEDIN + + +class SocialMedia(models.Model): + """Registers LinkedIn as an available social media.""" + + _inherit = "social.media" + + media_type = fields.Selection(selection_add=[("linkedin", "LinkedIn")]) + + def _get_linkedin_headers( + self, access_token=None, content_type=None, x_restli_method=None + ): + """Return the headers of a LinkedIn request. + + The common ones are always sent; the rest are added only when the + call needs them, since LinkedIn refuses a request carrying a header + that does not belong to it. + + :param access_token: the token authorizing the call, when it needs one. + :param content_type: the media type of the body, when there is one. + :param x_restli_method: the Rest.li method, for the endpoints that + answer several entities at once. + :rtype: dict + """ + 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 _get_linkedin_scopes(self): + """Return the OAuth scopes requested when authorizing an account. + + Extension point: modules adding LinkedIn features append the scopes + their API calls need. + """ + self.ensure_one() + return list(_SCOPE_LINKEDIN) + + def _get_utm_medium(self): + """LinkedIn publications are reported under the LinkedIn medium. + + The medium is resolved here instead of in a data file: the social + media of the connector already exists, so a ``noupdate`` record would + never be applied and an updatable one would reset the medium chosen by + the integrator on every module update. + """ + self.ensure_one() + if not self.utm_medium_id and self.media_type == "linkedin": + return ( + self.env.ref("utm.utm_medium_linkedin", raise_if_not_found=False) + or super()._get_utm_medium() + ) + return super()._get_utm_medium() + + def action_open_account(self): + """Open the wizard associating a LinkedIn account. + + :rtype: dict + """ + self.ensure_one() + res = super().action_open_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..7633979314 --- /dev/null +++ b/social_media_linkedin/models/social_post.py @@ -0,0 +1,125 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import itertools + +from odoo import _, api, models + +from ..social_linkedin_utils import ( + _IMAGE_MIMETYPES_LINKEDIN, + _VIDEO_MIMETYPES_LINKEDIN, +) + + +class SocialPost(models.Model): + """LinkedIn specific constraints on the posts to publish.""" + + _inherit = "social.post" + + def _default_account_ids(self): + """Preselect the LinkedIn accounts of the active company. + + The company is filtered in the domain on purpose: the record rule of + ``social.account`` matches ``company_ids``, the companies the user is + allowed to see, so without this an account of another activated + company would be preselected as well. + """ + res = super()._default_account_ids() + account_ids = self.env["social.account"].search( + [ + ("media_type", "=", "linkedin"), + "|", + ("company_id", "=", False), + ("company_id", "=", self.env.company.id), + ] + ) + if account_ids: + return list(itertools.chain(account_ids.ids, res)) + return res + + @api.depends("account_ids", "image_ids", "video_ids") + def _compute_message_info(self): + res = super()._compute_message_info() + for post in self: + if "linkedin" not in post.account_ids.mapped("media_type"): + continue + for message_info in post._linkedin_message_info(): + post.message_info = ( + f"{post.message_info}\n{message_info}" + if post.message_info + else message_info + ) + return res + + def _render_values_preview(self, media): + """Drop the images LinkedIn will not publish from its preview. + + The Posts API holds a single media entry, so a post carrying a video + is published without its images (see + :meth:`~odoo.addons.social_media_linkedin.models.social_account. + SocialAccount._linkedin_create_post`). Showing them would preview + exactly what does not reach LinkedIn. Only the LinkedIn preview is + touched: another media of the same post may well publish them. + """ + values = super()._render_values_preview(media) + if media.media_type == "linkedin" and self.video_ids: + values = dict(values, image_ids=self.env["ir.attachment"]) + return values + + def _linkedin_message_info(self): + """Return the LinkedIn warnings to show on the post. + + :rtype: list + """ + self.ensure_one() + messages = [] + if len(self.video_ids) > 1: + messages.append( + _( + "LinkedIn publishes a single video per post. Leave one " + "video or create a separate post for each of them." + ) + ) + if self.image_ids and self.video_ids: + messages.append( + _( + "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 video will be published. If you wish " + "to publish the images, please remove the video from this " + "post or create a separate post." + ) + ) + # The formats are refused when publishing; warning here saves the user + # a publication that is bound to fail. Only the medias LinkedIn really + # takes are looked at: with a video the images are not published. + wrong_videos = self.video_ids.filtered( + lambda video: (video.mimetype or "").lower() + not in _VIDEO_MIMETYPES_LINKEDIN + ) + if wrong_videos: + messages.append( + _( + "LinkedIn only publishes MP4 videos, so %(names)s will not " + "be published.", + names=", ".join(wrong_videos.mapped("name")), + ) + ) + wrong_images = ( + self.env["ir.attachment"] + if self.video_ids + else self.image_ids.filtered( + lambda image: (image.mimetype or "").lower() + not in _IMAGE_MIMETYPES_LINKEDIN + ) + ) + if wrong_images: + messages.append( + _( + "LinkedIn only publishes JPG, PNG and GIF images, so " + "%(names)s will not be published.", + names=", ".join(wrong_images.mapped("name")), + ) + ) + return messages 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..116507fbc2 --- /dev/null +++ b/social_media_linkedin/models/social_post_account.py @@ -0,0 +1,481 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import itertools +import logging +from urllib.parse import quote + +import psycopg2 + +from odoo import Command, _, fields, models +from odoo.exceptions import UserError +from odoo.service.model import PG_CONCURRENCY_ERRORS_TO_RETRY +from odoo.tools import plaintext2html + +from odoo.addons.social_media_base.social_utils import convert_date_in_time + +from ..social_linkedin_utils import ( + _IMAGE_MIMETYPES_LINKEDIN, + _URL_FEED_UPDATE_LINKEDIN, + _URN_IMAGE_LINKEDIN, + _VIDEO_MIMETYPES_LINKEDIN, +) + +_logger = logging.getLogger(__name__) + + +class SocialPostAccount(models.Model): + """Publication, comments and statistics of a post on a LinkedIn account.""" + + _inherit = "social.post.account" + + def _get_linkedin_image_urns(self, content): + """Return the image URNs carried by the content of a post. + + :param content: The ``content`` of the post answered by the Posts API. + :rtype: list + """ + media_id = str(content.get("media", {}).get("id", "")) + image_urns = [media_id] if media_id.startswith(_URN_IMAGE_LINKEDIN) else [] + image_urns += [ + str(image.get("id", "")) + for image in content.get("multiImage", {}).get("images", []) + if str(image.get("id", "")).startswith(_URN_IMAGE_LINKEDIN) + ] + return image_urns + + def _remove_assets_deleted(self, content): + """Drop the images that are no longer on the LinkedIn post. + + The publication mirrors what is online, so an image deleted on + LinkedIn has to leave the dashboard card too. Only the attachments + named after a LinkedIn URN are considered, so anything attached by + hand in Odoo is never touched. + + The relation is unlinked before deleting the attachment: the field + is declared with ``ondelete="restrict"``, so the database refuses to + delete a media that a publication still points at. + + :param content: The ``content`` of the post answered by the Posts API. + :return: The removed attachments. + """ + self.ensure_one() + remote_urns = self._get_linkedin_image_urns(content) + removed = self.image_ids.filtered( + lambda image: (image.name or "").startswith(_URN_IMAGE_LINKEDIN) + and image.name not in remote_urns + ) + if removed: + self.image_ids = [Command.unlink(image.id) for image in removed] + removed.sudo().unlink() + return removed + + def _get_assets_save(self, content, account=None): + """Download the images of a post that are not stored yet. + + :param content: The ``content`` of the post answered by the Posts API. + :param account: The account to ask LinkedIn with, needed when the post + does not exist in Odoo yet. + :return: The commands creating the missing attachments. + :rtype: list + """ + image_urns = self._get_linkedin_image_urns(content) + medias_exist = self._get_medias_account(image_urns) + image_urns = [urn for urn in image_urns if urn not in medias_exist] + if not image_urns: + return [] + account = account or self.account_id + download_urls = account._get_linkedin_images_download_url(image_urns) + commands = [ + self._map_medias_account(**{"name": urn, "url": download_urls[urn]}) + for urn in image_urns + if download_urls.get(urn) + ] + return [command for command in commands if command] + + def _check_linkedin_post_format(self): + """Check that the post matches what LinkedIn publishes. + + Unlike the checks that extension modules add through + :meth:`_check_linkedin_publishable`, this one runs for every post. + + :raise UserError: When the post cannot be published as it is. + """ + self.ensure_one() + if len(self.post_id.video_ids) > 1: + raise UserError( + _( + "LinkedIn publishes a single video per post, so this post " + "carrying %(videos)s videos cannot be published. Leave one " + "video or create a separate post for each of them.", + videos=len(self.post_id.video_ids), + ) + ) + for video in self.post_id.video_ids: + if (video.mimetype or "").lower() not in _VIDEO_MIMETYPES_LINKEDIN: + raise UserError( + _( + "LinkedIn only publishes MP4 videos, so %(name)s " + "(%(mimetype)s) cannot be published. Convert it to MP4 " + "or remove it from the post.", + name=video.name, + mimetype=video.mimetype, + ) + ) + if self.post_id.video_ids: + # A post carrying a video is published without its images, so + # their format decides nothing. Checking them would fail a post + # LinkedIn publishes just fine. + return + for image in self.post_id.image_ids: + if (image.mimetype or "").lower() not in _IMAGE_MIMETYPES_LINKEDIN: + raise UserError( + _( + "LinkedIn only publishes JPG, PNG and GIF images, so " + "%(name)s (%(mimetype)s) cannot be published. Convert " + "it or remove it from the post.", + name=image.name, + mimetype=image.mimetype, + ) + ) + + def _check_linkedin_publishable(self): + """Validate this publication before sending it to LinkedIn. + + Extension point: modules adding LinkedIn features override it to add + their own pre-publish checks. It runs inside :meth:`_publish_guard`, + so raising here fails this publication only. + """ + self.ensure_one() + self._check_linkedin_post_format() + + def _linkedin_published_values(self, post_entity): + """Return the extra values to store once the post is online. + + Extension point. It runs after ``remote_ref`` has been written and + inside :meth:`_publish_guard`, so an implementation must never let an + error escape: rolling back here would drop the reference of a post + that already exists on LinkedIn. + """ + self.ensure_one() + return {} + + 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"]) + images, videos = post_id._medias_for_publication() + for post_account in post_accounts: + with post_account._publish_guard(): + post_account._check_linkedin_publishable() + ( + post_entity, + image_urns, + ) = post_account._publish_attempt( + post_account.account_id._linkedin_create_post, + message=post_account.message, + image_ids=images, + video_ids=videos, + ) + if post_entity: + post_account.write( + { + "remote_ref": post_entity, + "post_account_url": ( + f"{_URL_FEED_UPDATE_LINKEDIN}{post_entity}" + ), + "has_video": bool(videos), + "state": "posted", + "published_date": fields.Datetime.now(), + "failed_description": False, + } + ) + post_account._linkedin_enrich_published_post( + post_entity, image_urns, images + ) + else: + post_account.write( + { + "state": "failed", + "failed_description": plaintext2html( + _( + "The account has no LinkedIn access " + "token. Update the account to " + "authorize it again." + ) + ), + } + ) + post_account.account_id._flag_credentials_expired( + _("the account has no access token") + ) + return res + + def _linkedin_enrich_published_post( + self, post_entity, image_urns=None, images=None + ): + """Complete a publication that is already online on LinkedIn. + + Downloading the images is a best-effort step, and so are the extra + values that extension modules add through + :meth:`_linkedin_published_values`. The post exists on the social + media, so a failure here must never revert its remote reference: + errors are logged and reported on the post instead of being raised. + + LinkedIn does not always expose the images of a post right after + creating it, so when the download brings nothing back the local + attachments are copied instead: the dashboard card is never empty and + the media does not have to wait for the next synchronization. + + :param post_entity: URN of the post created on LinkedIn. + :param image_urns: URNs of the images attached to the post, in the + same order as the local attachments that produced them. + :param images: The attachments that produced ``image_urns``. They are + received instead of read again so that both lists pair up by + construction and not because two reads happen to agree on the + order. Defaults to the images of the post. + """ + self.ensure_one() + values = {} + attach_images = [] + try: + ugc_post = self.account_id._get_posts( + **{ + "params_fields": ["ids"], + "params_values": {"ids": [post_entity]}, + } + ) + if ugc_post and ugc_post[0].get("content", False): + attach_images = self._get_assets_save(ugc_post[0].get("content", {})) + except psycopg2.OperationalError as error: + if error.pgcode in PG_CONCURRENCY_ERRORS_TO_RETRY: + raise + _logger.exception( + "Error retrieving the medias of the LinkedIn post %s", post_entity + ) + except Exception: # noqa: BLE001 - the post is already published + _logger.exception( + "Error retrieving the medias of the LinkedIn post %s", post_entity + ) + if not attach_images and image_urns: + if images is None: + images = self._sorted_medias(self.post_id.image_ids) + attach_images = self._copy_medias_account(images, image_urns) + if attach_images: + values["image_ids"] = attach_images + values.update(self._linkedin_published_values(post_entity)) + if values: + self.write(values) + + 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.sudo().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.remote_ref, + "reactionType": "LIKE", + }, + ) + message_like = "" + if response.status_code == 201: + like_ok = True + elif response.status_code == 409: + message_like = _("You have already reacted to this post.") + elif response.status_code == 404: + message_like = _("The post does not exist or has been deleted.") + else: + message_like = self.account_id._linkedin_error_message(response) + return {"success": like_ok, "message": message_like} + return res + + def action_like_comment(self, comment_id=None, author_urn=None): + super().action_like_comment(comment_id, author_urn) + return {"success": False, "message": ""} + + def get_comments(self): + data = super().get_comments() + comments = [] + if self.account_id.media_type == "linkedin" and self.remote_ref: + response = self.account_id._request_linkedin( + method="GET", + endpoint=f"/socialActions/{quote(self.remote_ref)}/comments", + headers=self.account_id.media_id._get_linkedin_headers( + self.account_id.sudo().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( + milliseconds=comment.get("lastModified", {}).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 = _( + "The comments could not be read from LinkedIn: %(error)s", + error=self.account_id._linkedin_error_message(response), + ) + _logger.error( + "Error getting the comments of LinkedIn post %s: %s", + self.remote_ref, + response.status_code, + ) + 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 self.account_id.media_type == "linkedin": + json_data = { + "actor": self.account_id.remote_ref, + "message": {"text": post_data.get("body", "")}, + "object": self.remote_ref, + } + response = self.account_id._request_linkedin( + method="POST", + endpoint=f"/socialActions/{quote(self.remote_ref)}/comments", + headers=self.account_id.media_id._get_linkedin_headers( + self.account_id.sudo().access_token + ), + json_data=json_data, + token=True, + return_json=False, + linkedin_v2=True, + ) + if response.status_code != 201: + return_message = _( + "The comment could not be published on LinkedIn: %(error)s", + error=self.account_id._linkedin_error_message(response), + ) + _logger.error( + "Error replying to LinkedIn post %s: %s", + self.remote_ref, + response.status_code, + ) + return { + "success": False, + "message": return_message, + } + return { + "success": True, + } + + def create_comment(self, post_data, context=None): + if self.account_id.media_type == "linkedin": + return self._create_linkedin_comment(post_data) + else: + return super().create_comment(post_data, context) + + def delete_linkedin_comment(self, comment_id, actor_urn): + if self.account_id.media_type == "linkedin": + response = self.account_id._request_linkedin( + method="DELETE", + endpoint=f"/socialActions/{quote(self.remote_ref)}/comments/{quote(comment_id)}", + headers=self.account_id.media_id._get_linkedin_headers( + self.account_id.sudo().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": _( + "An error occurred while deleting the comment or it " + "no longer exists, please try again later." + ), + } + return { + "success": True, + } + + def _check_remote_post_exists(self): + """Read the post on LinkedIn to know whether it is still online. + + Only a ``404`` is treated as a deletion. Any other answer means + LinkedIn could not be asked, not that the publication is gone: a + ``403`` is a lost page role, a ``429`` a throttled application, and + acting on them would mark a live publication as deleted. + """ + if self.account_id.media_type != "linkedin" or not self.remote_ref: + return super()._check_remote_post_exists() + try: + response = self.account_id._request_linkedin( + endpoint=f"/posts/{quote(self.remote_ref)}", + headers=self.account_id.media_id._get_linkedin_headers( + self.account_id.sudo().access_token + ), + return_json=False, + ) + except Exception: # noqa: BLE001 - unreachable is not deleted + _logger.exception( + "Error checking the LinkedIn post %s, it is left untouched", + self.remote_ref, + ) + return True + if response.status_code == 404: + self._register_remote_post_gone() + return False + if response.status_code != 200: + _logger.warning( + "LinkedIn answered %(code)s while checking the post %(post)s, " + "it is left untouched: %(error)s", + { + "code": response.status_code, + "post": self.remote_ref, + "error": self.account_id._linkedin_error_message(response), + }, + ) + return True + + def _delete_post_account(self): + if self.media_id.media_type == "linkedin" and self.remote_ref: + self.account_id.with_context(not_notify=True).validate_access_token() + delete_post = self.account_id._request_linkedin( + method="DELETE", + endpoint=f"/posts/{quote(self.remote_ref)}", + headers=self.media_id._get_linkedin_headers( + self.account_id.sudo().access_token + ), + return_json=False, + ) + if delete_post.status_code != 204: + error_message = self.account_id._linkedin_error_message( + delete_post + ) or _("The post could not be deleted, please try again later.") + raise UserError( + _("Error deleting LinkedIn post: %(error)s", error=error_message) + ) + return super()._delete_post_account() 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..ac58630925 --- /dev/null +++ b/social_media_linkedin/readme/CONFIGURE.md @@ -0,0 +1,146 @@ +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. + +Only **company pages (organizations)** can be linked, never personal +profiles: a post is always published on behalf of the organization. Odoo +only reads the organizations where the member who authorizes is an +`ADMINISTRATOR` whose invitation has already been accepted (`APPROVED` +state). If your role is still pending, LinkedIn does not return the +organization and the wizard ends without creating any account. + +The connector needs no additional Python library: it uses `requests`, which +is already shipped with Odoo, so there is nothing to install on the server +besides the module itself. + +- Go to https://developer.linkedin.com/ +- Create a new Developer App with the *Create app* button. + + ![BUTTON_CREATE_APP](../static/img/readme/BUTTON_CREATE_APP.png) + +- 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: + * Sign In with LinkedIn using OpenID Connect, which grants `profile` and + `email` and is self-serve. + * Share on LinkedIn, which grants `w_member_social` and is self-serve. + * Advertising API, which is the product granting the organization scopes + the connector needs (`r_organization_social`, `w_organization_social`, + `r_organization_admin`, `rw_organization_admin`, `r_basicprofile` and + `r_1st_connections_size`). It is not self-serve: LinkedIn reviews the + request, and until it is approved the authorization cannot be completed. + + ![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. + The products, the scopes they grant and the versions of the API are described in the + [LinkedIn Marketing API documentation](https://learn.microsoft.com/en-us/linkedin/marketing/getting-started) + and in its [versioning guide](https://learn.microsoft.com/en-us/linkedin/marketing/versioning). + +- After requesting the aforementioned Products, go to the Auth tab and you will see all the enabled scopes at the bottom. + + A first authorization requests these scopes: `profile`, `email`, + `r_basicprofile`, `r_organization_social`, `r_organization_admin`, + `rw_organization_admin`, `w_member_social`, `w_organization_social` and + `r_1st_connections_size`, plus `r_ads`, `rw_ads` and `r_ads_reporting` + when the advertising module is installed. All of them have to appear + enabled in the *Auth* tab of your App; if one is missing, LinkedIn refuses + the whole authorization and the Product that grants it has to be + requested. LinkedIn does not let the member pick part of them either: the + consent screen is all or nothing. + + What LinkedIn granted is stored on the account, in the *Granted Scopes* + field of its *Configuration* tab, so a permission error can be read there + instead of in the logs. That same field is what the **next** authorization + requests, and it can be edited: + + * Enable the new Product on your LinkedIn App and wait for it to be + approved. + * Add the scopes it grants to the *Granted Scopes* field of the account. + * Open *Update account*, tick **Update keys** and authorize again on + LinkedIn. + + Editing the field on its own changes nothing, and ticking only *Update + token* is not enough either: refreshing a token keeps the scopes it was + granted, only a new authorization can ask for more. Go straight to *Update + account* after editing the field, because *Validate token* and any token + refresh rewrite it with the scopes LinkedIn reports, and the additions typed + by hand are lost. Beware that a scope + the Products of your App do not grant makes LinkedIn refuse the whole + authorization, and the account cannot be authorized again until it is + removed from the field. + + Every call is sent with the `LinkedIn-Version: 202607` and + `X-Restli-Protocol-Version: 2.0.0` headers. LinkedIn retires each version of + the API about a year after publishing it, so the module has to be updated + periodically: once the version is no longer supported, LinkedIn answers + every request with a version error. + +- 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* > Configuration > Social medias +- Click on the *Associate Account* button for the desired social media. + + ![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, LinkedIn brings you back to Odoo: if the association + succeeded you land directly on the *Social Media* Dashboard with a success notification, + and if it failed you come back to the start screen with the notification of the error. + + ![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 +- After the account is associated, an initial synchronization of its posts + and statistics is triggered automatically. +- Note that the account credentials (Client ID, Client Secret and tokens) + are masked and only visible to administrator users. +- The authorization flow is bound to the session that started it: the state + token sent to LinkedIn only works for the user who opened the wizard, and + it is discarded once the flow ends, so the same authorization cannot be + replayed. If the state does not match, the association is refused with + *Invalid OAuth state token. Please restart the account association + process.* The flow itself is the one described in the + [authorization code flow](https://learn.microsoft.com/en-us/linkedin/shared/authentication/authorization-code-flow) + of LinkedIn. +- If the LinkedIn organization is already linked to an account of another + Odoo user, the association is refused and nothing is overwritten: only the + responsible user of the account and the *Social Media / Administrator* + group can relink it. +- Every account has to use its own credentials: if the Client ID and the + Client Secret are already registered on another account, the association is + refused with *An account with this information already exists; please also + check archived accounts.* The check includes the archived accounts, which + keep their credentials, so check the *Archived* filter as well before trying + again. To reauthorize an existing account, use its *Update account* button + instead of the wizard. diff --git a/social_media_linkedin/readme/CONTRIBUTORS.md b/social_media_linkedin/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..4c895949c9 --- /dev/null +++ b/social_media_linkedin/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Binhex](https://www.binhex.cloud): + - Edilio Escalona Almira diff --git a/social_media_linkedin/readme/DESCRIPTION.md b/social_media_linkedin/readme/DESCRIPTION.md new file mode 100644 index 0000000000..6b96274704 --- /dev/null +++ b/social_media_linkedin/readme/DESCRIPTION.md @@ -0,0 +1,27 @@ +This module provides the necessary functionality for +basic interaction with the LinkedIn social media. + +Main features: +- Integration of the LinkedIn company pages (organizations) the user + administrates; personal profiles are not supported. +- Post creation, with images or a video: LinkedIn publishes either the images + or the video, never both. +- Post reactions (likes, comments). +- Full resync of a page, which reads the whole feed and reports the + publications deleted on LinkedIn. +- Reports and graphs with agnostic metrics. + +Liking a comment is not available: LinkedIn grants the scopes it needs +only as special permissions, see the roadmap. + + +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 interactions (clicks, likes, comments and shares) + accumulated by the posts created historically on the account. +3. The star icon: the engagement of the publications. LinkedIn reports an + engagement rate for each publication, and the account adds them up, so the + figure is a sum of rates and not a percentage of the account. + + ![STATISTICS_ACCOUNT](../static/img/readme/STATISTICS_ACCOUNT.png) diff --git a/social_media_linkedin/readme/ROADMAP.md b/social_media_linkedin/readme/ROADMAP.md new file mode 100644 index 0000000000..2545046be7 --- /dev/null +++ b/social_media_linkedin/readme/ROADMAP.md @@ -0,0 +1,198 @@ +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, see the products of the + [LinkedIn Marketing API](https://learn.microsoft.com/en-us/linkedin/marketing/getting-started) + +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 + [Comments API](https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/comments-api), + in practice it doesn't work, even in paid versions. + +Post with video or image +------------------------ + +- The Posts API only supports creating a post with either images or a video, + not both at the same time: the `content` of a post holds a single `media` + entry. When a post carries a video, its images are ignored. + + https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/posts-api?view=li-lms-2026-07&tabs=http#post-schema + +Images removed from a publication +--------------------------------- + +- The statistics synchronization mirrors the medias of the posts that are + still online: an image removed on LinkedIn leaves the publication in Odoo + too. A post deleted on LinkedIn is not touched, it keeps its medias as + history together with its *Deleted* state. +- The Posts API answers a post without media with an empty `content`, which + is indistinguishable from a response that did not carry the content this + time. The medias themselves are the ones of the + [Images API](https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/images-api). + An incomplete answer therefore drops the images of that publication. + The ones downloaded from LinkedIn come back on the next synchronization, + but a local copy attached at publishing time, kept because LinkedIn never + exposed its media, cannot be downloaded again and is lost. + +Video of a publication +---------------------- + +- The video of a published post is not attached to the publication itself: + only the *has video* flag is kept, and the dashboard shows a camera icon. + The video stays available on the post it was published from. + +Video upload +------------ + +- LinkedIn decides how a video is split: `initializeUpload` answers one + instruction per part, of 4 MiB each except the last one, and every part is + uploaded with its own request. The identifiers LinkedIn returns for the + parts are sent back to `finalizeUpload` in the same order, so the video is + put together as it was cut. A 22 MB video takes 6 parts and around 25 + seconds, upload and processing included. + + https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/videos-api + +- A post publishes a single video, so a post carrying several is refused + before uploading anything: LinkedIn would only keep the first one and the + others would be transferred and processed for nothing. +- Odoo does not check the size nor the duration of the video before uploading + it: those limits are the ones of the + [Videos API](https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/videos-api) + and LinkedIn applies them while processing. A video out of limits is + transferred whole and rejected afterwards, in the processing phase, with + *LinkedIn could not process the video*. + +Publishing options +------------------ + +- Every publication is created **public**, in the main feed, with no targeting + by country, language or industry, and letting it be shared: `visibility`, + `feedDistribution`, `targetEntities` and `thirdPartyDistributionChannels` + are fixed in the code and cannot be configured from Odoo. Scheduling is not + delegated to LinkedIn either: the Odoo scheduled action is the one that + publishes when the date arrives. + + https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/posts-api + +Rate limits +----------- + +- The module does not handle the + [throttle limits](https://learn.microsoft.com/en-us/linkedin/shared/api-guide/concepts/rate-limits) + of LinkedIn, applied per day and per application. When LinkedIn answers with + a limit error, the operation is recorded as failed like any other error and + has to be retried later by hand; only a credential rejection triggers an + automatic retry, and only once. +- Those limits are counted **per endpoint**, so what matters is not the total + number of calls but how they spread. A full synchronization spends up to 50 + calls on `/rest/posts` alone, plus one per batch of identifiers on each of + the statistics endpoints. +- The scheduled action checking for updates costs **two calls per account and + run**, whatever the number of publications, one when it does find something, + and none at all for an account already announcing updates or with no + organization linked. It reads the figures LinkedIn reports for the whole page + day by day and compares them against the ones the last import left: no + publication is read one by one to decide whether the dashboard should + announce updates. Running every two hours, that is around 24 calls a day per + account. +- The **Update** button no longer walks the feed. The statistics are asked for + by publication identifier, and those identifiers are already stored in Odoo, + so one page of the feed is enough: the one sorted by last modification, which + is what brings the publications created or edited on LinkedIn. That is one + call instead of one per hundred publications. +- **Asking LinkedIn for only the publications that changed is not possible** + with these endpoints, so the statistics still cost one call per batch of + identifiers whatever moved: + - The `/posts` finder takes `author`, `start`, `count`, `sortBy` and + `viewContext`. There is no `since` of any kind, so there is no equivalent + to what the X connector does with `since_id`. + - The statistics of specific publications cannot be restricted to a period: + *"Time-bound statistics is not supported for specific share queries"*. + - The daily figures of the page say that something moved, not which + publication moved. +- There **is** a stream of engagement events, and it is not used. It would tell + which publication changed, and with it the refresh could ask about that one + alone. It is not implemented because of three limits worth weighing first: + it only reports reactions, comments and reshares, never impressions or + clicks, which would still need the ordinary refresh; the publication it + points at comes as an `urn:li:activity:` and only the webhook payload carries + the matching `urn:li:share:`, the pull finder does not, so pulling alone + cannot attribute an event to a publication; and the webhook needs a publicly + reachable HTTPS URL validated by LinkedIn plus a subscription per member and + organization. The permission it needs, `rw_organization_admin`, is already + requested by this module. + + https://learn.microsoft.com/en-us/linkedin/marketing/community-management/organizations/organization-social-action-notifications + +Synchronization scope +--------------------- + +- **Only the full resync notices a publication deleted on LinkedIn.** The + ordinary refresh cannot: it does not read the whole feed any more, so a + publication missing from what it read says nothing. The *Full resync* button + of the account does it on demand, and a scheduled action does it weekly on + its own, so the *Deleted on LinkedIn* ribbon may take up to a week to appear. +- The full resync is also the expensive pass, one call per hundred publications + of every account, and every account runs in the same weekly run. On a + database with several large pages that run alone could exhaust the daily + quota and make the manual refresh fail with it. Spreading the accounts over + the days of the week is the way out and is not implemented. +- The synchronization walks the feed of the organization page by page + (`count=100`) up to **50 pages**, that is 5000 publications. A page is only + taken as the last one when it comes back empty, because LinkedIn documents + that a page may carry fewer publications than asked while more are left. + On an account whose feed is longer than that, the answer is incomplete and + the sweep that marks as *Deleted* what is no longer on LinkedIn is skipped + for that run: reporting nothing is preferable to marking a publication + that is still online. +- The statistics of the publications are asked in as many calls as the 4 KB + limit of the query string needs, since those endpoints take every identifier + in the URL and none of them paginates. Around a hundred publications fit in + one call, so a full feed takes several. + + https://learn.microsoft.com/en-us/linkedin/marketing/community-management/organizations/share-statistics +- The check for updates only tells that *something* on the page moved, not + which publication moved. It does not need to: the only thing it decides is + whether the dashboard shows the notice inviting to synchronize. A change + that cancels itself out between two runs, a reaction removed and another + one added, leaves the figures of the page where they were and goes + unnoticed until the next synchronization. +- The check watches the **daily** figures of the page, over a window of the + last seven days, and not the lifetime totals the same endpoint answers when + no time interval is given. Those lifetime totals lag behind: measured against + a real account, a reaction was already counted in the daily buckets while the + lifetime figures still ignored it an hour and a half later, and the sum of + the twelve monthly buckets matched the lifetime answer on every figure except + that one reaction. Two consequences worth knowing: + - Activity older than the seven-day window is not announced. It is imported + all the same when the user synchronizes, because the import reads the + publications themselves and not this window. + - Impressions reach the daily buckets later than reactions do, so a + publication that only gained views may be announced a run or two later + than one that gained a reaction. +- The engagement is not compared. It is a ratio of the clicks, reactions, + comments and shares over the impressions, so it cannot move without one of + those moving, and it is the only non-integer figure of the set. +- The statistics endpoint only answers activity of the **last 12 months**, on a + rolling window. A publication older than that stops being counted, which can + move the figures of the page on its own, with nobody having interacted with + anything. The check announces updates once when that happens and the next + import reconciles it. + +Video processing +---------------- + +- LinkedIn processes an uploaded video before it can be published, so + publishing a post with a video waits until the video is available. The wait + can be tuned with the `social_media_linkedin.video_poll_attempts` and + `social_media_linkedin.video_poll_delay` system parameters (30 attempts + every 2 seconds by default). A long video may need more than that. diff --git a/social_media_linkedin/readme/USAGE.md b/social_media_linkedin/readme/USAGE.md new file mode 100644 index 0000000000..6f80f91aa5 --- /dev/null +++ b/social_media_linkedin/readme/USAGE.md @@ -0,0 +1,209 @@ +List of posts generated from Odoo. +--------------- + +Only posts generated using Odoo are displayed. + +- Go to *Social Media* > Posts + +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* > Posts > New or Go to *Social Media* > Dashboard > Add Post +- Fill in the required fields +- When the post is created, every LinkedIn account of the active company is + selected by default in *Accounts*; remove the ones you do not want to use + before publishing. + ![CREATE_POST](../static/img/readme/CREATE_POST.png) +- Save +- Click on the *Post* button +- LinkedIn publishes either images or a video, never both: when the post + carries a video, its images are left out. The preview of the post says so + by showing the video alone, so what is previewed is what LinkedIn + receives, and a banner on the form explains it as well. +- Posts deleted directly on LinkedIn are marked as *Deleted on LinkedIn* in + Odoo, so the dashboard keeps their history. Only the *Full resync* button + and the weekly scheduled action notice them, not the ordinary statistics + synchronization, so a deletion can take up to a week to be reported. +- The images of the published post are attached to it right away. LinkedIn + is asked for them first, and when it has not exposed them yet the local + attachments of the post are copied instead, so the dashboard card never + waits for the next statistics synchronization to show its image. +- The publication mirrors what is online: an image removed from the post on + LinkedIn is dropped from the dashboard card on the next statistics + synchronization. Only the medias downloaded from LinkedIn are managed this + way, so a file attached by hand in Odoo is never removed. + +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 is proposed + to the administrator users, the only ones allowed to read it, and the Client + Secret has to be typed again: the stored secret is never sent to the browser. + Authentication is then performed again through LinkedIn, and the keys are + only written on the account once LinkedIn has accepted them, so an + authorization left halfway keeps the credentials that still work. + + ![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) + +Validate the token +--------------- + +- Go to *Social Media* > Configuration > Accounts +- Select the account and open the *Configuration* tab +- Click on the *Validate token* button. It always asks LinkedIn whether the + token is still active, because a token can be revoked there long before + the stored expiry dates: a notification confirms that it is valid, and if + it is not, the token is renewed. Outside the renewal window, the check made + before every call to LinkedIn uses the stored dates, so it costs no extra + request. + + ![VALIDATE_TOKEN](../static/img/readme/VALIDATE_TOKEN.png) + +Full resync of a page +--------------- + +- Go to *Social Media* > Configuration > Accounts, select the account and click + on the *Full resync* button of the header. +- The *Update* button of the dashboard reads one page of the feed, the one + sorted by last modification, and refreshes the figures of every publication + by its identifier. That is enough for everything except one thing: it cannot + notice that a publication was **deleted** on LinkedIn, because a publication + missing from the page it read is not necessarily gone. +- *Full resync* is what reads the whole feed and marks as *Deleted on LinkedIn* + what is no longer there. It is also the expensive pass, one call per hundred + publications against the daily quota, so it asks for confirmation. +- The scheduled action *Social: Full resync of the accounts* does the same + weekly for every account, so pressing the button is only needed to see a + deletion reported sooner. +- A publication edited on LinkedIn long enough ago to have fallen off that + first page keeps the text Odoo already had until a full resync. Its figures + are refreshed all the same. + +Archive Account Linkedin +---------------------------- + +- Go to *Social Media* > Configuration > Accounts +- Select the account +- Click on the *Archive account* button + + ![ARCHIVE_ACCOUNT](../static/img/readme/ARCHIVE_ACCOUNT.png) + +- Please note that all data associated with this account will be archived. +- If you associate the same LinkedIn account again later, the archived + account and its related data will be reactivated instead of creating a + duplicate. +- An archived account can be deleted permanently with the *Delete + permanently* button, only available to a social media administrator. The + LinkedIn publications stay online, only the Odoo history is removed. + +Uninstalling the module +---------------------------- + +Uninstalling *Social Media Linkedin* does not delete the accounts nor their +publication history: + +- The access token and the refresh token are cleared, so no credential + outlives the module. +- The LinkedIn accounts are archived, together with their posts. +- The LinkedIn specific data is lost, because Odoo drops the columns of an + uninstalled module: the application Client ID and Client Secret. +- The identifier of each account and publication on LinkedIn is kept, so + installing the module back and associating the account again reactivates + the archived history and updates it, instead of importing everything as + duplicated records. + +Statistics of the charts +--------------- + +- The charts of *Social Media* > Charts group the LinkedIn statistics by + **day** or by **month** only. The + [`organizationalEntityShareStatistics`](https://learn.microsoft.com/en-us/linkedin/marketing/community-management/organizations/share-statistics) + endpoint documents those two as the values of its `timeGranularityType`, + so no other one is offered for a LinkedIn account. +- The date range has to cover at least one whole day or one whole month, + depending on the granularity selected. LinkedIn answers an error for a + shorter range, so it is refused before calling it. + +LinkedIn tokens +--------------- + +- The access token of LinkedIn lasts two months and its refresh token a year, + as stated in the + [refresh tokens documentation](https://learn.microsoft.com/en-us/linkedin/shared/authentication/programmatic-refresh-tokens). + Within the week before the expiry date, and on every run of the scheduled + action *Social: Checking social media updates* and before publishing, Odoo + asks LinkedIn whether the token is still active (`introspectToken` endpoint) + and only renews it when LinkedIn answers that it is not. Outside of that + window the check is answered with the stored dates and costs no request. + Nothing has to be done for a post planned weeks ahead. +- If LinkedIn refuses the token anyway, it is renewed and the publication is + sent again straight away. +- Once the refresh token expires, or the authorization is revoked from + LinkedIn, no renewal is possible: the account shows the update warning and + has to be authorized again with *Update account*, which is the only step + that needs the browser. + +LinkedIn limits and validations +--------------- + +- The text of the post is **not** checked in Odoo: the `commentary` field of + the + [Posts API](https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/posts-api) + limits the body of a publication to 3.000 characters. A longer text fails + when it is sent and the line is left as *Failed* with the validation error + LinkedIn answers, so check the length before publishing. +- The number of images is not checked either: one image is published as a + single image and two or more as a + [multi-image post](https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/multiimage-post-api). + Its limits are applied by LinkedIn when it receives the post, so a number of + images it does not accept is only detected when publishing. +- LinkedIn publishes **JPG, PNG and GIF** images and **MP4** videos. Any other + format is announced on the post as soon as it is attached and refuses the + publication of that line, which is left as *Failed* naming the file. The + images are only checked when the post carries no video, because a video + leaves them out anyway. +- Every call to LinkedIn has a timeout of **10 seconds**, not configurable. If + LinkedIn or the connection take longer, the operation fails with *Error + connecting to LinkedIn* and has to be retried; a publication is left as + *Failed* and can be sent again with the *Post* button. +- An account without an access token does not publish: this is what happens + after uninstalling and installing the module back, which clears the + credentials. The line is left as *Failed* stating that the account has no + access token, and the account shows the update warning. Authorize it again + with *Update account*. +- The + [Reactions API](https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/reactions-api) + only accepts **one reaction per account**: liking a publication that was + already liked answers *You have already reacted to this post.*, and the + reaction cannot be withdrawn from Odoo. If the publication was deleted on + LinkedIn, the message is *The post does not exist or has been deleted.* +- Deleting a publication from the dashboard deletes it on LinkedIn first. If + LinkedIn does not confirm the deletion, the operation is cancelled with + *Error deleting LinkedIn post* and the record is kept in Odoo, so the two + sides never get out of sync. + +Errors reported by LinkedIn +--------------- + +- When LinkedIn rejects a request because of its content, its answer only + says that the validation failed and lists the rejected fields apart, in + the shape described in its + [error handling guide](https://learn.microsoft.com/en-us/linkedin/shared/api-guide/concepts/error-handling). + Those explanations are the ones shown in Odoo, one per line, so the + message names the field and the rule that was broken instead of the + generic summary. diff --git a/social_media_linkedin/social_linkedin_utils.py b/social_media_linkedin/social_linkedin_utils.py new file mode 100644 index 0000000000..fff9794034 --- /dev/null +++ b/social_media_linkedin/social_linkedin_utils.py @@ -0,0 +1,325 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import json + +from odoo.addons.social_media_base.social_utils import social_url_encode + +_URL_FEED_UPDATE_LINKEDIN = "https://www.linkedin.com/feed/update/" +_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_LINKEDIN = "202607" + +# Months of statistics the chart sums up as the figures of the account. The +# analytics endpoints keep about a year of history, so asking for more adds +# empty buckets and nothing else. +_CHART_HISTORY_MONTHS_LINKEDIN = 12 + +_HEADERS_LINKEDIN = { + "X-Restli-Protocol-Version": "2.0.0", + "LinkedIn-Version": _VERSION_STRING_LINKEDIN, +} + +_SCOPE_LINKEDIN = [ + "profile", + "r_organization_social", + "rw_organization_admin", + "w_member_social", + "w_organization_social", + "r_basicprofile", + "r_organization_admin", + "email", + "r_1st_connections_size", +] + +# The formats the media APIs of LinkedIn take. Images API: "JPG, GIF, and PNG +# formats". Videos API: "File format: MP4". +_IMAGE_MIMETYPES_LINKEDIN = ("image/jpeg", "image/png", "image/gif") +_VIDEO_MIMETYPES_LINKEDIN = ("video/mp4",) + +_URN_ORGANIZATION_LINKEDIN = "urn:li:organization:" +_URN_IMAGE_LINKEDIN = "urn:li:image:" +_URN_VIDEO_LINKEDIN = "urn:li:video:" +_URN_SHARE_LINKEDIN = "urn:li:share:" +_URN_UGC_POST_LINKEDIN = "urn:li:ugcPost:" + +# The criteria of the organization finder, which the endpoints answering a +# single entity by URN do not take. +_FINDER_PARAMS_LINKEDIN = ("q", "organizationalEntity") + +# Days of daily buckets the check for updates watches. The window has to be +# wider than the interval of the cron so a bucket is compared against itself at +# least once before ageing out of it, and wide enough to still catch a figure +# LinkedIn revises a few days late. +_UPDATE_CHECK_DAYS_LINKEDIN = 7 + +# Which figures of a daily bucket are watched, by their position in the tuple +# ``_get_linkedin_chart_statistics`` builds: clicks, likes, comments, shares +# and impressions. The engagement (position 4) is left out on purpose: it is a +# ratio of the other figures over the impressions, so it cannot move without +# one of them moving, and it is the only float of the set. +_UPDATE_CHECK_FIGURES_LINKEDIN = (0, 1, 2, 3, 5) + +_VIDEO_UPLOAD_PART_SIZE_LINKEDIN = 4 * 1024 * 1024 + +# The Posts API answers at most 100 posts per page, and it may answer fewer +# than asked while there are still posts left, so a page is only the last one +# when it comes back empty. The number of pages is capped to keep a feed that +# never ends from looping forever. +_POSTS_PAGE_SIZE_LINKEDIN = 100 +_POSTS_MAX_PAGES_LINKEDIN = 50 + +# LinkedIn answers 414 to a query string longer than 4 KB, and the statistics +# endpoints take the URN of every post in it. Neither of them paginates, so +# the URNs are the only thing that can be split. The margin covers the rest of +# the query string and what percent-encoding adds to the URNs. +_QUERY_STRING_MAX_BYTES_LINKEDIN = 4096 +_QUERY_STRING_MARGIN_BYTES_LINKEDIN = 512 + +# How many days before its expiry date a token is treated as expired. The +# check runs before every publication and on the schedule of the updates +# cron, and a token renewed at the last moment is one that a post planned +# for the weekend would not find. +_TOKEN_MARGIN_DAYS_LINKEDIN = 7 + +_VIDEO_POLL_ATTEMPTS_LINKEDIN = 30 +_VIDEO_POLL_DELAY_LINKEDIN = 2 + +_ERROR_DETAIL_KEYS_LINKEDIN = ("error_description", "message") +_ERROR_CODE_KEYS_LINKEDIN = ("error", "serviceErrorCode") +_ERROR_INPUT_KEYS_LINKEDIN = ("inputErrors", "conditionalInputErrors") + +_ERROR_CREDENTIALS_CODES_LINKEDIN = ( + "invalid_client", + "invalid_grant", + "invalid_request", + "invalid_token", + "REVOKED_ACCESS_TOKEN", + "EXPIRED_ACCESS_TOKEN", +) + + +def _encoded_urns_bytes(urns, param_field): + """Return what the URNs weigh in a query string, once encoded. + + Measured through the very function that builds the parameter, so the + ``List(...)`` wrapper and the percent-encoding are counted as they will + be sent. + + :param urns: the URNs of one batch. + :param param_field: the name of the query parameter carrying them. + :rtype: int + """ + encoded = social_url_encode(param_field, {param_field: [",".join(urns)]}, None) + return len(encoded.encode()) + + +def _batch_urns_by_url_size(urns, param_field, fixed_query_bytes=0): + """Split the URNs into batches whose query string LinkedIn accepts. + + The statistics endpoints take every URN in the query string and none of + them paginates, so a feed of more than about a hundred posts can only be + read in several calls. The cut is made on the encoded size rather than on + a fixed number of URNs because their length varies and the encoding does + not grow linearly with it. + + A URN too long to fit on its own is still given its own batch: LinkedIn + refusing one call is better than never making it. + + :param urns: the URNs to split, in the order they should be asked for. + :param param_field: the name of the query parameter carrying them. + :param fixed_query_bytes: what the rest of the query string weighs. + :return: the batches of URNs, empty when there is nothing to ask for. + :rtype: list + """ + budget = ( + _QUERY_STRING_MAX_BYTES_LINKEDIN + - _QUERY_STRING_MARGIN_BYTES_LINKEDIN + - fixed_query_bytes + ) + batches = [] + batch = [] + for urn in urns: + if batch and _encoded_urns_bytes(batch + [urn], param_field) > budget: + batches.append(batch) + batch = [urn] + else: + batch.append(urn) + if batch: + batches.append(batch) + return batches + + +def _linkedin_statistics_checkpoint(statistics): + """Return the stored form of the daily buckets of a page. + + Kept as sorted JSON so the value is stable whatever order LinkedIn + answered the buckets in. + + :param statistics: the buckets as ``_get_linkedin_recent_statistics`` + returns them, keyed by day. + :return: the value to store, empty when there is nothing to compare. + :rtype: str + """ + if not statistics: + return "" + return json.dumps( + {period: list(figures) for period, figures in statistics.items()}, + sort_keys=True, + ) + + +def _linkedin_statistics_snapshot(checkpoint): + """Read back a checkpoint, ignoring anything that is not one. + + The value comes from a stored column, so it is validated instead of + trusted: a checkpoint written by an older version of this check, or edited + by hand, must read as "no baseline yet" rather than compare wrongly. + + :param checkpoint: the stored value. + :return: the buckets by day, empty when there is nothing usable. + :rtype: dict + """ + if not checkpoint: + return {} + try: + snapshot = json.loads(checkpoint) + except ValueError: + return {} + if not isinstance(snapshot, dict): + return {} + return { + period: figures + for period, figures in snapshot.items() + if isinstance(figures, list) + and all(isinstance(figure, int | float) for figure in figures) + } + + +def _linkedin_statistics_moved(previous, current): + """Tell whether the daily buckets carry activity the last import missed. + + Buckets are compared day by day and never as a whole: the window slides, + so the oldest day of the previous reading is gone from this one, and + comparing the two sets would report a change every single day. + + A day missing from the previous reading only counts when it carries + activity. LinkedIn answers a bucket of zeros for a day with nothing on it, + and the day in progress starts as one of those: announcing it would mean + announcing updates every midnight. + + A day gone from this reading is ignored: it aged out of the window, which + is not activity. + + :param previous: the buckets of the last import. + :param current: the buckets read now. + :return: whether anything moved. + :rtype: bool + """ + if not previous: + return False + for period, figures in current.items(): + stored = previous.get(period) + if stored is None: + if any(figures): + return True + elif list(stored) != list(figures): + return True + return False + + +def _linkedin_error_inputs(payload): + """Return the per-field explanations of a LinkedIn validation error. + + :param payload: the parsed answer of LinkedIn. + :return: one description per rejected field, in the order LinkedIn + reported them. + :rtype: list + """ + details = payload.get("errorDetails") + if not isinstance(details, dict): + return [] + descriptions = [] + for key in _ERROR_INPUT_KEYS_LINKEDIN: + for input_error in details.get(key) or []: + description = isinstance(input_error, dict) and input_error.get( + "description" + ) + if description: + descriptions.append(str(description)) + return descriptions + + +def _linkedin_error_payload(error): + """Return the answer of LinkedIn as a dict, when it is one. + + :param error: a ``requests.Response``, a parsed body, or anything that + was raised while talking to LinkedIn. + :return: the parsed body, or an empty dict when it is not JSON. + :rtype: dict + """ + if isinstance(error, dict): + return error + body = getattr(error, "text", None) + if body is None: + body = str(error) + try: + payload = json.loads(body) + except (TypeError, ValueError): + return {} + return payload if isinstance(payload, dict) else {} + + +def _linkedin_error_detail(error): + """Return what LinkedIn answered, in a readable form. + + A validation error is reported field by field, since its ``message`` + only says that something failed. The answer of LinkedIn is never + dropped: when it carries no known key, or when it is not JSON at all, + its body is returned as it is. + + :rtype: str + """ + payload = _linkedin_error_payload(error) + inputs = _linkedin_error_inputs(payload) + if inputs: + return "\n".join(inputs) + for key in _ERROR_DETAIL_KEYS_LINKEDIN: + detail = payload.get(key) + if detail: + return str(detail) + body = getattr(error, "text", None) + if body is None: + body = str(error) + return str(body).strip() + + +def _linkedin_error_code(error): + """Return the code that names an error of LinkedIn, if it has one. + + :rtype: str + """ + payload = _linkedin_error_payload(error) + for key in _ERROR_CODE_KEYS_LINKEDIN: + code = payload.get(key) + if code: + return str(code) + return "" + + +def _linkedin_is_credentials_error(error): + """Return whether LinkedIn refused the authorization of the account. + + Those are the only errors worth retrying after renewing the token, so + they are told apart from everything else LinkedIn may refuse. The HTTP + status is checked too: an expired token is answered with a 401 that does + not always carry one of the known codes. + + :param error: a ``requests.Response`` or the parsed answer of LinkedIn. + :rtype: bool + """ + if getattr(error, "status_code", None) == 401: + return True + return _linkedin_error_code(error) in _ERROR_CREDENTIALS_CODES_LINKEDIN 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..91bb25c924 --- /dev/null +++ b/social_media_linkedin/static/description/index.html @@ -0,0 +1,1113 @@ + + + + + +Social Media Linkedin + + + +
+

Social Media Linkedin

+ + +

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

+

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

+

Main features:

+
    +
  • Integration of the LinkedIn company pages (organizations) the user +administrates; personal profiles are not supported.
  • +
  • Post creation, with images or a video: LinkedIn publishes either the +images or the video, never both.
  • +
  • Post reactions (likes, comments).
  • +
  • Full resync of a page, which reads the whole feed and reports the +publications deleted on LinkedIn.
  • +
  • Reports and graphs with agnostic metrics.
  • +
+

Liking a comment is not available: LinkedIn grants the scopes it needs +only as special permissions, see the roadmap.

+
+

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 interactions (clicks, likes, comments and +shares) accumulated by the posts created historically on the account.

    +
  4. +
  5. The star icon: the engagement of the publications. LinkedIn reports +an engagement rate for each publication, and the account adds them +up, so the figure is a sum of rates and not a percentage of the +account.

    +

    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.

+

Only company pages (organizations) can be linked, never personal +profiles: a post is always published on behalf of the organization. Odoo +only reads the organizations where the member who authorizes is an +ADMINISTRATOR whose invitation has already been accepted +(APPROVED state). If your role is still pending, LinkedIn does not +return the organization and the wizard ends without creating any +account.

+

The connector needs no additional Python library: it uses requests, +which is already shipped with Odoo, so there is nothing to install on +the server besides the module itself.

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

    +
  • +
  • Create a new Developer App with the Create app button.

    +

    BUTTON_CREATE_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:

    +
      +
    • Sign In with LinkedIn using OpenID Connect, which grants profile +and email and is self-serve.
    • +
    • Share on LinkedIn, which grants w_member_social and is +self-serve.
    • +
    • Advertising API, which is the product granting the organization +scopes the connector needs (r_organization_social, +w_organization_social, r_organization_admin, +rw_organization_admin, r_basicprofile and +r_1st_connections_size). It is not self-serve: LinkedIn reviews +the request, and until it is approved the authorization cannot be +completed.
    • +
    +

    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. The products, the scopes they grant and the versions +of the API are described in the LinkedIn Marketing API +documentation +and in its versioning +guide.

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

    +

    A first authorization requests these scopes: profile, email, +r_basicprofile, r_organization_social, +r_organization_admin, rw_organization_admin, +w_member_social, w_organization_social and +r_1st_connections_size, plus r_ads, rw_ads and +r_ads_reporting when the advertising module is installed. All of +them have to appear enabled in the Auth tab of your App; if one is +missing, LinkedIn refuses the whole authorization and the Product that +grants it has to be requested. LinkedIn does not let the member pick +part of them either: the consent screen is all or nothing.

    +

    What LinkedIn granted is stored on the account, in the Granted +Scopes field of its Configuration tab, so a permission error can be +read there instead of in the logs. That same field is what the +next authorization requests, and it can be edited:

    +
      +
    • Enable the new Product on your LinkedIn App and wait for it to be +approved.
    • +
    • Add the scopes it grants to the Granted Scopes field of the +account.
    • +
    • Open Update account, tick Update keys and authorize again on +LinkedIn.
    • +
    +

    Editing the field on its own changes nothing, and ticking only Update +token is not enough either: refreshing a token keeps the scopes it +was granted, only a new authorization can ask for more. Go straight to +Update account after editing the field, because Validate token and +any token refresh rewrite it with the scopes LinkedIn reports, and the +additions typed by hand are lost. Beware that a scope the Products of +your App do not grant makes LinkedIn refuse the whole authorization, +and the account cannot be authorized again until it is removed from +the field.

    +

    Every call is sent with the LinkedIn-Version: 202607 and +X-Restli-Protocol-Version: 2.0.0 headers. LinkedIn retires each +version of the API about a year after publishing it, so the module has +to be updated periodically: once the version is no longer supported, +LinkedIn answers every request with a version error.

    +
  • +
  • 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 > Configuration > Social medias

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

    +

    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, LinkedIn +brings you back to Odoo: if the association succeeded you land +directly on the Social Media Dashboard with a success notification, +and if it failed you come back to the start screen with the +notification of the error.

    +

    AUTHORIZE_ACCOUNT

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

    +
  • +
  • After the account is associated, an initial synchronization of its +posts and statistics is triggered automatically.

    +
  • +
  • Note that the account credentials (Client ID, Client Secret and +tokens) are masked and only visible to administrator users.

    +
  • +
  • The authorization flow is bound to the session that started it: the +state token sent to LinkedIn only works for the user who opened the +wizard, and it is discarded once the flow ends, so the same +authorization cannot be replayed. If the state does not match, the +association is refused with Invalid OAuth state token. Please restart +the account association process. The flow itself is the one described +in the authorization code +flow +of LinkedIn.

    +
  • +
  • If the LinkedIn organization is already linked to an account of +another Odoo user, the association is refused and nothing is +overwritten: only the responsible user of the account and the Social +Media / Administrator group can relink it.

    +
  • +
  • Every account has to use its own credentials: if the Client ID and the +Client Secret are already registered on another account, the +association is refused with An account with this information already +exists; please also check archived accounts. The check includes the +archived accounts, which keep their credentials, so check the +Archived filter as well before trying again. To reauthorize an +existing account, use its Update account button instead of the +wizard.

    +
  • +
+
+

Usage

+
+
+
+

List of posts generated from Odoo.

+

Only posts generated using Odoo are displayed.

+
    +
  • Go to Social Media > Posts
  • +
+
+
+

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 > Posts > New or Go to Social Media > Dashboard +> Add Post
  • +
  • Fill in the required fields
  • +
  • When the post is created, every LinkedIn account of the active company +is selected by default in Accounts; remove the ones you do not want +to use before publishing. CREATE_POST
  • +
  • Save
  • +
  • Click on the Post button
  • +
  • LinkedIn publishes either images or a video, never both: when the post +carries a video, its images are left out. The preview of the post says +so by showing the video alone, so what is previewed is what LinkedIn +receives, and a banner on the form explains it as well.
  • +
  • Posts deleted directly on LinkedIn are marked as Deleted on LinkedIn +in Odoo, so the dashboard keeps their history. Only the Full resync +button and the weekly scheduled action notice them, not the ordinary +statistics synchronization, so a deletion can take up to a week to be +reported.
  • +
  • The images of the published post are attached to it right away. +LinkedIn is asked for them first, and when it has not exposed them yet +the local attachments of the post are copied instead, so the dashboard +card never waits for the next statistics synchronization to show its +image.
  • +
  • The publication mirrors what is online: an image removed from the post +on LinkedIn is dropped from the dashboard card on the next statistics +synchronization. Only the medias downloaded from LinkedIn are managed +this way, so a file attached by hand in Odoo is never removed.
  • +
+
+
+

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 is +proposed to the administrator users, the only ones allowed to read it, +and the Client Secret has to be typed again: the stored secret is +never sent to the browser. Authentication is then performed again +through LinkedIn, and the keys are only written on the account once +LinkedIn has accepted them, so an authorization left halfway keeps the +credentials that still work.

    +

    UPDATE_KEYS

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

    +

    UPDATE_TOKEN

    +
  • +
+
+
+

Validate the token

+
    +
  • Go to Social Media > Configuration > Accounts

    +
  • +
  • Select the account and open the Configuration tab

    +
  • +
  • Click on the Validate token button. It always asks LinkedIn whether +the token is still active, because a token can be revoked there long +before the stored expiry dates: a notification confirms that it is +valid, and if it is not, the token is renewed. Outside the renewal +window, the check made before every call to LinkedIn uses the stored +dates, so it costs no extra request.

    +

    VALIDATE_TOKEN

    +
  • +
+
+
+

Full resync of a page

+
    +
  • Go to Social Media > Configuration > Accounts, select the account +and click on the Full resync button of the header.
  • +
  • The Update button of the dashboard reads one page of the feed, the +one sorted by last modification, and refreshes the figures of every +publication by its identifier. That is enough for everything except +one thing: it cannot notice that a publication was deleted on +LinkedIn, because a publication missing from the page it read is not +necessarily gone.
  • +
  • Full resync is what reads the whole feed and marks as Deleted on +LinkedIn what is no longer there. It is also the expensive pass, one +call per hundred publications against the daily quota, so it asks for +confirmation.
  • +
  • The scheduled action Social: Full resync of the accounts does the +same weekly for every account, so pressing the button is only needed +to see a deletion reported sooner.
  • +
  • A publication edited on LinkedIn long enough ago to have fallen off +that first page keeps the text Odoo already had until a full resync. +Its figures are refreshed all the same.
  • +
+
+
+

Archive Account Linkedin

+
    +
  • Go to Social Media > Configuration > Accounts

    +
  • +
  • Select the account

    +
  • +
  • Click on the Archive account button

    +

    ARCHIVE_ACCOUNT

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

    +
  • +
  • If you associate the same LinkedIn account again later, the archived +account and its related data will be reactivated instead of creating a +duplicate.

    +
  • +
  • An archived account can be deleted permanently with the Delete +permanently button, only available to a social media administrator. +The LinkedIn publications stay online, only the Odoo history is +removed.

    +
  • +
+
+
+

Uninstalling the module

+

Uninstalling Social Media Linkedin does not delete the accounts nor +their publication history:

+
    +
  • The access token and the refresh token are cleared, so no credential +outlives the module.
  • +
  • The LinkedIn accounts are archived, together with their posts.
  • +
  • The LinkedIn specific data is lost, because Odoo drops the columns of +an uninstalled module: the application Client ID and Client Secret.
  • +
  • The identifier of each account and publication on LinkedIn is kept, so +installing the module back and associating the account again +reactivates the archived history and updates it, instead of importing +everything as duplicated records.
  • +
+
+
+

Statistics of the charts

+
    +
  • The charts of Social Media > Charts group the LinkedIn statistics by +day or by month only. The +`organizationalEntityShareStatistics <https://learn.microsoft.com/en-us/linkedin/marketing/community-management/organizations/share-statistics>`__ +endpoint documents those two as the values of its +timeGranularityType, so no other one is offered for a LinkedIn +account.
  • +
  • The date range has to cover at least one whole day or one whole month, +depending on the granularity selected. LinkedIn answers an error for a +shorter range, so it is refused before calling it.
  • +
+
+
+

LinkedIn tokens

+
    +
  • The access token of LinkedIn lasts two months and its refresh token a +year, as stated in the refresh tokens +documentation. +Within the week before the expiry date, and on every run of the +scheduled action Social: Checking social media updates and before +publishing, Odoo asks LinkedIn whether the token is still active +(introspectToken endpoint) and only renews it when LinkedIn +answers that it is not. Outside of that window the check is answered +with the stored dates and costs no request. Nothing has to be done for +a post planned weeks ahead.
  • +
  • If LinkedIn refuses the token anyway, it is renewed and the +publication is sent again straight away.
  • +
  • Once the refresh token expires, or the authorization is revoked from +LinkedIn, no renewal is possible: the account shows the update warning +and has to be authorized again with Update account, which is the +only step that needs the browser.
  • +
+
+
+

LinkedIn limits and validations

+
    +
  • The text of the post is not checked in Odoo: the commentary +field of the Posts +API +limits the body of a publication to 3.000 characters. A longer text +fails when it is sent and the line is left as Failed with the +validation error LinkedIn answers, so check the length before +publishing.
  • +
  • The number of images is not checked either: one image is published as +a single image and two or more as a multi-image +post. +Its limits are applied by LinkedIn when it receives the post, so a +number of images it does not accept is only detected when publishing.
  • +
  • LinkedIn publishes JPG, PNG and GIF images and MP4 videos. Any +other format is announced on the post as soon as it is attached and +refuses the publication of that line, which is left as Failed naming +the file. The images are only checked when the post carries no video, +because a video leaves them out anyway.
  • +
  • Every call to LinkedIn has a timeout of 10 seconds, not +configurable. If LinkedIn or the connection take longer, the operation +fails with Error connecting to LinkedIn and has to be retried; a +publication is left as Failed and can be sent again with the Post +button.
  • +
  • An account without an access token does not publish: this is what +happens after uninstalling and installing the module back, which +clears the credentials. The line is left as Failed stating that the +account has no access token, and the account shows the update warning. +Authorize it again with Update account.
  • +
  • The Reactions +API +only accepts one reaction per account: liking a publication that +was already liked answers You have already reacted to this post., +and the reaction cannot be withdrawn from Odoo. If the publication was +deleted on LinkedIn, the message is The post does not exist or has +been deleted.
  • +
  • Deleting a publication from the dashboard deletes it on LinkedIn +first. If LinkedIn does not confirm the deletion, the operation is +cancelled with Error deleting LinkedIn post and the record is kept +in Odoo, so the two sides never get out of sync.
  • +
+
+
+

Errors reported by LinkedIn

+
    +
  • When LinkedIn rejects a request because of its content, its answer +only says that the validation failed and lists the rejected fields +apart, in the shape described in its error handling +guide. +Those explanations are the ones shown in Odoo, one per line, so the +message names the field and the rule that was broken instead of the +generic summary.
  • +
+
+

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, see the products of the LinkedIn Marketing +API
    • +
    +
  • +
+
+
+

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 Comments +API, +in practice it doesn’t work, even in paid versions.
  • +
+
+
+

Post with video or image

+ +
+
+

Images removed from a publication

+
    +
  • The statistics synchronization mirrors the medias of the posts that +are still online: an image removed on LinkedIn leaves the publication +in Odoo too. A post deleted on LinkedIn is not touched, it keeps its +medias as history together with its Deleted state.
  • +
  • The Posts API answers a post without media with an empty content, +which is indistinguishable from a response that did not carry the +content this time. The medias themselves are the ones of the Images +API. +An incomplete answer therefore drops the images of that publication. +The ones downloaded from LinkedIn come back on the next +synchronization, but a local copy attached at publishing time, kept +because LinkedIn never exposed its media, cannot be downloaded again +and is lost.
  • +
+
+
+

Video of a publication

+
    +
  • The video of a published post is not attached to the publication +itself: only the has video flag is kept, and the dashboard shows a +camera icon. The video stays available on the post it was published +from.
  • +
+
+
+

Video upload

+
    +
  • LinkedIn decides how a video is split: initializeUpload answers +one instruction per part, of 4 MiB each except the last one, and every +part is uploaded with its own request. The identifiers LinkedIn +returns for the parts are sent back to finalizeUpload in the same +order, so the video is put together as it was cut. A 22 MB video takes +6 parts and around 25 seconds, upload and processing included.

    +

    https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/videos-api

    +
  • +
  • A post publishes a single video, so a post carrying several is refused +before uploading anything: LinkedIn would only keep the first one and +the others would be transferred and processed for nothing.

    +
  • +
  • Odoo does not check the size nor the duration of the video before +uploading it: those limits are the ones of the Videos +API +and LinkedIn applies them while processing. A video out of limits is +transferred whole and rejected afterwards, in the processing phase, +with LinkedIn could not process the video.

    +
  • +
+
+
+

Publishing options

+
    +
  • Every publication is created public, in the main feed, with no +targeting by country, language or industry, and letting it be shared: +visibility, feedDistribution, targetEntities and +thirdPartyDistributionChannels are fixed in the code and cannot be +configured from Odoo. Scheduling is not delegated to LinkedIn either: +the Odoo scheduled action is the one that publishes when the date +arrives.

    +

    https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/posts-api

    +
  • +
+
+
+

Rate limits

+
    +
  • The module does not handle the throttle +limits +of LinkedIn, applied per day and per application. When LinkedIn +answers with a limit error, the operation is recorded as failed like +any other error and has to be retried later by hand; only a credential +rejection triggers an automatic retry, and only once.

    +
  • +
  • Those limits are counted per endpoint, so what matters is not the +total number of calls but how they spread. A full synchronization +spends up to 50 calls on /rest/posts alone, plus one per batch of +identifiers on each of the statistics endpoints.

    +
  • +
  • The scheduled action checking for updates costs two calls per +account and run, whatever the number of publications, one when it +does find something, and none at all for an account already announcing +updates or with no organization linked. It reads the figures LinkedIn +reports for the whole page day by day and compares them against the +ones the last import left: no publication is read one by one to decide +whether the dashboard should announce updates. Running every two +hours, that is around 24 calls a day per account.

    +
  • +
  • The Update button no longer walks the feed. The statistics are +asked for by publication identifier, and those identifiers are already +stored in Odoo, so one page of the feed is enough: the one sorted by +last modification, which is what brings the publications created or +edited on LinkedIn. That is one call instead of one per hundred +publications.

    +
  • +
  • Asking LinkedIn for only the publications that changed is not +possible with these endpoints, so the statistics still cost one call +per batch of identifiers whatever moved:

    +
      +
    • The /posts finder takes author, start, count, +sortBy and viewContext. There is no since of any kind, +so there is no equivalent to what the X connector does with +since_id.
    • +
    • The statistics of specific publications cannot be restricted to a +period: “Time-bound statistics is not supported for specific share +queries”.
    • +
    • The daily figures of the page say that something moved, not which +publication moved.
    • +
    +
  • +
  • There is a stream of engagement events, and it is not used. It +would tell which publication changed, and with it the refresh could +ask about that one alone. It is not implemented because of three +limits worth weighing first: it only reports reactions, comments and +reshares, never impressions or clicks, which would still need the +ordinary refresh; the publication it points at comes as an +urn:li:activity: and only the webhook payload carries the matching +urn:li:share:, the pull finder does not, so pulling alone cannot +attribute an event to a publication; and the webhook needs a publicly +reachable HTTPS URL validated by LinkedIn plus a subscription per +member and organization. The permission it needs, +rw_organization_admin, is already requested by this module.

    +

    https://learn.microsoft.com/en-us/linkedin/marketing/community-management/organizations/organization-social-action-notifications

    +
  • +
+
+
+

Synchronization scope

+
    +
  • Only the full resync notices a publication deleted on LinkedIn. +The ordinary refresh cannot: it does not read the whole feed any more, +so a publication missing from what it read says nothing. The Full +resync button of the account does it on demand, and a scheduled +action does it weekly on its own, so the Deleted on LinkedIn ribbon +may take up to a week to appear.

    +
  • +
  • The full resync is also the expensive pass, one call per hundred +publications of every account, and every account runs in the same +weekly run. On a database with several large pages that run alone +could exhaust the daily quota and make the manual refresh fail with +it. Spreading the accounts over the days of the week is the way out +and is not implemented.

    +
  • +
  • The synchronization walks the feed of the organization page by page +(count=100) up to 50 pages, that is 5000 publications. A page +is only taken as the last one when it comes back empty, because +LinkedIn documents that a page may carry fewer publications than asked +while more are left. On an account whose feed is longer than that, the +answer is incomplete and the sweep that marks as Deleted what is no +longer on LinkedIn is skipped for that run: reporting nothing is +preferable to marking a publication that is still online.

    +
  • +
  • The statistics of the publications are asked in as many calls as the 4 +KB limit of the query string needs, since those endpoints take every +identifier in the URL and none of them paginates. Around a hundred +publications fit in one call, so a full feed takes several.

    +

    https://learn.microsoft.com/en-us/linkedin/marketing/community-management/organizations/share-statistics

    +
  • +
  • The check for updates only tells that something on the page moved, +not which publication moved. It does not need to: the only thing it +decides is whether the dashboard shows the notice inviting to +synchronize. A change that cancels itself out between two runs, a +reaction removed and another one added, leaves the figures of the page +where they were and goes unnoticed until the next synchronization.

    +
  • +
  • The check watches the daily figures of the page, over a window of +the last seven days, and not the lifetime totals the same endpoint +answers when no time interval is given. Those lifetime totals lag +behind: measured against a real account, a reaction was already +counted in the daily buckets while the lifetime figures still ignored +it an hour and a half later, and the sum of the twelve monthly buckets +matched the lifetime answer on every figure except that one reaction. +Two consequences worth knowing:

    +
      +
    • Activity older than the seven-day window is not announced. It is +imported all the same when the user synchronizes, because the import +reads the publications themselves and not this window.
    • +
    • Impressions reach the daily buckets later than reactions do, so a +publication that only gained views may be announced a run or two +later than one that gained a reaction.
    • +
    +
  • +
  • The engagement is not compared. It is a ratio of the clicks, +reactions, comments and shares over the impressions, so it cannot move +without one of those moving, and it is the only non-integer figure of +the set.

    +
  • +
  • The statistics endpoint only answers activity of the last 12 +months, on a rolling window. A publication older than that stops +being counted, which can move the figures of the page on its own, with +nobody having interacted with anything. The check announces updates +once when that happens and the next import reconciles it.

    +
  • +
+
+
+

Video processing

+
    +
  • LinkedIn processes an uploaded video before it can be published, so +publishing a post with a video waits until the video is available. The +wait can be tuned with the +social_media_linkedin.video_poll_attempts and +social_media_linkedin.video_poll_delay system parameters (30 +attempts every 2 seconds by default). A long video may need more than +that.
  • +
+
+

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.

+

Current maintainer:

+

edescalona

+

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/readme/ARCHIVE_ACCOUNT.png b/social_media_linkedin/static/img/readme/ARCHIVE_ACCOUNT.png new file mode 100644 index 0000000000..deb038171d 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/VALIDATE_TOKEN.png b/social_media_linkedin/static/img/readme/VALIDATE_TOKEN.png new file mode 100644 index 0000000000..4731c7197a Binary files /dev/null and b/social_media_linkedin/static/img/readme/VALIDATE_TOKEN.png differ diff --git a/social_media_linkedin/static/img/readme/WIZARD_ASSOCIATE_ACCOUNT.png b/social_media_linkedin/static/img/readme/WIZARD_ASSOCIATE_ACCOUNT.png new file mode 100644 index 0000000000..7c3f525b22 Binary files /dev/null and b/social_media_linkedin/static/img/readme/WIZARD_ASSOCIATE_ACCOUNT.png differ diff --git a/social_media_linkedin/static/src/components/social_comment/social_comment.esm.js b/social_media_linkedin/static/src/components/social_comment/social_comment.esm.js new file mode 100644 index 0000000000..e388477f25 --- /dev/null +++ b/social_media_linkedin/static/src/components/social_comment/social_comment.esm.js @@ -0,0 +1,28 @@ +/** @odoo-module **/ + +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, { + /** @override */ + setup() { + super.setup(); + this.socialLinkedinService = useService("social_linkedin_service"); + }, + /** @override */ + async _onDeleteComment() { + return this.socialLinkedinService.deleteLinkedinComment( + this.props.post.id.raw_value, + this.props.socialComment.id, + this.props.socialComment.actor + ); + }, + + /** @override */ + 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..f211f6ab78 --- /dev/null +++ b/social_media_linkedin/static/src/components/social_comment_dialog/social_comment_dialog.esm.js @@ -0,0 +1,14 @@ +/** @odoo-module **/ + +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..94d4fb0914 --- /dev/null +++ b/social_media_linkedin/static/src/js/services/social_linkedin_service.esm.js @@ -0,0 +1,24 @@ +/** @odoo-module */ + +import {registry} from "@web/core/registry"; + +export const socialLinkedinService = { + dependencies: ["orm"], + + async start(env, {orm}) { + return { + async deleteLinkedinComment(postAccountId, commentId, actorUrn) { + if (!postAccountId) { + return []; + } + return await orm.call( + "social.post.account", + "delete_linkedin_comment", + [postAccountId, commentId, actorUrn] + ); + }, + }; + }, +}; + +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..28f9ce7a5d --- /dev/null +++ b/social_media_linkedin/static/src/js/views/kanban/social_kanban_model.esm.js @@ -0,0 +1,23 @@ +/** @odoo-module **/ + +import {SocialKanbanModel} from "@social_media_base/js/views/kanban/social_kanban_model.esm"; +import {patch} from "@web/core/utils/patch"; + +patch(SocialKanbanModel.prototype, { + /** @override */ + async onLikePost(record) { + super.onLikePost(record); + const post_id = record.id.raw_value; + const author_urn = record.account_remote_ref.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.remote_ref.raw_value, + }); + return result; + }, +}); diff --git a/social_media_linkedin/tests/__init__.py b/social_media_linkedin/tests/__init__.py new file mode 100644 index 0000000000..7cf00cfa8e --- /dev/null +++ b/social_media_linkedin/tests/__init__.py @@ -0,0 +1,8 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import test_common_linkedin +from . import test_account_linkedin +from . import test_media_linkedin +from . import test_post_linkedin +from . import test_controller diff --git a/social_media_linkedin/tests/test_account_linkedin.py b/social_media_linkedin/tests/test_account_linkedin.py new file mode 100644 index 0000000000..1c0731efaa --- /dev/null +++ b/social_media_linkedin/tests/test_account_linkedin.py @@ -0,0 +1,3071 @@ +# 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 + +import psycopg2 +from psycopg2 import errorcodes + +from odoo import _, fields +from odoo.exceptions import UserError +from odoo.fields import Command +from odoo.tests.common import tagged +from odoo.tools import mute_logger + +from odoo.addons.social_media_base.exceptions import SocialCredentialsError +from odoo.addons.social_media_base.social_utils import ( + generate_timestamps, + social_url_encode, +) +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.social_linkedin_utils import ( + _POSTS_MAX_PAGES_LINKEDIN, + _QUERY_STRING_MARGIN_BYTES_LINKEDIN, + _QUERY_STRING_MAX_BYTES_LINKEDIN, + _TOKEN_MARGIN_DAYS_LINKEDIN, + _UPDATE_CHECK_DAYS_LINKEDIN, + _batch_urns_by_url_size, + _encoded_urns_bytes, + _linkedin_statistics_checkpoint, +) +from odoo.addons.social_media_linkedin.tests.test_common_linkedin import ( + PATCH_ACCOUNT_LINKEDIN, + PATCH_POST_ACCOUNT_LINKEDIN, + PATCH_WIZARD_ACCOUNT_LINKEDIN, + TestSocialCommonLinkedin, +) + +LOGGER_ACCOUNT_LINKEDIN = "odoo.addons.social_media_linkedin.models.social_account" + +# The figures LinkedIn reports for a whole page day by day, which the check for +# updates watches instead of reading the statistics of every publication. Every +# bucket is (clicks, likes, comments, shares, impressions), the engagement left +# out. Taken from a real account. +RECENT_STATISTICS_LINKEDIN = { + "2026-08-06": (0, 0, 0, 0, 17), + "2026-08-07": (5, 0, 0, 0, 29), + "2026-08-08": (0, 1, 0, 0, 0), +} + + +@tagged("post_install", "-at_install") +class TestSocialLinkedin(TestSocialCommonLinkedin): + """Users are created here, so every module has to be in the registry.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.video_mock = type("Video", (), {"datas": cls.video_data})() + cls.image_mock = type("Image", (), {"datas": cls.image_base64})() + cls.media_image = "urn:li:image:{}" + cls.media_video = "urn:li:video:{}" + + def test_linkedin_prepare_url_upload_image(self): + fake_response = { + "value": { + "image": self.media_image.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._linkedin_prepare_url_upload_image() + + self.assertEqual(image, self.media_image.format("C123456")) + self.assertEqual(upload_url, "https://fake.upload.url/image") + + mock_request.assert_called_once() + self.assertEqual( + mock_request.call_args.kwargs["params_values"], + {"action": "initializeUpload"}, + ) + + def test_linkedin_prepare_url_upload_image_error(self): + """An answer that is not the registered upload stops the publication.""" + mock_response = self.generate_magic_mock(**{"status_code": 403}) + with self.get_patch_exceptions_linkedin(mock_response): + with self.assertRaises(UserError) as context: + self.SocialAccountLinkedin._linkedin_prepare_url_upload_image() + self.assertIn("could not be uploaded to LinkedIn", str(context.exception)) + + def test_linkedin_prepare_images_for_post_success(self): + """Every image is registered and uploaded, and its URN is kept.""" + patch_upload_url = patch.object( + type(self.SocialAccountLinkedin), + "_linkedin_prepare_url_upload_image", + return_value=( + self.media_image.format("XYZ"), + "https://fake.upload/image", + ), + ) + mock_response = self.generate_magic_mock(**{"status_code": 201}) + with patch_upload_url, self.get_patch_exceptions_linkedin( + mock_response + ) as mock_request: + images = self.SocialAccountLinkedin._linkedin_prepare_images_for_post( + image_ids=[self.image_mock] + ) + self.assertEqual(images, [self.media_image.format("XYZ")]) + self.assertEqual(mock_request.call_args.kwargs["method"], "PUT") + self.assertEqual( + mock_request.call_args.kwargs["complete_url"], "https://fake.upload/image" + ) + self.assertEqual(mock_request.call_args.kwargs["data"], b"testimage") + + def test_linkedin_prepare_images_for_post_upload_error(self): + patch_upload_url = patch.object( + type(self.SocialAccountLinkedin), + "_linkedin_prepare_url_upload_image", + return_value=( + self.media_image.format("XYZ"), + "https://fake.upload/image", + ), + ) + mock_response = self.generate_magic_mock(**{"status_code": 400}) + with patch_upload_url, self.get_patch_exceptions_linkedin(mock_response): + with self.assertRaises(UserError) as context: + self.SocialAccountLinkedin._linkedin_prepare_images_for_post( + image_ids=[self.image_mock] + ) + self.assertIn("could not be uploaded to LinkedIn", str(context.exception)) + + def test_initialize_video_upload(self): + fake_response = { + "value": { + "video": self.media_video.format("VID123"), + "uploadInstructions": [ + { + "uploadUrl": "https://fake.upload/video/1", + "firstByte": 0, + "lastByte": 3, + } + ], + "uploadToken": "token-123", + } + } + with self.get_patch_exceptions_linkedin(fake_response) as mock_request: + ( + video, + instructions, + token, + ) = self.SocialAccountLinkedin._linkedin_initialize_video_upload(4) + self.assertEqual(video, self.media_video.format("VID123")) + self.assertEqual(len(instructions), 1) + self.assertEqual(token, "token-123") + json_data = mock_request.call_args.kwargs["json_data"] + self.assertEqual(json_data["initializeUploadRequest"]["fileSizeBytes"], 4) + + def test_initialize_video_upload_error(self): + mock_response = self.generate_magic_mock(**{"status_code": 400}) + with self.get_patch_exceptions_linkedin(mock_response): + with self.assertRaises(UserError) as context: + self.SocialAccountLinkedin._linkedin_initialize_video_upload(4) + self.assertIn("could not be uploaded to LinkedIn", str(context.exception)) + + def test_upload_video_parts_keeps_the_order_of_the_etags(self): + """Each part carries its own slice and its ETag keeps its position.""" + instructions = [ + {"uploadUrl": "https://fake.upload/video/1", "firstByte": 0, "lastByte": 3}, + {"uploadUrl": "https://fake.upload/video/2", "firstByte": 4, "lastByte": 8}, + ] + first_part = self.generate_magic_mock(**{"status_code": 201}) + first_part.headers = {"etag": '"etag-1"'} + second_part = self.generate_magic_mock(**{"status_code": 201}) + second_part.headers = {"etag": '"etag-2"'} + patch_request = self.get_patch_exceptions_linkedin( + side_effect=[first_part, second_part] + ) + with patch_request as mock_request: + part_ids = self.SocialAccountLinkedin._linkedin_upload_video_parts( + b"123456789", instructions + ) + self.assertEqual(part_ids, ["etag-1", "etag-2"]) + self.assertEqual(mock_request.call_args_list[0].kwargs["data"], b"1234") + self.assertEqual(mock_request.call_args_list[1].kwargs["data"], b"56789") + + def test_upload_video_parts_without_etag(self): + """A part without ETag is caught here, not inside finalizeUpload.""" + instructions = [ + {"uploadUrl": "https://fake.upload/video/1", "firstByte": 0, "lastByte": 3}, + {"uploadUrl": "https://fake.upload/video/2", "firstByte": 4, "lastByte": 8}, + ] + first_part = self.generate_magic_mock(**{"status_code": 201}) + first_part.headers = {"etag": '"etag-1"'} + second_part = self.generate_magic_mock(**{"status_code": 201}) + second_part.headers = {} + patch_request = self.get_patch_exceptions_linkedin( + side_effect=[first_part, second_part] + ) + with patch_request, self.assertRaises(UserError) as context: + self.SocialAccountLinkedin._linkedin_upload_video_parts( + b"123456789", instructions + ) + self.assertIn("did not return the identifier", str(context.exception)) + self.assertIn("part 2 of 2", str(context.exception)) + + def test_upload_video_parts_multipart_slices(self): + """Every part carries exactly the bytes of its own instruction.""" + part_size = 4 * 1024 * 1024 + video_data = b"\x00" * (part_size * 2 + 512) + instructions = [ + { + "uploadUrl": f"https://fake.upload/video/{number}", + "firstByte": number * part_size, + "lastByte": min(len(video_data), (number + 1) * part_size) - 1, + } + for number in range(3) + ] + parts = [] + for number in range(3): + part = self.generate_magic_mock(**{"status_code": 201}) + part.headers = {"etag": f'"etag-{number}"'} + parts.append(part) + with self.get_patch_exceptions_linkedin(side_effect=parts) as mock_request: + part_ids = self.SocialAccountLinkedin._linkedin_upload_video_parts( + video_data, instructions + ) + self.assertEqual(part_ids, ["etag-0", "etag-1", "etag-2"]) + self.assertEqual(len(mock_request.call_args_list), 3) + self.assertEqual( + [len(call.kwargs["data"]) for call in mock_request.call_args_list], + [part_size, part_size, 512], + ) + + def test_upload_video_parts_error(self): + instructions = [ + {"uploadUrl": "https://fake.upload/video/1", "firstByte": 0, "lastByte": 3} + ] + mock_response = self.generate_magic_mock(**{"status_code": 400}) + with self.get_patch_exceptions_linkedin(mock_response): + with self.assertRaises(UserError) as context: + self.SocialAccountLinkedin._linkedin_upload_video_parts( + b"1234", instructions + ) + self.assertIn("could not be uploaded to LinkedIn", str(context.exception)) + + def test_finalize_video_upload(self): + mock_response = self.generate_magic_mock(**{"status_code": 200}) + with self.get_patch_exceptions_linkedin(mock_response) as mock_request: + self.SocialAccountLinkedin._linkedin_finalize_video_upload( + self.media_video.format("VID123"), "token-123", ["etag-1"] + ) + json_data = mock_request.call_args.kwargs["json_data"] + self.assertEqual( + json_data["finalizeUploadRequest"]["uploadedPartIds"], ["etag-1"] + ) + + def test_finalize_video_upload_error(self): + mock_response = self.generate_magic_mock(**{"status_code": 400}) + with self.get_patch_exceptions_linkedin(mock_response): + with self.assertRaises(UserError) as context: + self.SocialAccountLinkedin._linkedin_finalize_video_upload( + self.media_video.format("VID123"), "token-123", ["etag-1"] + ) + self.assertIn("could not be uploaded to LinkedIn", str(context.exception)) + + def test_wait_video_available(self): + """The video is polled until LinkedIn finishes processing it.""" + processing = self.generate_magic_mock( + **{"status_code": 200, "json_return_value": {"status": "PROCESSING"}} + ) + available = self.generate_magic_mock( + **{"status_code": 200, "json_return_value": {"status": "AVAILABLE"}} + ) + patch_request = self.get_patch_exceptions_linkedin( + side_effect=[processing, available] + ) + with patch_request as mock_request, patch( + f"{LOGGER_ACCOUNT_LINKEDIN}.time.sleep" + ) as mock_sleep: + self.assertTrue( + self.SocialAccountLinkedin._linkedin_wait_video_available( + self.media_video.format("VID123") + ) + ) + self.assertEqual(mock_request.call_count, 2) + mock_sleep.assert_called_once() + + def test_wait_video_available_processing_failed(self): + failed = self.generate_magic_mock( + **{ + "status_code": 200, + "json_return_value": { + "status": "PROCESSING_FAILED", + "processingFailureReason": "UNSUPPORTED_FORMAT", + }, + } + ) + with self.get_patch_exceptions_linkedin(failed): + with self.assertRaises(UserError) as context: + self.SocialAccountLinkedin._linkedin_wait_video_available( + self.media_video.format("VID123") + ) + self.assertIn("UNSUPPORTED_FORMAT", str(context.exception)) + + def test_wait_video_available_timeout(self): + """A video that never becomes available stops the publication.""" + self.env["ir.config_parameter"].sudo().set_param( + "social_media_linkedin.video_poll_attempts", "2" + ) + self.env["ir.config_parameter"].sudo().set_param( + "social_media_linkedin.video_poll_delay", "0" + ) + processing = self.generate_magic_mock( + **{"status_code": 200, "json_return_value": {"status": "PROCESSING"}} + ) + with self.get_patch_exceptions_linkedin(processing) as mock_request, patch( + f"{LOGGER_ACCOUNT_LINKEDIN}.time.sleep" + ): + with self.assertRaises(UserError) as context: + self.SocialAccountLinkedin._linkedin_wait_video_available( + self.media_video.format("VID123") + ) + self.assertEqual(mock_request.call_count, 2) + self.assertIn("still processing the video", str(context.exception)) + + def test_wait_video_available_error(self): + mock_response = self.generate_magic_mock(**{"status_code": 404}) + with self.get_patch_exceptions_linkedin(mock_response): + with self.assertRaises(UserError) as context: + self.SocialAccountLinkedin._linkedin_wait_video_available( + self.media_video.format("VID123") + ) + self.assertIn("status of the video could not be read", str(context.exception)) + + def test_video_poll_settings_fall_back_on_a_wrong_parameter(self): + self.env["ir.config_parameter"].sudo().set_param( + "social_media_linkedin.video_poll_attempts", "not a number" + ) + attempts, delay = self.SocialAccountLinkedin._linkedin_video_poll_settings() + self.assertEqual(attempts, 30) + self.assertEqual(delay, 2) + + def test_linkedin_prepare_videos_for_post_success(self): + """A video is uploaded by parts and published once it is available.""" + patch_initialize = patch.object( + type(self.SocialAccountLinkedin), + "_linkedin_initialize_video_upload", + return_value=(self.media_video.format("VID123"), [{}], "token-123"), + ) + patch_parts = patch.object( + type(self.SocialAccountLinkedin), + "_linkedin_upload_video_parts", + return_value=["etag-1"], + ) + patch_finalize = patch.object( + type(self.SocialAccountLinkedin), + "_linkedin_finalize_video_upload", + return_value=None, + ) + patch_wait = patch.object( + type(self.SocialAccountLinkedin), + "_linkedin_wait_video_available", + return_value=True, + ) + with patch_initialize, patch_parts, patch_finalize as mock_finalize, patch_wait: + videos = self.SocialAccountLinkedin._linkedin_prepare_videos_for_post( + video_ids=[self.video_mock] + ) + self.assertEqual(videos, [self.media_video.format("VID123")]) + mock_finalize.assert_called_once_with( + self.media_video.format("VID123"), "token-123", ["etag-1"] + ) + + def _patch_media_uploads(self, image_urns=None, video_urns=None): + return ( + patch.object( + type(self.SocialAccountLinkedin), + "_linkedin_prepare_images_for_post", + return_value=image_urns or [], + ), + patch.object( + type(self.SocialAccountLinkedin), + "_linkedin_prepare_videos_for_post", + return_value=video_urns or [], + ), + ) + + def _linkedin_create_post_payload(self, image_urns=None, video_urns=None): + """Publish a post and return the body sent to the Posts API.""" + patch_images, patch_videos = self._patch_media_uploads(image_urns, video_urns) + mock_response = self.generate_magic_mock(**{"status_code": 201}) + mock_response.headers = {"x-restli-id": "urn:li:share:1"} + with patch_images, patch_videos, self.get_patch_exceptions_linkedin( + mock_response + ) as mock_request: + post_urn, published_urns = self.SocialAccountLinkedin._linkedin_create_post( + message="Hello", image_ids=[], video_ids=[] + ) + self.assertEqual(post_urn, "urn:li:share:1") + self.assertEqual(published_urns, image_urns or []) + self.assertEqual(mock_request.call_args.kwargs["endpoint"], "/posts") + return mock_request.call_args.kwargs["json_data"] + + def test_linkedin_create_post_text(self): + json_data = self._linkedin_create_post_payload() + self.assertEqual(json_data["commentary"], "Hello") + self.assertEqual(json_data["visibility"], "PUBLIC") + self.assertEqual(json_data["lifecycleState"], "PUBLISHED") + self.assertNotIn("content", json_data) + + def test_linkedin_create_post_single_image(self): + json_data = self._linkedin_create_post_payload( + image_urns=[self.media_image.format("1")] + ) + self.assertEqual( + json_data["content"], {"media": {"id": self.media_image.format("1")}} + ) + + def test_linkedin_create_post_multi_image(self): + json_data = self._linkedin_create_post_payload( + image_urns=[self.media_image.format("1"), self.media_image.format("2")] + ) + self.assertEqual( + json_data["content"], + { + "multiImage": { + "images": [ + {"id": self.media_image.format("1")}, + {"id": self.media_image.format("2")}, + ] + } + }, + ) + + def test_linkedin_create_post_video_wins_over_the_images(self): + """A post carrying a video does not even upload its images.""" + patch_images, patch_videos = self._patch_media_uploads( + video_urns=[self.media_video.format("1")] + ) + mock_response = self.generate_magic_mock(**{"status_code": 201}) + mock_response.headers = {"x-restli-id": "urn:li:ugcPost:1"} + with patch_images as mock_images, patch_videos, ( + self.get_patch_exceptions_linkedin(mock_response) + ) as mock_request: + ( + _post_urn, + published_urns, + ) = self.SocialAccountLinkedin._linkedin_create_post( + message="Hello", image_ids=[1], video_ids=[2] + ) + mock_images.assert_not_called() + self.assertEqual(published_urns, []) + self.assertEqual( + mock_request.call_args.kwargs["json_data"]["content"], + {"media": {"id": self.media_video.format("1")}}, + ) + + def test_linkedin_create_post_error(self): + patch_images, patch_videos = self._patch_media_uploads() + mock_response = self.generate_magic_mock(**{"status_code": 422}) + with patch_images, patch_videos, self.get_patch_exceptions_linkedin( + mock_response + ): + with self.assertRaises(UserError) as context: + self.SocialAccountLinkedin._linkedin_create_post(message="Hello") + self.assertIn("could not be published on LinkedIn", str(context.exception)) + + def test_linkedin_create_post_without_access_token(self): + self.SocialAccountLinkedin.sudo().access_token = False + self.assertEqual( + self.SocialAccountLinkedin._linkedin_create_post(message="Hello"), + (False, []), + ) + + def test_get_posts(self): + mock_response = self.generate_magic_mock( + **{ + "status_code": 200, + "json_return_value": { + "elements": [ + {"id": "123", "commentary": "Post 1"}, + {"id": "456", "commentary": "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]["commentary"], "Post 1") + self.assertEqual(posts[1]["commentary"], "Post 2") + mock_request_linkedin.assert_called_once() + call_kwargs = mock_request_linkedin.call_args.kwargs + self.assertEqual(call_kwargs["endpoint"], "/posts") + self.assertEqual(call_kwargs["params_values"]["q"], "author") + self.assertEqual( + call_kwargs["params_values"]["author"], "urn:li:organization:123456" + ) + self.assertEqual(call_kwargs["headers"]["X-RestLi-Method"], "FINDER") + + 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(UserError): + self.SocialAccountLinkedin._get_posts() + mock_request_linkedin_failed.assert_called_once() + + def test_get_posts_by_ids(self): + mock_response = self.generate_magic_mock( + **{ + "status_code": 200, + "json_return_value": { + "results": { + "urn:li:share:1": { + "id": "urn:li:share:1", + "commentary": "Post by id", + "content": {"media": {"id": "urn:li:image:1"}}, + "author": "urn:li:organization:123456", + "publishedAt": 1735689600000, + "createdAt": 1735689600000, + } + }, + "statuses": {}, + "errors": {}, + }, + } + ) + patch_request_linkedin = self.get_patch_exceptions_linkedin(mock_response) + with patch_request_linkedin as mock_request_linkedin: + posts = self.SocialAccountLinkedin._get_posts( + params_fields=["ids"], + params_values={"ids": ["urn:li:share:1"]}, + ) + self.assertEqual(len(posts), 1) + self.assertEqual(posts[0]["id"], "urn:li:share:1") + self.assertEqual(posts[0]["commentary"], "Post by id") + self.assertEqual(posts[0]["content"], {"media": {"id": "urn:li:image:1"}}) + self.assertEqual(posts[0]["author"], "urn:li:organization:123456") + mock_request_linkedin.assert_called_once() + call_kwargs = mock_request_linkedin.call_args.kwargs + self.assertEqual(call_kwargs["params_fields"], ["ids"]) + self.assertEqual(call_kwargs["params_values"], {"ids": ["urn:li:share:1"]}) + self.assertEqual(call_kwargs["headers"]["X-RestLi-Method"], "BATCH_GET") + + def test_get_posts_merges_the_author_params(self): + """``add_values`` keeps the given params and adds the author finder.""" + mock_response = self.generate_magic_mock( + **{"status_code": 200, "json_return_value": {"elements": []}} + ) + with self.get_patch_exceptions_linkedin(mock_response) as mock_request_linkedin: + self.SocialAccountLinkedin._get_posts( + params_fields=["sortBy"], + params_values={"sortBy": "LAST_MODIFIED"}, + add_values=True, + ) + call_kwargs = mock_request_linkedin.call_args.kwargs + self.assertEqual( + call_kwargs["params_fields"], ["sortBy", "q", "author", "count"] + ) + self.assertEqual(call_kwargs["params_values"]["sortBy"], "LAST_MODIFIED") + self.assertEqual(call_kwargs["params_values"]["q"], "author") + + def test_get_all_posts_walks_every_page(self): + """The feed is read until a page comes back empty.""" + pages = [ + [{"id": f"urn:li:share:{index}"} for index in range(3)], + [{"id": "urn:li:share:3"}], + [], + ] + with patch.object( + type(self.SocialAccountLinkedin), "_get_posts", side_effect=pages + ) as mock_get_posts: + posts, complete = self.SocialAccountLinkedin._get_all_posts() + self.assertTrue(complete) + self.assertEqual(len(posts), 4) + self.assertEqual(mock_get_posts.call_count, 3) + starts = [ + call.kwargs["params_values"]["start"] + for call in mock_get_posts.call_args_list + ] + self.assertEqual(starts, [0, 100, 200]) + + def test_get_all_posts_keeps_reading_after_a_short_page(self): + """A page shorter than asked is not the end of the feed.""" + pages = [[{"id": "urn:li:share:1"}], [{"id": "urn:li:share:2"}], []] + with patch.object( + type(self.SocialAccountLinkedin), "_get_posts", side_effect=pages + ): + posts, complete = self.SocialAccountLinkedin._get_all_posts() + self.assertTrue(complete) + self.assertEqual( + [post["id"] for post in posts], ["urn:li:share:1", "urn:li:share:2"] + ) + + @mute_logger(LOGGER_ACCOUNT_LINKEDIN) + def test_get_all_posts_stops_at_the_page_cap(self): + """A feed longer than the cap is reported as read partially.""" + with patch.object( + type(self.SocialAccountLinkedin), + "_get_posts", + return_value=[{"id": "urn:li:share:1"}], + ) as mock_get_posts: + posts, complete = self.SocialAccountLinkedin._get_all_posts() + self.assertFalse(complete) + self.assertEqual(mock_get_posts.call_count, _POSTS_MAX_PAGES_LINKEDIN) + self.assertEqual(len(posts), 1) + + def test_get_linkedin_images_download_url(self): + mock_response = self.generate_magic_mock( + **{ + "status_code": 200, + "json_return_value": { + "results": { + "urn:li:image:1": {"downloadUrl": "https://fake/1.png"}, + "urn:li:image:2": {}, + } + }, + } + ) + with self.get_patch_exceptions_linkedin(mock_response) as mock_request: + urls = self.SocialAccountLinkedin._get_linkedin_images_download_url( + ["urn:li:image:1", "urn:li:image:2"] + ) + self.assertEqual(urls, {"urn:li:image:1": "https://fake/1.png"}) + self.assertEqual( + mock_request.call_args.kwargs["headers"]["X-RestLi-Method"], "BATCH_GET" + ) + + @mute_logger(LOGGER_ACCOUNT_LINKEDIN) + def test_get_linkedin_images_download_url_error_is_not_fatal(self): + """A failure reading the images does not stop the statistics pass.""" + mock_response = self.generate_magic_mock(**{"status_code": 403}) + with self.get_patch_exceptions_linkedin(mock_response): + urls = self.SocialAccountLinkedin._get_linkedin_images_download_url( + ["urn:li:image:1"] + ) + self.assertEqual(urls, {}) + self.assertEqual( + self.SocialAccountLinkedin._get_linkedin_images_download_url([]), {} + ) + + def _generate_update_posts_statistics_patches( + self, ugc_posts, feed_is_complete=True + ): + """Patches of a statistics pass. + + ``feed_is_complete`` is what the feed reader answers along the posts: + the sweep of the publications gone from LinkedIn only runs when the + whole feed was read. + """ + return ( + self.generate_patch( + **{ + "model_patch": PATCH_ACCOUNT_LINKEDIN.format( + "validate_access_token" + ), + "return_value": True, + } + ), + self.generate_patch( + **{ + "model_patch": PATCH_ACCOUNT_LINKEDIN.format("_get_posts"), + "return_value": ugc_posts, + } + ), + self.generate_patch( + **{ + "model_patch": PATCH_ACCOUNT_LINKEDIN.format("_get_all_posts"), + "return_value": (ugc_posts, feed_is_complete), + } + ), + self.generate_patch( + **{ + "model_patch": PATCH_ACCOUNT_LINKEDIN.format( + "_get_entity_statistics" + ), + "side_effect": lambda *args, **kwargs: {}, + } + ), + self.generate_patch( + **{ + "model_patch": PATCH_POST_ACCOUNT_LINKEDIN.format( + "_get_assets_save" + ), + "side_effect": lambda *args, **kwargs: None, + } + ), + self.generate_patch( + **{ + "model_patch": PATCH_ACCOUNT_LINKEDIN.format( + "_get_linkedin_recent_statistics" + ), + "return_value": dict(RECENT_STATISTICS_LINKEDIN), + } + ), + ) + + def test_update_posts_statistics_single_post_preserves_urns(self): + ugc_posts = [ + { + "id": "urn:li:share:new", + "commentary": "Single post", + "content": {"media": {"id": "urn:li:video:1"}}, + "publishedAt": 1735689600000, + "author": "urn:li:organization:123456", + } + ] + ( + patch_validate, + patch_get_posts, + patch_all_posts, + patch_entity, + patch_assets, + patch_page, + ) = self._generate_update_posts_statistics_patches(ugc_posts) + self.SocialAccountLinkedin.linkedin_statistics_checkpoint = "untouched" + with patch_validate, patch_get_posts as mock_get_posts, patch_all_posts, ( + patch_entity + ), patch_assets, patch_page as mock_page: + self.SocialAccountLinkedin._update_posts_statistics( + "urn:li:share:new", None + ) + mock_get_posts.assert_called_once() + self.assertEqual( + mock_get_posts.call_args.kwargs.get("params_fields"), ["ids"] + ) + mock_page.assert_not_called() + self.assertEqual( + self.SocialAccountLinkedin.linkedin_statistics_checkpoint, + "untouched", + msg="Refreshing one publication says nothing about the page.", + ) + self.assertEqual(self.SocialPostAccountLinkedin.remote_ref, "1234567890") + post_account = self.SocialPostAccount.search( + [("remote_ref", "=", "urn:li:share:new")] + ) + self.assertTrue(post_account) + self.assertEqual(post_account.message, "Single post") + self.assertEqual(post_account.actor_urn, "urn:li:organization:123456") + self.assertTrue( + post_account.has_video, + msg="A post whose media is a video URN is marked as a video post.", + ) + + def test_update_posts_statistics_single_post_keeps_the_account_totals(self): + """Refreshing one post must not turn its figures into the totals.""" + account = self.SocialAccountLinkedin + account.write( + { + "click_count": 5, + "like_count": 17, + "comment_count": 3, + "share_count": 2, + "engagement": 0.18, + "impression_count": 346, + } + ) + ugc_posts = [ + { + "id": "urn:li:share:new", + "commentary": "Single post", + "content": {}, + "publishedAt": 1735689600000, + "author": "urn:li:organization:123456", + } + ] + ( + patch_validate, + patch_get_posts, + patch_all_posts, + __, + patch_assets, + patch_page, + ) = self._generate_update_posts_statistics_patches(ugc_posts) + patch_entity = self.generate_patch( + **{ + "model_patch": PATCH_ACCOUNT_LINKEDIN.format("_get_entity_statistics"), + "return_value": {"urn:li:share:new": (0, 1, 0, 0, 0, 0)}, + } + ) + with patch_validate, patch_get_posts, patch_all_posts, patch_entity, ( + patch_assets + ), patch_page: + account._update_posts_statistics("urn:li:share:new", None) + self.assertEqual(account.like_count, 17) + self.assertEqual(account.impression_count, 346) + self.assertEqual(account.engagement, 0.18) + self.assertEqual( + self.SocialPostAccount.search( + [("remote_ref", "=", "urn:li:share:new")] + ).like_count, + 1, + ) + + def test_update_posts_statistics_full_list_updates_the_account_totals(self): + """The whole feed was read, so its sum is the total of the account.""" + account = self.SocialAccountLinkedin + account.write({"like_count": 17, "impression_count": 346}) + ugc_posts = [ + { + "id": "urn:li:share:new", + "commentary": "Single post", + "content": {}, + "publishedAt": 1735689600000, + "author": "urn:li:organization:123456", + } + ] + ( + patch_validate, + patch_get_posts, + patch_all_posts, + __, + patch_assets, + patch_page, + ) = self._generate_update_posts_statistics_patches(ugc_posts) + patch_entity = self.generate_patch( + **{ + "model_patch": PATCH_ACCOUNT_LINKEDIN.format("_get_entity_statistics"), + "return_value": {"urn:li:share:new": (0, 1, 0, 0, 0, 12)}, + } + ) + with patch_validate, patch_get_posts, patch_all_posts, patch_entity, ( + patch_assets + ), patch_page: + account._update_posts_statistics(False, None) + self.assertEqual(account.like_count, 1) + self.assertEqual(account.impression_count, 12) + + def test_update_posts_statistics_marks_the_page(self): + """The import leaves the figures the check for updates compares with.""" + account = self.SocialAccountLinkedin + account.write( + { + "linkedin_statistics_checkpoint": "stale", + "need_update": True, + } + ) + ugc_posts = [ + { + "id": "urn:li:share:new", + "commentary": "Single post", + "content": {}, + "publishedAt": 1735689600000, + "author": "urn:li:organization:123456", + } + ] + ( + patch_validate, + patch_get_posts, + patch_all_posts, + patch_entity, + patch_assets, + patch_page, + ) = self._generate_update_posts_statistics_patches(ugc_posts) + with patch_validate, patch_get_posts, patch_all_posts, patch_entity, ( + patch_assets + ), patch_page: + account._update_posts_statistics(False, None) + self.assertEqual( + account.linkedin_statistics_checkpoint, + _linkedin_statistics_checkpoint(RECENT_STATISTICS_LINKEDIN), + ) + self.assertFalse(account.need_update) + + def test_full_resync_cleans_stale_urns(self): + """A publication gone from a feed read whole is marked as deleted.""" + remote_ref = self.SocialPostAccountLinkedin.remote_ref + ugc_posts = [ + { + "id": "urn:li:share:other", + "commentary": "Other post", + "content": {}, + "publishedAt": 1735689600000, + "author": "urn:li:organization:123456", + } + ] + ( + patch_validate, + patch_get_posts, + patch_all_posts, + patch_entity, + patch_assets, + patch_page, + ) = self._generate_update_posts_statistics_patches(ugc_posts) + with patch_validate, patch_get_posts, patch_all_posts, patch_entity, ( + patch_assets + ), patch_page: + self.SocialAccountLinkedin._full_resync() + self.assertFalse(self.SocialPostAccountLinkedin.post_account_url) + self.assertEqual(self.SocialPostAccountLinkedin.state, "deleted") + self.assertEqual(self.SocialPostAccountLinkedin.remote_ref, remote_ref) + + def test_full_resync_reads_each_account_once(self): + """The accounts left to the base must not come back as every account. + + The connector reconciles its own accounts and delegates the rest, + none here, and the ordinary refresh takes an empty recordset as + every account: without the guard of the base each account was read + twice, once whole and once more in the ordinary way. + """ + with self.generate_patch( + model_patch=PATCH_ACCOUNT_LINKEDIN.format("_refresh_linkedin_posts"), + return_value=True, + ) as mock_refresh: + self.SocialAccountLinkedin._full_resync() + mock_refresh.assert_called_once_with(self.SocialAccountLinkedin, full_feed=True) + + def test_update_posts_statistics_keeps_a_publication_off_the_page(self): + """Update no longer walks the feed, so it cannot conclude a deletion. + + The publication missing from the page of recently modified ones is not + gone: nothing was read that could say so. Only the full resync decides + that, which is what ``test_full_resync_cleans_stale_urns`` covers. + """ + ugc_posts = [ + { + "id": "urn:li:share:other", + "commentary": "Other post", + "content": {}, + "publishedAt": 1735689600000, + "author": "urn:li:organization:123456", + } + ] + ( + patch_validate, + patch_get_posts, + patch_all_posts, + patch_entity, + patch_assets, + patch_page, + ) = self._generate_update_posts_statistics_patches(ugc_posts) + with patch_validate, patch_get_posts as mock_get_posts, ( + patch_all_posts + ) as mock_all_posts, patch_entity, patch_assets, patch_page: + self.SocialAccountLinkedin._update_posts_statistics(False, None) + self.assertEqual(self.SocialPostAccountLinkedin.state, "posted") + mock_all_posts.assert_not_called() + mock_get_posts.assert_called_once() + self.assertEqual( + mock_get_posts.call_args.kwargs.get("params_values", {}).get("sortBy"), + "LAST_MODIFIED", + msg="The page has to be the one of the recently modified posts.", + ) + + def test_update_posts_statistics_refreshes_a_publication_off_the_page(self): + """The figures of a stored publication are refreshed without the feed. + + It is the whole point of the change: the statistics are asked for by + URN, and Odoo already knows the URNs of the account, so a publication + the discovery page did not bring still gets its figures. + """ + post_account = self.SocialPostAccountLinkedin + post_account.write({"like_count": 0, "impression_count": 0}) + ugc_posts = [ + { + "id": "urn:li:share:other", + "commentary": "Other post", + "content": {}, + "publishedAt": 1735689600000, + "author": "urn:li:organization:123456", + } + ] + ( + patch_validate, + patch_get_posts, + patch_all_posts, + __, + patch_assets, + patch_page, + ) = self._generate_update_posts_statistics_patches(ugc_posts) + statistics = {post_account.remote_ref: (1, 7, 2, 0, 0.5, 42)} + patch_entity = patch( + PATCH_ACCOUNT_LINKEDIN.format("_get_entity_statistics"), + autospec=True, + return_value=statistics, + ) + with patch_validate, patch_get_posts, patch_all_posts, ( + patch_entity + ) as mock_entity, patch_assets, patch_page: + self.SocialAccountLinkedin._update_posts_statistics(False, None) + asked = [post["id"] for post in mock_entity.call_args.kwargs.get("posts") or []] + self.assertIn( + post_account.remote_ref, + asked, + msg="The stored URNs are asked about even when the page misses them.", + ) + self.assertEqual(post_account.like_count, 7) + self.assertEqual(post_account.impression_count, 42) + self.assertEqual( + post_account.state, + "posted", + msg="Refreshing the figures must not touch the state.", + ) + + def test_update_posts_statistics_partial_feed_keeps_the_publications(self): + """A feed read partially says nothing about what is missing from it.""" + ugc_posts = [ + { + "id": "urn:li:share:other", + "commentary": "Other post", + "content": {}, + "publishedAt": 1735689600000, + "author": "urn:li:organization:123456", + } + ] + ( + patch_validate, + patch_get_posts, + patch_all_posts, + patch_entity, + patch_assets, + patch_page, + ) = self._generate_update_posts_statistics_patches( + ugc_posts, feed_is_complete=False + ) + with patch_validate, patch_get_posts, patch_all_posts, patch_entity, ( + patch_assets + ), patch_page: + self.SocialAccountLinkedin._update_posts_statistics(False, None) + self.assertEqual(self.SocialPostAccountLinkedin.state, "posted") + self.assertEqual(self.SocialPostAccountLinkedin.remote_ref, "1234567890") + + def test_update_posts_statistics_restores_a_publication_back_online(self): + """A line wrongly marked is recognised again by its remote reference.""" + post_account = self.SocialPostAccountLinkedin + post_account.write({"state": "deleted", "post_account_url": False}) + ugc_posts = [ + { + "id": post_account.remote_ref, + "commentary": "Back online", + "content": {}, + "publishedAt": 1735689600000, + "author": "urn:li:organization:123456", + } + ] + ( + patch_validate, + patch_get_posts, + patch_all_posts, + patch_entity, + patch_assets, + patch_page, + ) = self._generate_update_posts_statistics_patches(ugc_posts) + with patch_validate, patch_get_posts, patch_all_posts, patch_entity, ( + patch_assets + ), patch_page: + self.SocialAccountLinkedin._update_posts_statistics(False, None) + self.assertEqual(post_account.state, "posted") + self.assertTrue(post_account.post_account_url) + + def test_chart_granularities_drop_the_week(self): + """LinkedIn only groups the share statistics by day or by month.""" + self.assertEqual( + self.SocialAccountLinkedin._get_chart_granularities(), + ["DAY", "MONTH"], + ) + + def _patch_chart_statistics(self, elements): + """Patch the finder answering one bucket per period.""" + return ( + 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", + ), + } + ), + 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": elements}, + } + ), + } + ), + ) + + def _chart_bucket(self, start, **statistics): + return { + "timeRange": {"start": start, "end": start + 86400000}, + "totalShareStatistics": statistics, + } + + def test_get_chart_account_statistics(self): + """Every bucket is charted on its own day, never on its position.""" + patch_dates, patch_request = self._patch_chart_statistics( + [ + # 2025-01-02 and 2025-01-05, in milliseconds. + self._chart_bucket( + 1735776000000, + clickCount=100, + likeCount=30, + commentCount=50, + impressionCount=7, + ), + self._chart_bucket( + 1736035200000, + clickCount=200, + likeCount=70, + commentCount=100, + shareCount=1, + engagement=0.5, + impressionCount=9, + ), + ] + ) + patch_totals = self.generate_patch( + **{ + "type_object": True, + "model_patch": self.SocialAccountLinkedin, + "method_patch": "_get_linkedin_chart_totals", + "return_value": { + "impressionCount": 280, + "commentCount": 3, + "reactionCount": 9, + }, + } + ) + with patch_dates, patch_request, patch_totals: + result = self.SocialAccountLinkedin._get_chart_account_statistics( + start_date="2025-01-01", end_date="2025-01-07", granularity="DAY" + ) + self.assertEqual(len(result), 1) + chart = result[0] + self.assertEqual(len(chart["labels"]), 7) + for dataset in chart["datasets"]: + self.assertEqual(len(dataset["data"]), len(chart["labels"])) + self.assertEqual(chart["datasets"][0]["data"], [0, 100, 0, 0, 200, 0, 0]) + self.assertEqual(chart["datasets"][1]["data"], [0, 0, 0, 0, 1, 0, 0]) + self.assertEqual(chart["datasets"][2]["data"], [0, 30, 0, 0, 70, 0, 0]) + self.assertEqual(chart["datasets"][3]["data"], [0, 50, 0, 0, 100, 0, 0]) + self.assertEqual(chart["datasets"][4]["data"], [0, 7, 0, 0, 9, 0, 0]) + self.assertEqual(chart["datasets"][5]["data"], [0, 0, 0, 0, 0.5, 0, 0]) + self.assertEqual(chart["impressionCount"], 16, "The ones of the period.") + self.assertEqual( + chart["impressionCountTotal"], 280, "The ones of the whole account." + ) + self.assertEqual(chart["commentCountTotal"], 3) + self.assertEqual(chart["reactionCountTotal"], 9) + + def test_get_chart_account_statistics_without_totals(self): + """Filtering does not read the figures of the account again.""" + patch_dates, patch_request = self._patch_chart_statistics( + [self._chart_bucket(1735776000000, impressionCount=7)] + ) + patch_totals = self.generate_patch( + **{ + "type_object": True, + "model_patch": self.SocialAccountLinkedin, + "method_patch": "_get_linkedin_chart_totals", + "return_value": {"impressionCount": 280}, + } + ) + with patch_dates, patch_request, patch_totals as mock_totals: + result = self.SocialAccountLinkedin._get_chart_account_statistics( + start_date="2025-01-01", + end_date="2025-01-07", + granularity="DAY", + with_totals=False, + ) + mock_totals.assert_not_called() + self.assertIsNone(result[0]["impressionCountTotal"]) + + def test_get_linkedin_chart_totals(self): + """The figures of the account are the series over the whole history. + + Reading them per post would mix the totals of the posts Odoo knows + with the ones of the whole organization page, and the figure of a + period would not be a part of them. + """ + _patch_dates, patch_request = self._patch_chart_statistics( + [ + self._chart_bucket( + 1735776000000, likeCount=4, commentCount=1, impressionCount=100 + ), + self._chart_bucket( + 1736035200000, + likeCount=5, + commentCount=2, + shareCount=1, + impressionCount=180, + ), + ] + ) + with patch_request as mock_request: + totals = self.SocialAccountLinkedin._get_linkedin_chart_totals() + self.assertEqual( + totals, + {"impressionCount": 280, "commentCount": 3, "reactionCount": 10}, + ) + self.assertIn( + "timeGranularityType:MONTH", + mock_request.call_args.kwargs["params_values"]["timeIntervals"], + msg="The history is asked for by month, not day by day.", + ) + + def test_get_linkedin_chart_statistics_adds_up_the_same_period(self): + """Two buckets of one period are added up, not overwritten.""" + _patch_dates, patch_request = self._patch_chart_statistics( + [ + # Both are days of january, so both are the "2025-01" month. + self._chart_bucket(1735776000000, impressionCount=100), + self._chart_bucket(1736035200000, impressionCount=180), + ] + ) + with patch_request: + statistics = self.SocialAccountLinkedin._get_linkedin_chart_statistics( + 0, 0, "MONTH", "ME" + ) + self.assertEqual(list(statistics), ["2025-01"]) + self.assertEqual(statistics["2025-01"][5], 280) + + def test_get_chart_account_statistics_error(self): + patch_dates = self._patch_chart_statistics([])[0] + patch_request_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_dates, patch_request_failed, self.assertRaises(UserError): + self.SocialAccountLinkedin._get_chart_account_statistics( + start_date="2025-01-01", end_date="2025-01-07", granularity="DAY" + ) + + @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("_request_linkedin")) + def test_validate_linkedin_access_token_stores_the_scopes( + self, mock_request_linkedin + ): + """The introspection is the only place LinkedIn says what was granted.""" + account = self.SocialAccountLinkedin + mock_request_linkedin.return_value = { + "active": True, + "scope": "w_member_social,r_organization_social", + } + self.assertTrue(account._validate_linkedin_access_token("token")) + self.assertEqual( + account.linkedin_granted_scopes, + "r_organization_social, w_member_social", + ) + + @patch(PATCH_ACCOUNT_LINKEDIN.format("_request_linkedin")) + def test_validate_linkedin_access_token_without_scopes(self, mock_request_linkedin): + """An answer without scopes does not erase what is known.""" + account = self.SocialAccountLinkedin + account.linkedin_granted_scopes = "r_ads" + mock_request_linkedin.return_value = {"active": True} + self.assertTrue(account._validate_linkedin_access_token("token")) + self.assertEqual(account.linkedin_granted_scopes, "r_ads") + + def test_has_linkedin_scope(self): + account = self.SocialAccountLinkedin + account.linkedin_granted_scopes = "r_ads, w_member_social" + self.assertTrue(account._has_linkedin_scope("r_ads")) + self.assertFalse(account._has_linkedin_scope("r_ads_reporting")) + + def test_has_linkedin_scope_unknown(self): + """Unknown scopes must not block an account that works.""" + account = self.SocialAccountLinkedin + account.linkedin_granted_scopes = False + self.assertTrue(account._has_linkedin_scope("r_ads")) + + def test_check_linkedin_scopes(self): + account = self.SocialAccountLinkedin + account.linkedin_granted_scopes = "r_ads" + self.assertIsNone(account._check_linkedin_scopes(["r_ads"])) + with self.assertRaises(UserError) as error: + account._check_linkedin_scopes(["r_ads", "r_ads_reporting"]) + self.assertIn("r_ads_reporting", str(error.exception)) + + def test_get_default_filter_date(self): + start_date = datetime.now() + end_date = start_date + timedelta(days=30) + result = self.SocialAccountLinkedinData._get_default_filter_date( + start_date=start_date, + end_date=end_date, + time_date=True, + ) + self.assertEqual( + result, + generate_timestamps(date_start=start_date, date_end=end_date), + msg="With time_date the dates come back as millisecond timestamps.", + ) + self.assertEqual( + self.SocialAccountLinkedinData._get_default_filter_date( + start_date=start_date, end_date=end_date + ), + (start_date, end_date), + ) + + @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) + + def test_unique_account(self): + with self.assertRaises(UserError): + self.SocialAccount._unique_account( + linkedin_client_id="fake-client-id", linkedin_secret="fake-secret" + ) + + def test_update_account(self): + res = self.SocialAccountLinkedin.action_update_account() + self.assertEqual(res["context"]["default_linkedin_client"], "fake-client-id") + + 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: + res = self.SocialAccountLinkedin._refresh_token() + self.assertEqual(res, fake_response) + 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(UserError): + 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_account_linkedin_reports_the_error_of_linkedin(self): + """The failure used to be hidden behind an Expected singleton.""" + account = self.SocialAccountLinkedin + account.write({"linkedin_account_id": "123456"}) + error_response = MagicMock(status_code=403) + error_response.text = '{"message": "Not enough permissions"}' + with patch.object( + type(self.SocialAccount), + "_request_linkedin", + return_value=error_response, + ), self.assertRaises(UserError) as error: + account._get_account_linkedin("fake-access-token") + self.assertIn("Not enough permissions", str(error.exception)) + + def test_get_account_linkedin_keeps_the_organizations_that_answered(self): + """One unreadable organization must not drop the others.""" + account = self.SocialAccountLinkedin + account.sudo().write({"remote_ref": "urn:li:organization:123456"}) + error_response = MagicMock(status_code=403) + error_response.text = '{"message": "Not enough permissions"}' + messages = len(account.message_ids) + patch_request_linkedin = self.get_patch_exceptions_linkedin( + side_effect=[ + { + "elements": [ + {"organization": "urn:li:organization:123456"}, + {"organization": "urn:li:organization:999"}, + ] + }, + error_response, + { + "id": "999", + "vanityName": "Readable", + "name": {"localized": {"en_US": "Readable"}}, + }, + ] + ) + with patch_request_linkedin: + res = self.SocialAccount._get_account_linkedin("fake-access-token") + self.assertEqual([organization["id"] for organization in res], ["999"]) + self.assertEqual(len(account.message_ids), messages + 1) + self.assertIn("Not enough permissions", account.message_ids[0].body) + + def test_get_account_linkedin_without_localized_name(self): + """An organization without a localized name must not break the flow.""" + patch_request_linkedin = self.get_patch_exceptions_linkedin( + side_effect=[ + {"elements": [{"organization": "urn:li:organization:123456"}]}, + { + "id": "123456", + "vanityName": "No name", + "name": {"localized": {}}, + }, + ] + ) + with patch_request_linkedin: + res = self.SocialAccount._get_account_linkedin("fake-access-token") + self.assertEqual(len(res), 1) + self.assertFalse(res[0]["localizedName"]) + + def test_get_account_linkedin_prefers_the_language_of_the_user(self): + """The name of the organization follows the language of the user.""" + patch_request_linkedin = self.get_patch_exceptions_linkedin( + side_effect=[ + {"elements": [{"organization": "urn:li:organization:123456"}]}, + { + "id": "123456", + "vanityName": "Binhex", + "name": { + "localized": {"es_ES": "Nombre", "en_US": "Name"}, + }, + }, + ] + ) + self.env.user.lang = "en_US" + with patch_request_linkedin: + res = self.SocialAccount._get_account_linkedin("fake-access-token") + self.assertEqual(res[0]["localizedName"], "Name") + + def test_get_account_linkedin_logo_without_preferred_size(self): + organization_logo_mock = MagicMock() + organization_logo_mock.status_code = 200 + organization_logo_mock.content = b"fake image data" + response_organization = { + "id": "organization123456789", + "vanityName": "Organization Test", + "name": {"localized": {"es_ES": "Organization Test"}}, + "logoV2": { + "original~": { + "elements": [ + { + "artifact": "logo_200_200/image_organization123456789", + "identifiers": [ + {"identifier": "https://www.medias.com/logo_200_200"} + ], + } + ] + } + }, + } + patch_request_linkedin = self.get_patch_exceptions_linkedin( + side_effect=[ + {"elements": [{"organization": "organization:123456789"}]}, + response_organization, + organization_logo_mock, + ] + ) + with patch_request_linkedin: + res = self.SocialAccount._get_account_linkedin("fake-access-token") + self.assertTrue( + res[0]["logo"], + msg="The first element is used when no 400x400 variant exists.", + ) + + 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_add_account_asks_for_the_module_scopes(self): + """Without an account there is nothing but the defaults to ask for.""" + with patch.object( + type(self.wizard_account_id), + "_get_url_redirect", + return_value=self.url_callback, + ): + url = self.wizard_account_id.with_context( + only_url=True + )._action_add_account() + media = self.env.ref("social_media_linkedin.social_media_linkedin") + for scope in media._get_linkedin_scopes(): + self.assertIn(scope, url) + + def test_action_add_account_asks_for_the_account_scopes(self): + """Re-authorizing asks for the module scopes and the account edits.""" + account = self.SocialAccountLinkedin + account.sudo().linkedin_granted_scopes = "r_basicprofile, r_fake_product" + wizard = self.wizard_account_id.copy( + {"account_id": account.id, "update_keys": True} + ) + with patch.object( + type(wizard), "_get_url_redirect", return_value=self.url_callback + ): + url = wizard.with_context(only_url=True)._action_add_account() + self.assertIn("r_fake_product", url) + for scope in account.media_id._get_linkedin_scopes(): + self.assertIn(scope, url) + + def test_get_linkedin_authorization_scopes_without_them(self): + """An account that knows no scopes falls back to the defaults.""" + account = self.SocialAccountLinkedin + account.sudo().linkedin_granted_scopes = False + self.assertEqual( + account._get_linkedin_authorization_scopes(), + account.media_id._get_linkedin_scopes(), + ) + + def test_get_linkedin_authorization_scopes_keeps_the_module_ones(self): + """A token granted before a module was installed still asks for it.""" + account = self.SocialAccountLinkedin + account.sudo().linkedin_granted_scopes = "w_member_social" + scopes = account._get_linkedin_authorization_scopes() + for scope in account.media_id._get_linkedin_scopes(): + self.assertIn(scope, scopes) + self.assertEqual(len(scopes), len(set(scopes))) + + 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, + "linkedin_client": "new-client-id", + "linkedin_secret": "new-secret", + } + ) + result = self.wizard_account_id._update_account() + self.assertEqual(result["type"], "ir.actions.act_url") + self.assertEqual(result["target"], "self") + account_sudo = self.SocialAccountLinkedin.sudo() + self.assertEqual(account_sudo.linkedin_client_id, "fake-client-id") + self.assertEqual(account_sudo.linkedin_secret, "fake-secret") + + def test_update_account_keys_rejects_the_keys_of_another_account(self): + other = self.SocialAccount.sudo().create( + { + "name": "Another Linkedin", + "media_id": self.media_linkedin_id.id, + "linkedin_client_id": "taken-client-id", + "linkedin_secret": "taken-secret", + } + ) + self.wizard_account_id.write( + { + "update_keys": True, + "account_id": self.SocialAccountLinkedin.id, + "linkedin_client": other.linkedin_client_id, + "linkedin_secret": other.linkedin_secret, + } + ) + with self.assertRaises(UserError): + self.wizard_account_id._update_account() + + @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_set_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._set_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(UserError) as ctx: + self.SocialAccount._create_account_linkedin( + "fake-client-id", + "fake-secret", + MagicMock(text="Error token"), + ) + self.assertIn("Creating account", str(ctx.exception)) + self.assertIn("Error token", str(ctx.exception)) + + def test_create_account_linkedin_without_access_token(self): + """A token without an access token used to fail silently.""" + with self.assertRaises(UserError) as ctx: + self.SocialAccount._create_account_linkedin( + "fake-client-id", + "fake-secret", + {"expires_in": 3600}, + ) + self.assertIn("without an access token", str(ctx.exception)) + + def test_linkedin_error_message_explains_the_credentials(self): + error = Mock( + text='{"error":"invalid_client",' + '"error_description":"Client authentication failed"}' + ) + message = self.SocialAccount._linkedin_error_message(error) + self.assertIn("Client ID", message) + self.assertIn("Client Secret", message) + + def test_linkedin_error_message_explains_the_authorization(self): + error = Mock( + text='{"error":"invalid_request","error_description":' + '"Unable to retrieve access token: authorization code not found"}' + ) + message = self.SocialAccount._linkedin_error_message(error) + self.assertIn("no longer valid", message) + + def test_linkedin_error_message_uses_the_answer_of_linkedin(self): + """An unknown error is reported with the words of LinkedIn.""" + error = Mock( + text='{"serviceErrorCode":100,"message":"Not enough permissions ' + 'to access: GET /organizationAcls"}' + ) + self.assertEqual( + self.SocialAccount._linkedin_error_message(error), + "Not enough permissions to access: GET /organizationAcls", + ) + + def test_linkedin_error_message_details_the_rejected_fields(self): + """A validation error is reported field by field, not as a summary.""" + error = Mock( + text=( + '{"code":"MULTIPLE_VALIDATIONS_FAILED","message":"Multiple ' + 'errors occurred during the input validation.","errorDetails":' + '{"inputErrors":[{"description":"/Campaign/runSchedule/start ' + 'value 1 must be no earlier than 2"}],' + '"conditionalInputErrors":[{"description":"/Campaign/status ' + "cannot be set to ARCHIVED if /CampaignGroup/status is set to " + 'DRAFT"}]}}' + ) + ) + message = self.SocialAccount._linkedin_error_message(error) + self.assertIn("must be no earlier than", message) + self.assertIn("cannot be set to ARCHIVED", message) + self.assertNotIn("Multiple errors", message) + + def test_linkedin_error_message_keeps_an_answer_that_is_not_json(self): + error = Mock(text="502 Bad Gateway") + self.assertEqual( + self.SocialAccount._linkedin_error_message(error), + "502 Bad Gateway", + ) + + def test_linkedin_error_message_accepts_a_parsed_body(self): + self.assertEqual( + self.SocialAccount._linkedin_error_message({"message": "Bad request"}), + "Bad request", + ) + + 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, + patch( + PATCH_ACCOUNT.format("_trigger_initial_sync"), + autospec=True, + ) as mock_trigger_sync, + ): + self.SocialAccount._create_account_linkedin( + "fake-client-id", + "fake-secret", + {"access_token": "fake-access-token"}, + ) + self.assertEqual( + mock_search.call_count, + 3, + msg="The wizard, the account by remote reference and the " + "account by user name for the rows stored without one.", + ) + mock_account_linkedin.assert_called_once() + mock_create.assert_called_once() + mock_unlink.assert_called_once() + mock_trigger_sync.assert_called_once() + self.assertEqual( + mock_trigger_sync.call_args[0][0], + self.SocialAccountLinkedin, + msg="The initial sync only targets the associated accounts.", + ) + + def test_create_account_linkedin_reactivates_archived(self): + self.SocialAccount.create( + { + "name": "Archived Org", + "username": "archived-org", + "media_id": self.media_linkedin_id.id, + "active": False, + } + ) + fake_organization = { + "vanityName": "archived-org", + "localizedName": "Archived Org", + "id": "999", + } + with ( + patch.object( + type(self.SocialAccountLinkedin), + "_get_account_linkedin", + autospec=True, + return_value=[fake_organization], + ), + patch( + PATCH_ACCOUNT.format("_trigger_initial_sync"), + autospec=True, + ), + ): + self.SocialAccount._create_account_linkedin( + "fake-client-id-2", + "fake-secret-2", + {"access_token": "fake-access-token"}, + ) + accounts = self.SocialAccount.with_context(active_test=False).search( + [("username", "=", "archived-org"), ("media_type", "=", "linkedin")] + ) + self.assertEqual(len(accounts), 1) + self.assertTrue(accounts.active) + + def test_create_account_linkedin_stamps_the_last_update(self): + """The OAuth callback is where the account gets its data back. + + The wizard only writes the keys when they are updated and hands the + rest over to this flow, so the stamp belongs here. + """ + account = self.SocialAccountLinkedin + account.last_update_account = False + fake_organization = { + "vanityName": account.username, + "localizedName": account.name, + "id": account.remote_ref.split(":")[-1], + } + with ( + patch.object( + type(account), + "_get_account_linkedin", + autospec=True, + return_value=[fake_organization], + ), + patch(PATCH_ACCOUNT.format("_trigger_initial_sync"), autospec=True), + ): + self.SocialAccount._create_account_linkedin( + "fake-client-id", + "fake-secret", + {"access_token": "fake-access-token"}, + ) + self.assertTrue(account.last_update_account) + + 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=30) + ).date() + self.SocialAccountLinkedin.refresh_token_expires_in = ( + datetime.now() + timedelta(days=30) + ).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() + + def test_validate_access_token_renews_before_the_expiry_date(self): + """A token expiring within the margin is renewed, not left to run out.""" + self.SocialAccountLinkedin.expire_access_token_date = ( + datetime.now() + timedelta(days=_TOKEN_MARGIN_DAYS_LINKEDIN - 1) + ).date() + self.SocialAccountLinkedin.refresh_token_expires_in = ( + datetime.now() + timedelta(days=200) + ).date() + with patch(PATCH_ACCOUNT.format("validate_access_token")), patch.object( + type(self.SocialAccount), + "_validate_linkedin_access_token", + autospec=True, + return_value=True, + ) as mock_validate_token, patch( + PATCH_SOCIAL_BASE_MIXIN.format("_notify_user_client") + ): + self.SocialAccountLinkedin.validate_access_token() + mock_validate_token.assert_called_once() + + def test_validate_access_token_message_is_not_ambiguous(self): + self.SocialAccountLinkedin.expire_access_token_date = ( + datetime.now() + timedelta(days=-10) + ).date() + with patch(PATCH_ACCOUNT.format("validate_access_token")), patch.object( + type(self.SocialAccount), + "_validate_linkedin_access_token", + autospec=True, + return_value=True, + ), patch( + PATCH_SOCIAL_BASE_MIXIN.format("_notify_user_client") + ) as mock_notify_user: + self.SocialAccountLinkedin.validate_access_token() + self.assertEqual( + mock_notify_user.call_args.kwargs["notif_message"], "The token is valid." + ) + self.assertEqual( + mock_notify_user.call_args.kwargs["notif_type"], "social_form_success" + ) + + def test_validate_access_token_without_expiry_dates(self): + self.SocialAccountLinkedin.write( + { + "expire_access_token_date": False, + "refresh_token_expires_in": False, + } + ) + with patch( + PATCH_ACCOUNT_LINKEDIN.format("_validate_linkedin_access_token"), + autospec=True, + ) as mock_validate: + self.SocialAccountLinkedin.with_context( + not_notify=True + ).validate_access_token() + mock_validate.assert_not_called() + + def test_validate_access_token_expired_uses_context_token(self): + self.SocialAccountLinkedin.write( + { + "expire_access_token_date": "2020-01-01", + "access_token": False, + } + ) + with patch( + PATCH_ACCOUNT_LINKEDIN.format("_validate_linkedin_access_token"), + autospec=True, + return_value=True, + ) as mock_validate: + self.SocialAccountLinkedin.with_context( + not_notify=True, access_token="ctx-token" + ).validate_access_token() + mock_validate.assert_called_once() + self.assertEqual(mock_validate.call_args[0][1], "ctx-token") + + def test_action_validate_access_token_always_asks_linkedin(self): + """A token can be revoked long before the stored dates expire.""" + self.SocialAccountLinkedin.write( + { + "expire_access_token_date": ( + datetime.now() + timedelta(days=30) + ).date(), + "refresh_token_expires_in": ( + datetime.now() + timedelta(days=60) + ).date(), + } + ) + with patch( + PATCH_ACCOUNT_LINKEDIN.format("_validate_linkedin_access_token"), + autospec=True, + return_value=True, + ) as mock_validate, patch( + PATCH_SOCIAL_BASE_MIXIN.format("_notify_user_client") + ) as mock_notify: + self.SocialAccountLinkedin.action_validate_access_token() + mock_validate.assert_called_once() + self.assertEqual( + mock_notify.call_args.kwargs["notif_message"], "The token is valid." + ) + + def test_action_validate_access_token_renews_a_revoked_token(self): + self.SocialAccountLinkedin.write( + { + "expire_access_token_date": ( + datetime.now() + timedelta(days=30) + ).date(), + "refresh_token_expires_in": ( + datetime.now() + timedelta(days=60) + ).date(), + } + ) + with patch( + PATCH_ACCOUNT_LINKEDIN.format("_validate_linkedin_access_token"), + autospec=True, + return_value=False, + ), patch.object( + type(self.wizard_account_id), "_update_account", autospec=True + ) as mock_update: + self.SocialAccountLinkedin.action_validate_access_token() + mock_update.assert_called_once() + + def test_validate_access_token_guard_stays_cheap(self): + """The guard of every API call must not add a request of its own.""" + self.SocialAccountLinkedin.write( + { + "expire_access_token_date": ( + datetime.now() + timedelta(days=30) + ).date(), + "refresh_token_expires_in": ( + datetime.now() + timedelta(days=60) + ).date(), + } + ) + with patch( + PATCH_ACCOUNT_LINKEDIN.format("_validate_linkedin_access_token"), + autospec=True, + ) as mock_validate: + self.SocialAccountLinkedin.with_context( + not_notify=True + ).validate_access_token() + mock_validate.assert_not_called() + + def test_update_account_does_not_propose_the_client_secret(self): + """The context of an action is serialized to the browser.""" + action = self.SocialAccountLinkedin.action_update_account() + self.assertEqual(action["context"]["default_linkedin_client"], "fake-client-id") + self.assertNotIn("default_linkedin_secret", action["context"]) + + def test_update_account_hides_the_client_id_from_a_non_administrator(self): + """The Client ID is restricted to base.group_system.""" + manager = self.env["res.users"].create( + { + "name": "Social manager without system access", + "login": "social_manager_no_system_test", + "groups_id": [ + Command.set( + [ + self.env.ref("base.group_user").id, + self.env.ref( + "social_media_base.group_social_media_manager" + ).id, + ] + ) + ], + } + ) + action = self.SocialAccountLinkedin.with_user(manager).action_update_account() + self.assertNotIn("default_linkedin_client", action["context"]) + + def test_refresh_token_keeps_the_credentials_on_a_json_error(self): + """LinkedIn answers its errors as JSON too: a dict proves nothing.""" + with patch( + PATCH_ACCOUNT_LINKEDIN.format("_request_linkedin"), + autospec=True, + return_value={"error": "invalid_grant", "error_description": "expired"}, + ), self.assertRaises(UserError): + self.SocialAccountLinkedin._refresh_token() + self.assertEqual(self.SocialAccountLinkedin.sudo().access_token, "fake-token") + + def test_refresh_credentials_stores_the_new_token(self): + fake_response = { + "access_token": "renewed-access-token", + "refresh_token": "renewed-refresh-token", + "expires_in": 60 * 86400, + "refresh_token_expires_in": 365 * 86400, + } + self.SocialAccountLinkedin.sudo().refresh_access_token = "fake-refresh-token" + with patch( + PATCH_ACCOUNT_LINKEDIN.format("_request_linkedin"), + autospec=True, + return_value=fake_response, + ): + self.assertTrue(self.SocialAccountLinkedin._refresh_credentials()) + account_sudo = self.SocialAccountLinkedin.sudo() + self.assertEqual(account_sudo.access_token, "renewed-access-token") + self.assertEqual(account_sudo.refresh_access_token, "renewed-refresh-token") + self.assertEqual( + account_sudo.expire_access_token_date, + (datetime.now() + timedelta(days=60)).date(), + ) + + def test_refresh_credentials_without_refresh_token(self): + """Nothing to renew: the account has to be authorized from the browser.""" + self.SocialAccountLinkedin.sudo().refresh_access_token = False + self.assertFalse(self.SocialAccountLinkedin._refresh_credentials()) + + def test_refresh_credentials_with_an_expired_refresh_token(self): + self.SocialAccountLinkedin.sudo().refresh_access_token = "fake-refresh-token" + self.SocialAccountLinkedin.refresh_token_expires_in = ( + datetime.now() - timedelta(days=1) + ).date() + self.assertFalse(self.SocialAccountLinkedin._refresh_credentials()) + + @mute_logger(LOGGER_ACCOUNT_LINKEDIN) + def test_refresh_credentials_when_linkedin_refuses_it(self): + self.SocialAccountLinkedin.sudo().refresh_access_token = "fake-refresh-token" + with patch( + PATCH_ACCOUNT_LINKEDIN.format("_request_linkedin"), + autospec=True, + return_value={"error": "invalid_grant", "error_description": "expired"}, + ): + self.assertFalse(self.SocialAccountLinkedin._refresh_credentials()) + self.assertEqual(self.SocialAccountLinkedin.sudo().access_token, "fake-token") + + def test_a_refused_authorization_is_told_apart(self): + """Only the credentials errors are worth publishing again.""" + with self.assertRaises(SocialCredentialsError): + self.SocialAccountLinkedin._linkedin_raise_error( + "The post could not be published on LinkedIn", + self.generate_magic_mock(status_code=401), + ) + with self.assertRaises(UserError) as error: + self.SocialAccountLinkedin._linkedin_raise_error( + "The post could not be published on LinkedIn", + {"message": "The commentary is too long"}, + ) + self.assertNotIsInstance(error.exception, SocialCredentialsError) + + def test_unique_account_ignores_the_account_being_updated(self): + account = self.SocialAccountLinkedin + account.sudo()._unique_account( + account.sudo().linkedin_client_id, account.sudo().linkedin_secret + ) + with self.assertRaises(UserError): + self.SocialAccount.sudo()._unique_account( + account.sudo().linkedin_client_id, account.sudo().linkedin_secret + ) + + def test_get_access_token_linkedin_invalid_state(self): + with self.assertRaises(UserError): + self.SocialAccountLinkedin._get_access_token_linkedin( + "CODE", "/web", {"state": "unknown-state"} + ) + + def test_get_access_token_linkedin_state_of_another_user(self): + other_user = self.env["res.users"].create( + { + "name": "Other social user", + "login": "other_social_user_test", + "groups_id": [ + ( + 6, + 0, + [ + self.env.ref("base.group_user").id, + self.env.ref( + "social_media_base.group_social_media_user" + ).id, + ], + ) + ], + } + ) + with self.assertRaises(UserError): + self.SocialAccountLinkedin.with_user(other_user)._get_access_token_linkedin( + "CODE", "/web", {"state": "fake-csrf-token"} + ) + + def test_consume_linkedin_oauth_wizard(self): + self.SocialAccount._consume_linkedin_oauth_wizard("fake-csrf-token") + self.assertFalse(self.wizard_account_id.exists()) + + def test_get_entity_statistics_does_not_mutate_params(self): + params_fields = ["q", "organizationalEntity"] + params_values = { + "q": "organizationalEntity", + "organizationalEntity": "urn:li:organization:123456", + } + with patch( + PATCH_ACCOUNT_LINKEDIN.format("_get_share_statistics"), + autospec=True, + return_value={}, + ), patch( + PATCH_ACCOUNT_LINKEDIN.format("_get_ugc_share_statistics"), + autospec=True, + return_value={}, + ), patch( + PATCH_ACCOUNT_LINKEDIN.format("_get_ugc_posts_statistics"), + autospec=True, + return_value={}, + ): + self.SocialAccountLinkedin._get_entity_statistics( + posts=[{"id": "urn:li:ugcPost:1"}], + params_fields=params_fields, + params_values=params_values, + ) + self.assertEqual(params_fields, ["q", "organizationalEntity"]) + self.assertEqual( + params_values, + { + "q": "organizationalEntity", + "organizationalEntity": "urn:li:organization:123456", + }, + ) + + def test_get_share_statistics(self): + self.assertEqual(self.SocialAccountLinkedin._get_share_statistics(), {}) + params_fields = ["q"] + params_values = {"q": "organizationalEntity"} + self.assertEqual( + self.SocialAccountLinkedin._get_share_statistics( + posts=[{"id": "urn:li:ugcPost:1"}], + params_fields=params_fields, + params_values=params_values, + ), + {}, + msg="The share endpoint ignores the UGC posts.", + ) + self.assertNotIn("shares", params_fields) + response = MagicMock(status_code=200) + response.json.return_value = { + "elements": [ + { + "share": "urn:li:share:1", + "totalShareStatistics": { + "clickCount": 1, + "likeCount": 2, + "commentCount": 3, + "shareCount": 4, + "engagement": 0.5, + "impressionCount": 6, + }, + } + ] + } + with patch( + PATCH_ACCOUNT_LINKEDIN.format("_request_linkedin"), + autospec=True, + return_value=response, + ) as mock_request: + data = self.SocialAccountLinkedin._get_share_statistics( + posts=[{"id": "urn:li:share:1"}, {"id": "urn:li:ugcPost:2"}], + params_fields=params_fields, + params_values=params_values, + ) + self.assertEqual(data, {"urn:li:share:1": (1, 2, 3, 4, 0.5, 6)}) + self.assertEqual( + (params_fields, params_values), + (["q"], {"q": "organizationalEntity"}), + msg="The parameters of the caller are left alone.", + ) + self.assertEqual( + mock_request.call_args.kwargs["params_values"]["shares"], + ["urn:li:share:1"], + ) + self.assertEqual( + mock_request.call_args.kwargs["endpoint"], + "/organizationalEntityShareStatistics", + ) + error_response = MagicMock(status_code=400) + error_response.json.return_value = {"message": "Invalid share urn"} + with patch( + PATCH_ACCOUNT_LINKEDIN.format("_request_linkedin"), + autospec=True, + return_value=error_response, + ): + with self.assertRaises(UserError): + self.SocialAccountLinkedin._get_share_statistics( + posts=[{"id": "urn:li:share:1"}], + params_fields=["q"], + params_values={"q": "organizationalEntity"}, + ) + + def test_get_ugc_posts_statistics(self): + self.assertEqual(self.SocialAccountLinkedin._get_ugc_posts_statistics(), {}) + params_fields = ["q"] + params_values = {"q": "organizationalEntity"} + self.assertEqual( + self.SocialAccountLinkedin._get_ugc_posts_statistics( + posts=[{"id": "urn:li:share:1"}], + params_fields=params_fields, + params_values=params_values, + ), + {}, + msg="The UGC posts endpoint ignores the shares.", + ) + self.assertNotIn("ids", params_fields) + response = MagicMock(status_code=200) + response.json.return_value = { + "results": { + "urn:li:ugcPost:1": { + "likesSummary": {"totalLikes": 7}, + "commentsSummary": {"aggregatedTotalComments": 8}, + } + } + } + with patch( + PATCH_ACCOUNT_LINKEDIN.format("_request_linkedin"), + autospec=True, + return_value=response, + ) as mock_request: + data = self.SocialAccountLinkedin._get_ugc_posts_statistics( + posts=[{"id": "urn:li:ugcPost:1"}, {"id": "urn:li:share:2"}], + params_fields=params_fields, + params_values=params_values, + ) + self.assertEqual( + data, + {"urn:li:ugcPost:1": (7, 8)}, + msg="socialActions only knows the likes and the comments.", + ) + self.assertEqual( + (params_fields, params_values), + (["q"], {"q": "organizationalEntity"}), + msg="The parameters of the caller are left alone.", + ) + self.assertEqual( + mock_request.call_args.kwargs["params_values"]["ids"], + ["urn:li:ugcPost:1"], + ) + self.assertEqual(mock_request.call_args.kwargs["endpoint"], "/socialActions") + error_response = MagicMock(status_code=400) + error_response.json.return_value = {"message": "Invalid ugc post urn"} + with patch( + PATCH_ACCOUNT_LINKEDIN.format("_request_linkedin"), + autospec=True, + return_value=error_response, + ): + with self.assertRaises(UserError): + self.SocialAccountLinkedin._get_ugc_posts_statistics( + posts=[{"id": "urn:li:ugcPost:1"}], + params_fields=["q"], + params_values={"q": "organizationalEntity"}, + ) + + def _fake_urns(self, prefix, count): + """Return URNs as long as the ones LinkedIn answers.""" + return [f"{prefix}{7132564752928563200 + index}" for index in range(count)] + + def _linkedin_query_string(self, call): + """Rebuild the query string that a ``_request_linkedin`` call sends.""" + kwargs = call.kwargs + return "&".join( + social_url_encode( + param_field, + kwargs["params_values"], + kwargs.get("params_values_char_ignore"), + kwargs.get("format_quote"), + ) + for param_field in kwargs["params_fields"] + ) + + def test_batch_urns_by_url_size(self): + """The batches are cut on the encoded size and lose no URN.""" + urns = self._fake_urns("urn:li:ugcPost:", 250) + batches = _batch_urns_by_url_size(urns, "ugcPosts") + self.assertGreater(len(batches), 1) + for batch in batches: + self.assertLessEqual( + _encoded_urns_bytes(batch, "ugcPosts"), + _QUERY_STRING_MAX_BYTES_LINKEDIN - _QUERY_STRING_MARGIN_BYTES_LINKEDIN, + ) + self.assertEqual([urn for batch in batches for urn in batch], urns) + + def test_batch_urns_by_url_size_edge_cases(self): + self.assertEqual(_batch_urns_by_url_size([], "shares"), []) + self.assertEqual( + _batch_urns_by_url_size( + ["urn:li:share:1"], + "shares", + _QUERY_STRING_MAX_BYTES_LINKEDIN, + ), + [["urn:li:share:1"]], + msg="A URN that does not fit is still asked for, on its own.", + ) + + def test_get_share_statistics_splits_the_urns(self): + """A feed of more than a page fits in no single query string.""" + urns = self._fake_urns("urn:li:share:", 250) + response = MagicMock(status_code=200) + response.json.return_value = {"elements": []} + with patch( + PATCH_ACCOUNT_LINKEDIN.format("_request_linkedin"), + autospec=True, + return_value=response, + ) as mock_request: + self.SocialAccountLinkedin._get_share_statistics( + posts=[{"id": urn} for urn in urns], + params_fields=["q", "organizationalEntity"], + params_values={ + "q": "organizationalEntity", + "organizationalEntity": "urn:li:organization:123456", + }, + ) + self.assertGreater(mock_request.call_count, 1) + asked = [] + for call in mock_request.call_args_list: + self.assertLess( + len(self._linkedin_query_string(call).encode()), + _QUERY_STRING_MAX_BYTES_LINKEDIN, + ) + asked.extend(call.kwargs["params_values"]["shares"][0].split(",")) + self.assertEqual(asked, urns, msg="Every URN is asked for exactly once.") + + def test_get_ugc_posts_statistics_splits_the_urns(self): + urns = self._fake_urns("urn:li:ugcPost:", 250) + response = MagicMock(status_code=200) + response.json.return_value = {"results": {}} + with patch( + PATCH_ACCOUNT_LINKEDIN.format("_request_linkedin"), + autospec=True, + return_value=response, + ) as mock_request: + self.SocialAccountLinkedin._get_ugc_posts_statistics( + posts=[{"id": urn} for urn in urns], + params_fields=[], + params_values={}, + ) + self.assertGreater(mock_request.call_count, 1) + asked = [] + for call in mock_request.call_args_list: + self.assertLess( + len(self._linkedin_query_string(call).encode()), + _QUERY_STRING_MAX_BYTES_LINKEDIN, + ) + asked.extend(call.kwargs["params_values"]["ids"][0].split(",")) + self.assertEqual(asked, urns) + + def test_get_ugc_share_statistics(self): + """The UGC posts answer the same block of figures as the shares.""" + self.assertEqual(self.SocialAccountLinkedin._get_ugc_share_statistics(), {}) + response = MagicMock(status_code=200) + response.json.return_value = { + "elements": [ + { + "ugcPost": "urn:li:ugcPost:1", + "totalShareStatistics": { + "clickCount": 9, + "likeCount": 1, + "commentCount": 2, + "shareCount": 3, + "engagement": 0.25, + "impressionCount": 40, + }, + }, + {"organizationalEntity": "urn:li:organization:123456"}, + ] + } + with patch( + PATCH_ACCOUNT_LINKEDIN.format("_request_linkedin"), + autospec=True, + return_value=response, + ) as mock_request: + data = self.SocialAccountLinkedin._get_ugc_share_statistics( + posts=[{"id": "urn:li:ugcPost:1"}, {"id": "urn:li:share:2"}], + params_fields=["q"], + params_values={"q": "organizationalEntity"}, + ) + self.assertEqual( + data, + {"urn:li:ugcPost:1": (9, 1, 2, 3, 0.25, 40)}, + msg="The aggregate element, which names no entity, is left out.", + ) + self.assertEqual( + mock_request.call_args.kwargs["params_values"]["ugcPosts"], + ["urn:li:ugcPost:1"], + ) + self.assertEqual( + mock_request.call_args.kwargs["endpoint"], + "/organizationalEntityShareStatistics", + ) + error_response = MagicMock(status_code=400) + error_response.json.return_value = {"message": "Invalid ugc post urn"} + with patch( + PATCH_ACCOUNT_LINKEDIN.format("_request_linkedin"), + autospec=True, + return_value=error_response, + ): + with self.assertRaises(UserError): + self.SocialAccountLinkedin._get_ugc_share_statistics( + posts=[{"id": "urn:li:ugcPost:1"}], + params_fields=["q"], + params_values={"q": "organizationalEntity"}, + ) + + def test_get_entity_statistics_merges_the_two_ugc_sources(self): + """A UGC post keeps its figures and takes its likes from the feed.""" + with patch( + PATCH_ACCOUNT_LINKEDIN.format("_get_share_statistics"), + autospec=True, + return_value={"urn:li:share:1": (1, 2, 3, 4, 0.5, 6)}, + ), patch( + PATCH_ACCOUNT_LINKEDIN.format("_get_ugc_share_statistics"), + autospec=True, + return_value={"urn:li:ugcPost:1": (9, 0, 0, 3, 0.25, 40)}, + ), patch( + PATCH_ACCOUNT_LINKEDIN.format("_get_ugc_posts_statistics"), + autospec=True, + return_value={"urn:li:ugcPost:1": (7, 8), "urn:li:ugcPost:2": (1, 2)}, + ) as mock_social_actions: + data = self.SocialAccountLinkedin._get_entity_statistics( + posts=[{"id": "urn:li:share:1"}, {"id": "urn:li:ugcPost:1"}] + ) + self.assertEqual( + data, + { + "urn:li:share:1": (1, 2, 3, 4, 0.5, 6), + "urn:li:ugcPost:1": (9, 7, 8, 3, 0.25, 40), + "urn:li:ugcPost:2": (0, 1, 2, 0, 0, 0), + }, + ) + self.assertEqual( + mock_social_actions.call_args.kwargs["params_fields"], + [], + msg="socialActions takes neither the finder nor the organization.", + ) + self.assertEqual(mock_social_actions.call_args.kwargs["params_values"], {}) + + @mute_logger(LOGGER_ACCOUNT_LINKEDIN) + def test_update_posts_statistics_isolates_each_account(self): + """A failing account is rolled back alone, the other one is refreshed.""" + failing = self.SocialAccountLinkedin + working = self.SocialAccountLinkedinData + stale = self.SocialPostAccountLinkedin + ugc_posts = [ + { + "id": "urn:li:share:other", + "commentary": "Other post", + "content": {}, + "publishedAt": 1735689600000, + "author": "urn:li:organization:123456", + } + ] + ( + patch_validate, + patch_get_posts, + patch_all_posts, + __, + patch_assets, + patch_page, + ) = self._generate_update_posts_statistics_patches(ugc_posts) + + def entity_statistics(account, *args, **kwargs): + if account.id == failing.id: + raise UserError(_("LinkedIn refused the statistics")) + return {} + + patch_entity = self.generate_patch( + **{ + "model_patch": PATCH_ACCOUNT_LINKEDIN.format("_get_entity_statistics"), + "side_effect": entity_statistics, + } + ) + with patch_validate, patch_get_posts, patch_all_posts, patch_entity, ( + patch_assets + ), patch_page: + (failing | working)._update_posts_statistics(False, None) + self.env.invalidate_all() + self.assertEqual( + stale.state, + "posted", + msg="The sweep of the failing account was rolled back with it.", + ) + self.assertTrue( + working.post_account_ids.filtered( + lambda line: line.remote_ref == "urn:li:share:other" + ), + msg="The account that did not fail was refreshed all the same.", + ) + + def _isolate_linkedin_account(self): + """Leave ``SocialAccountLinkedin`` as the only LinkedIn account. + + ``_run_check_media_updates`` scans every LinkedIn account, so the + other accounts have to be archived to know which one the assertions + are about. + """ + self.SocialAccount.search( + [ + ("media_type", "=", "linkedin"), + ("id", "!=", self.SocialAccountLinkedin.id), + ] + ).write({"active": False}) + + def _mark_the_page_as_imported(self, accounts=None, statistics=None): + """Leave on the accounts the mark a previous import would have left.""" + accounts = accounts or self.SocialAccountLinkedin + accounts.linkedin_statistics_checkpoint = _linkedin_statistics_checkpoint( + statistics or RECENT_STATISTICS_LINKEDIN + ) + + def _patch_recent_statistics(self, statistics=None, side_effect=None): + if side_effect: + return patch( + PATCH_ACCOUNT_LINKEDIN.format("_get_linkedin_recent_statistics"), + autospec=True, + side_effect=side_effect, + ) + return patch( + PATCH_ACCOUNT_LINKEDIN.format("_get_linkedin_recent_statistics"), + autospec=True, + return_value=dict( + RECENT_STATISTICS_LINKEDIN if statistics is None else statistics + ), + ) + + def test_get_linkedin_recent_statistics(self): + """The daily buckets are asked for, and the engagement is dropped.""" + buckets = { + "2026-08-07": (5, 0, 0, 0, 0.17, 29), + "2026-08-08": (0, 1, 0, 0, 0.0, 0), + } + with patch( + PATCH_ACCOUNT_LINKEDIN.format("_get_linkedin_chart_statistics"), + autospec=True, + return_value=buckets, + ) as mock_chart: + statistics = self.SocialAccountLinkedin._get_linkedin_recent_statistics() + self.assertEqual( + statistics, + {"2026-08-07": (5, 0, 0, 0, 29), "2026-08-08": (0, 1, 0, 0, 0)}, + msg="The engagement is a ratio of the others, so it is not watched.", + ) + start_time, end_time, granularity, freq = mock_chart.call_args.args[1:] + self.assertEqual(granularity, "DAY") + self.assertEqual(freq, "D") + self.assertEqual( + round((end_time - start_time) / (24 * 3600 * 1000)), + _UPDATE_CHECK_DAYS_LINKEDIN, + msg="The window has to be wider than the interval of the cron.", + ) + self.assertGreater( + end_time, + generate_timestamps(date_start=fields.Datetime.now())[0], + msg="LinkedIn takes the end of the interval as exclusive and " + "normalizes it to the day, so a range ending now would leave the " + "bucket of today out, the one bucket the check is after.", + ) + + def test_get_linkedin_recent_statistics_without_figures(self): + with patch( + PATCH_ACCOUNT_LINKEDIN.format("_get_linkedin_chart_statistics"), + autospec=True, + return_value={}, + ): + self.assertEqual( + self.SocialAccountLinkedin._get_linkedin_recent_statistics(), {} + ) + + def test_run_check_media_updates_takes_the_first_reading_as_the_mark(self): + """An account with no mark yet gets one, and announces nothing.""" + self._isolate_linkedin_account() + self.SocialAccountLinkedin.linkedin_statistics_checkpoint = False + with self._patch_recent_statistics(), patch( + PATCH_ACCOUNT_LINKEDIN.format("_get_posts"), autospec=True + ) as mock_get_posts: + self.assertFalse(self.SocialAccount._run_check_media_updates()) + self.assertEqual( + self.SocialAccountLinkedin.linkedin_statistics_checkpoint, + _linkedin_statistics_checkpoint(RECENT_STATISTICS_LINKEDIN), + ) + self.assertFalse(self.SocialAccountLinkedin.need_update) + mock_get_posts.assert_not_called() + + def test_run_check_media_updates_ignores_an_unusable_mark(self): + """A mark written by an older version is no baseline, and reseeds.""" + self._isolate_linkedin_account() + self.SocialAccountLinkedin.linkedin_statistics_checkpoint = ( + '{"clickCount": 25, "likeCount": 12}' + ) + with self._patch_recent_statistics(), patch( + PATCH_ACCOUNT_LINKEDIN.format("_get_posts"), autospec=True + ) as mock_get_posts: + self.assertFalse(self.SocialAccount._run_check_media_updates()) + self.assertEqual( + self.SocialAccountLinkedin.linkedin_statistics_checkpoint, + _linkedin_statistics_checkpoint(RECENT_STATISTICS_LINKEDIN), + ) + self.assertFalse(self.SocialAccountLinkedin.need_update) + mock_get_posts.assert_not_called() + + def test_run_check_media_updates_when_the_page_moved(self): + """A page that moved is enough: the feed is not even asked for.""" + self._isolate_linkedin_account() + self._mark_the_page_as_imported() + mark = self.SocialAccountLinkedin.linkedin_statistics_checkpoint + moved = {**RECENT_STATISTICS_LINKEDIN, "2026-08-08": (0, 2, 0, 0, 0)} + with self._patch_recent_statistics(moved), patch( + PATCH_ACCOUNT_LINKEDIN.format("_get_posts"), autospec=True + ) as mock_get_posts: + self.assertTrue(self.SocialAccount._run_check_media_updates()) + self.assertTrue(self.SocialAccountLinkedin.need_update) + mock_get_posts.assert_not_called() + self.assertEqual( + self.SocialAccountLinkedin.linkedin_statistics_checkpoint, + mark, + msg="The mark belongs to the import, so the notice does not " + "clear itself on the next run.", + ) + + def test_run_check_media_updates_notices_the_impressions_alone(self): + """Views with no interaction are an update too.""" + self._isolate_linkedin_account() + self._mark_the_page_as_imported() + moved = {**RECENT_STATISTICS_LINKEDIN, "2026-08-07": (5, 0, 0, 0, 30)} + with self._patch_recent_statistics(moved): + self.SocialAccount._run_check_media_updates() + self.assertTrue(self.SocialAccountLinkedin.need_update) + + def test_run_check_media_updates_notices_a_new_day_with_activity(self): + """A day LinkedIn had nothing for before, now carrying activity.""" + self._isolate_linkedin_account() + self._mark_the_page_as_imported() + moved = {**RECENT_STATISTICS_LINKEDIN, "2026-08-09": (0, 1, 0, 0, 0)} + with self._patch_recent_statistics(moved): + self.SocialAccount._run_check_media_updates() + self.assertTrue(self.SocialAccountLinkedin.need_update) + + def test_run_check_media_updates_ignores_an_empty_new_day(self): + """The day in progress starts as a bucket of zeros, not as news.""" + self._isolate_linkedin_account() + self._mark_the_page_as_imported() + quiet = {**RECENT_STATISTICS_LINKEDIN, "2026-08-09": (0, 0, 0, 0, 0)} + with self._patch_recent_statistics(quiet), patch( + PATCH_ACCOUNT_LINKEDIN.format("_get_posts"), + autospec=True, + return_value=[{"id": self.SocialPostAccountLinkedin.remote_ref}], + ): + self.assertFalse(self.SocialAccount._run_check_media_updates()) + self.assertFalse(self.SocialAccountLinkedin.need_update) + + def test_run_check_media_updates_ignores_a_day_that_aged_out(self): + """The window slides, so its oldest day leaving is not activity.""" + self._isolate_linkedin_account() + self._mark_the_page_as_imported() + slid = { + period: figures + for period, figures in RECENT_STATISTICS_LINKEDIN.items() + if period != "2026-08-06" + } + with self._patch_recent_statistics(slid), patch( + PATCH_ACCOUNT_LINKEDIN.format("_get_posts"), + autospec=True, + return_value=[{"id": self.SocialPostAccountLinkedin.remote_ref}], + ): + self.assertFalse(self.SocialAccount._run_check_media_updates()) + self.assertFalse(self.SocialAccountLinkedin.need_update) + + def test_run_check_media_updates_without_posts(self): + self._isolate_linkedin_account() + self._mark_the_page_as_imported() + with self._patch_recent_statistics(), patch( + PATCH_ACCOUNT_LINKEDIN.format("_get_posts"), + autospec=True, + return_value=[], + ) as mock_get_posts: + self.assertFalse(self.SocialAccount._run_check_media_updates()) + self.assertTrue(mock_get_posts.called) + self.assertFalse(self.SocialAccountLinkedin.need_update) + + def test_run_check_media_updates_with_a_known_post(self): + """Same figures and nothing new published: nothing to announce.""" + self._isolate_linkedin_account() + self._mark_the_page_as_imported() + with self._patch_recent_statistics(), patch( + PATCH_ACCOUNT_LINKEDIN.format("_get_posts"), + autospec=True, + return_value=[{"id": self.SocialPostAccountLinkedin.remote_ref}], + ): + self.assertFalse(self.SocialAccount._run_check_media_updates()) + self.assertFalse(self.SocialAccountLinkedin.need_update) + + def test_run_check_media_updates_sees_an_archived_publication(self): + """Archiving a post does not make its publication new again.""" + self._isolate_linkedin_account() + self._mark_the_page_as_imported() + self.SocialPostAccountLinkedin.active = False + with self._patch_recent_statistics(), patch( + PATCH_ACCOUNT_LINKEDIN.format("_get_posts"), + autospec=True, + return_value=[ + { + "id": self.SocialPostAccountLinkedin.with_context( + active_test=False + ).remote_ref + } + ], + ): + self.assertFalse(self.SocialAccount._run_check_media_updates()) + self.assertFalse(self.SocialAccountLinkedin.need_update) + + def test_run_check_media_updates_with_unknown_post(self): + """A publication posted outside Odoo moves no figure of its own.""" + self._isolate_linkedin_account() + self._mark_the_page_as_imported() + with self._patch_recent_statistics(), patch( + PATCH_ACCOUNT_LINKEDIN.format("_get_posts"), + autospec=True, + return_value=[{"id": "urn:li:share:not-imported-yet"}], + ): + self.SocialAccount._run_check_media_updates() + self.assertTrue(self.SocialAccountLinkedin.need_update) + + def test_run_check_media_updates_skips_a_flagged_account(self): + """An account already announcing updates costs no call at all.""" + self._isolate_linkedin_account() + self._mark_the_page_as_imported() + self.SocialAccountLinkedin.need_update = True + with self._patch_recent_statistics() as mock_statistics, patch( + PATCH_ACCOUNT_LINKEDIN.format("_get_posts"), autospec=True + ) as mock_get_posts: + self.assertFalse(self.SocialAccount._run_check_media_updates()) + mock_statistics.assert_not_called() + mock_get_posts.assert_not_called() + + def test_run_check_media_updates_skips_an_account_without_organization(self): + """The feed of an account with no organization cannot be asked for.""" + self._isolate_linkedin_account() + self._mark_the_page_as_imported() + self.SocialAccountLinkedin.remote_ref = False + self.assertFalse(self.SocialAccountLinkedin.linkedin_account_id) + with self._patch_recent_statistics() as mock_statistics, patch( + PATCH_ACCOUNT_LINKEDIN.format("_get_posts"), autospec=True + ) as mock_get_posts: + self.assertFalse(self.SocialAccount._run_check_media_updates()) + mock_statistics.assert_not_called() + mock_get_posts.assert_not_called() + + def test_run_check_media_updates_scans_every_account(self): + """A flagged account must not stop the scan of the remaining ones.""" + self._isolate_linkedin_account() + other_account = self.SocialAccountLinkedin.copy( + { + "name": "Other LinkedIn", + "username": "other-linkedin", + "remote_ref": "urn:li:organization:other", + } + ) + self._mark_the_page_as_imported(self.SocialAccountLinkedin | other_account) + moved = {**RECENT_STATISTICS_LINKEDIN, "2026-08-08": (0, 2, 0, 0, 0)} + with self._patch_recent_statistics(moved): + self.SocialAccount._run_check_media_updates() + self.assertTrue(self.SocialAccountLinkedin.need_update) + self.assertTrue(other_account.need_update) + + @mute_logger(LOGGER_ACCOUNT_LINKEDIN) + def test_run_check_media_updates_isolates_each_account(self): + """The account LinkedIn refused must not hide the others.""" + self._isolate_linkedin_account() + failing = self.SocialAccountLinkedin + working = failing.copy( + { + "name": "Other LinkedIn", + "username": "other-linkedin", + "remote_ref": "urn:li:organization:other", + } + ) + self._mark_the_page_as_imported(failing | working) + + def recent_statistics(account): + if account.id == failing.id: + raise UserError(_("LinkedIn refused the page statistics")) + return {**RECENT_STATISTICS_LINKEDIN, "2026-08-08": (0, 2, 0, 0, 0)} + + with self._patch_recent_statistics(side_effect=recent_statistics): + self.assertTrue(self.SocialAccount._run_check_media_updates()) + self.assertFalse(failing.need_update) + self.assertTrue(working.need_update) + + @mute_logger(LOGGER_ACCOUNT_LINKEDIN) + def test_run_check_media_updates_reraises_a_concurrency_error(self): + """Odoo keeps its retry: neither handler may swallow the error. + + The inner one re-raises it and the outer one has to let it through, + because a cron gets no retry of its own. + """ + + class ConcurrencyError(psycopg2.OperationalError): + pgcode = errorcodes.SERIALIZATION_FAILURE + + self._isolate_linkedin_account() + self._mark_the_page_as_imported() + with self._patch_recent_statistics( + side_effect=ConcurrencyError("serialization conflict") + ): + with self.assertRaises(psycopg2.OperationalError): + self.SocialAccount._run_check_media_updates() + + def test_run_check_media_updates_notifies_the_responsible_user(self): + self._isolate_linkedin_account() + self._mark_the_page_as_imported() + Bus = self.env["bus.bus"] + with self._patch_recent_statistics(), patch( + PATCH_ACCOUNT_LINKEDIN.format("_get_posts"), + autospec=True, + return_value=[{"id": "urn:li:share:not-imported-yet"}], + ), patch.object(type(Bus), "_sendone", autospec=True) as patch_sendone: + self.SocialAccount._run_check_media_updates() + patch_sendone.assert_called_once() + self.assertEqual( + patch_sendone.call_args[0][1], + self.SocialAccountLinkedin.user_id.partner_id, + ) + + def test_flag_linkedin_update_is_idempotent(self): + """The bus message is not pushed again for what is already announced.""" + self.SocialAccountLinkedin.need_update = True + with patch( + PATCH_ACCOUNT.format("_need_update"), autospec=True + ) as mock_need_update: + self.SocialAccountLinkedin._flag_linkedin_update() + mock_need_update.assert_not_called() + + @mute_logger(LOGGER_ACCOUNT_LINKEDIN) + def test_run_check_media_updates_exception(self): + self._isolate_linkedin_account() + self._mark_the_page_as_imported() + with self._patch_recent_statistics(), patch( + PATCH_ACCOUNT_LINKEDIN.format("_get_posts"), + autospec=True, + side_effect=Exception("Error Check Media Updates"), + ): + self.assertFalse(self.SocialAccount._run_check_media_updates()) + self.assertFalse(self.SocialAccountLinkedin.need_update) 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..7384cde04f --- /dev/null +++ b/social_media_linkedin/tests/test_common_linkedin.py @@ -0,0 +1,138 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +import base64 +from unittest.mock import patch + +from odoo.fields import Command +from odoo.tools import hmac + +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.{}" +) + + +class LinkedinMockMixin: + def _mock_linkedin(self, return_value, account, attribute="_request_linkedin"): + return patch.object(type(account), attribute, return_value=return_value) + + +class TestSocialCommonLinkedin(LinkedinMockMixin, 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, + "remote_ref": "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, + "remote_ref": "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, + "remote_ref": "1234567890", + "state": "posted", + } + + cls.SocialPostAccountLinkedin = cls.SocialPostAccount.create(post_account) + + post_account.update( + { + "state": "ready", + "remote_ref": False, + } + ) + cls.SocialPostAccountReadyLinkedin = cls.SocialPostAccount.create(post_account) + + 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(), + "res_model": "social.post.account", + "res_id": self.SocialPostAccountLinkedin.id, + } + ) + + 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..c96c6eb834 --- /dev/null +++ b/social_media_linkedin/tests/test_controller.py @@ -0,0 +1,159 @@ +# Copyright 2025 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from unittest.mock import patch + +from odoo.exceptions import UserError +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(self): + super().setUp() + self.controller = SocialMediaLinkedin() + self.authenticate("admin", "admin") + + def test_callback_with_access_token_still_requires_exchange(self): + token = "ACCESS_TOKEN" + with patch( + PATCH_ACCOUNT_LINKEDIN.format("_get_access_token_linkedin"), + autospec=True, + return_value=("CID", "CSEC", "EXCHANGED_TOKEN"), + ) 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_called_once() + mocked_create.assert_called_once() + _, args, _kwargs = mocked_create.mock_calls[0] + self.assertEqual(args[1], "CID") + self.assertEqual(args[2], "CSEC") + self.assertEqual(args[3], "EXCHANGED_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_success_lands_on_the_dashboard(self): + """The posts are imported right after the association, so the user + has to land where they are shown.""" + with patch( + PATCH_ACCOUNT_LINKEDIN.format("_get_access_token_linkedin"), + autospec=True, + return_value=("CID", "CSEC", "TOKEN"), + ), patch( + PATCH_ACCOUNT_LINKEDIN.format("_create_account_linkedin"), + autospec=True, + ): + resp = self.url_open("/linkedin/callback?code=ANY", allow_redirects=False) + + self.assertTrue( + resp.headers.get("Location", "").endswith( + self.env["social.account"]._get_social_dashboard_url() + ) + ) + + def test_callback_success_notifies_user(self): + """The association answers a redirect, so it reports through the session.""" + with patch( + PATCH_ACCOUNT_LINKEDIN.format("_get_access_token_linkedin"), + autospec=True, + return_value=("CID", "CSEC", "TOKEN"), + ), patch( + PATCH_ACCOUNT_LINKEDIN.format("_create_account_linkedin"), + autospec=True, + ), patch( + PATCH_SOCIAL_BASE_MIXIN.format("_notify_user_session"), + autospec=True, + ) as mocked_notify: + resp = self.url_open("/linkedin/callback?code=ANY") + + mocked_notify.assert_called_once() + _, args, kwargs = mocked_notify.mock_calls[0] + self.assertIn("associated successfully", args[1]) + self.assertNotIn("ERROR:", args[1]) + self.assertEqual(kwargs["message_type"], "success") + self.assertEqual(resp.status_code, 200) + + def test_callback_user_error_reaches_the_user(self): + """A UserError explains what to fix, so it must not be hidden.""" + message = "The account Binhex belongs to another company." + with patch( + PATCH_ACCOUNT_LINKEDIN.format("_get_access_token_linkedin"), + autospec=True, + side_effect=UserError(message), + ), patch( + PATCH_SOCIAL_BASE_MIXIN.format("_notify_user_session"), + 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.assertIn("Social Media LINKEDIN", args[1]) + self.assertIn(message, args[1]) + mocked_logger.warning.assert_called_once() + mocked_logger.exception.assert_not_called() + 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_session"), + 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.assertIn("Social Media LINKEDIN", args[1]) + self.assertIn("could not be associated", args[1]) + # The provider detail must never reach the user. + self.assertNotIn("boom", args[1]) + mocked_logger.exception.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..9374465909 --- /dev/null +++ b/social_media_linkedin/tests/test_media_linkedin.py @@ -0,0 +1,63 @@ +# 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 + + +class TestSocialMediaLinkedin(TestSocialCommonLinkedin): + 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_action_open_account_media_linkedin(self): + action = self.media_linkedin_id.action_open_account() + self.valid_action_open_account_media(self.media_linkedin_id, action) + + def test_not_action_open_account_media_linkedin(self): + self.valid_not_action_open_account_media() + + def test_linkedin_media_reports_the_linkedin_medium(self): + self.assertEqual( + self.media_linkedin_id._get_utm_medium(), + self.env.ref("utm.utm_medium_linkedin"), + ) + + def test_a_configured_medium_wins_over_the_linkedin_one(self): + medium = self.env["utm.medium"].create({"name": "Configured medium"}) + self.media_linkedin_id.utm_medium_id = medium + self.assertEqual(self.media_linkedin_id._get_utm_medium(), medium) 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..03d568ea7a --- /dev/null +++ b/social_media_linkedin/tests/test_post_linkedin.py @@ -0,0 +1,1081 @@ +# 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 UserError +from odoo.tools import mute_logger + +from odoo.addons.social_media_base.exceptions import SocialCredentialsError +from odoo.addons.social_media_base.tests.test_social_common import ( + PATCH_POST_ACCOUNT, +) +from odoo.addons.social_media_linkedin.tests.test_common_linkedin import ( + PATCH_ACCOUNT_LINKEDIN, + PATCH_POST_ACCOUNT_LINKEDIN, + TestSocialCommonLinkedin, +) + +LOGGER_POST_ACCOUNT_LINKEDIN = ( + "odoo.addons.social_media_linkedin.models.social_post_account" +) +LOGGER_POST_ACCOUNT_BASE = "odoo.addons.social_media_base.models.social_post_account" + + +class TestSocialPostLinkedin(TestSocialCommonLinkedin): + @patch("odoo.addons.social_media_base.models.social_post_account.requests.get") + def test_get_assets_save(self, mock_get): + """Only the images that are not stored yet are downloaded.""" + 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 + self.create_attachment(attach_name="urn:li:image:exists") + content = { + "multiImage": { + "images": [ + {"id": "urn:li:image:new"}, + {"id": "urn:li:image:exists"}, + ] + } + } + images_response = MagicMock() + images_response.status_code = 200 + images_response.json.return_value = { + "results": {"urn:li:image:new": {"downloadUrl": "https://fake-url/new.jpg"}} + } + with patch.object( + type(self.SocialAccountLinkedin), + "_request_linkedin", + return_value=images_response, + ) as mock_request_linkedin: + attachments = self.SocialPostAccountLinkedin._get_assets_save(content) + self.assertEqual(len(attachments), 1) + self.assertEqual(attachments[0][2]["name"], "urn:li:image:new") + self.assertEqual(attachments[0][2]["datas"], base64.b64encode(fake_content)) + self.assertEqual( + mock_request_linkedin.call_args.kwargs["params_values"]["ids"], + ["urn:li:image:new"], + ) + + def test_remove_assets_deleted_drops_the_images_gone_from_linkedin(self): + """An image deleted on LinkedIn leaves the publication as well.""" + kept = self.create_attachment(attach_name="urn:li:image:kept") + gone = self.create_attachment(attach_name="urn:li:image:gone") + self.SocialPostAccountLinkedin.write( + {"image_ids": [Command.set((kept | gone).ids)]} + ) + content = {"multiImage": {"images": [{"id": "urn:li:image:kept"}]}} + removed = self.SocialPostAccountLinkedin._remove_assets_deleted(content) + self.assertEqual(self.SocialPostAccountLinkedin.image_ids, kept) + self.assertEqual(len(removed), 1) + self.assertFalse(gone.exists()) + + def test_remove_assets_deleted_keeps_the_manual_attachments(self): + """Only the medias named after a LinkedIn URN are managed here.""" + manual = self.create_attachment(attach_name="holidays.jpg") + self.SocialPostAccountLinkedin.write({"image_ids": [Command.set(manual.ids)]}) + self.assertFalse(self.SocialPostAccountLinkedin._remove_assets_deleted({})) + self.assertEqual(self.SocialPostAccountLinkedin.image_ids, manual) + self.assertTrue(manual.exists()) + + def test_remove_assets_deleted_drops_everything_without_remote_media(self): + stored = self.create_attachment(attach_name="urn:li:image:stored") + self.SocialPostAccountLinkedin.write({"image_ids": [Command.set(stored.ids)]}) + self.SocialPostAccountLinkedin._remove_assets_deleted({}) + self.assertFalse(self.SocialPostAccountLinkedin.image_ids) + self.assertFalse(stored.exists()) + + def test_remove_assets_deleted_keeps_the_images_still_online(self): + stored = self.create_attachment(attach_name="urn:li:image:stored") + self.SocialPostAccountLinkedin.write({"image_ids": [Command.set(stored.ids)]}) + content = {"media": {"id": "urn:li:image:stored"}} + self.assertFalse(self.SocialPostAccountLinkedin._remove_assets_deleted(content)) + self.assertEqual(self.SocialPostAccountLinkedin.image_ids, stored) + + def test_get_assets_save_single_image(self): + """The image of a post with a single media is resolved as well.""" + content = {"media": {"id": "urn:li:image:single"}} + with patch.object( + type(self.SocialAccountLinkedin), + "_get_linkedin_images_download_url", + return_value={}, + ) as mock_download_url: + self.assertEqual( + self.SocialPostAccountLinkedin._get_assets_save(content), [] + ) + mock_download_url.assert_called_once_with(["urn:li:image:single"]) + + def test_get_assets_save_without_images(self): + """A post with a video or without media does not ask for any image.""" + with patch.object( + type(self.SocialAccountLinkedin), + "_get_linkedin_images_download_url", + ) as mock_download_url: + self.assertEqual( + self.SocialPostAccountLinkedin._get_assets_save( + {"media": {"id": "urn:li:video:1"}} + ), + [], + ) + self.assertEqual(self.SocialPostAccountLinkedin._get_assets_save({}), []) + mock_download_url.assert_not_called() + + @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.text = '{"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_without_last_modified(self, mock_request): + """LinkedIn may answer a comment without its modification data.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "elements": [ + { + "id": "comment1", + "message": {"text": "Great post!"}, + "content": [], + } + ] + } + mock_request.return_value = mock_response + result = self.SocialPostAccountLinkedin.get_comments() + self.assertTrue(result["success"]) + self.assertEqual(result["data"][0]["actor"], {}) + + @mute_logger(LOGGER_POST_ACCOUNT_LINKEDIN) + @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("comments could not be read from LinkedIn", result["message"]) + + @mute_logger(LOGGER_POST_ACCOUNT_LINKEDIN) + @patch(PATCH_ACCOUNT_LINKEDIN.format("_request_linkedin")) + @patch(PATCH_ACCOUNT_LINKEDIN.format("_linkedin_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_request.return_value = mock_response + post_data.update({"attachment_ids": []}) + result = self.SocialPostAccountLinkedin._create_linkedin_comment(post_data) + self.assertFalse(result["success"]) + self.assertIn("comment could not be published on 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_check_remote_post_exists(self, mock_request): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_request.return_value = mock_response + self.assertTrue(self.SocialPostAccountLinkedin.check_post_exists()) + + @patch(PATCH_ACCOUNT_LINKEDIN.format("_request_linkedin")) + def test_check_remote_post_exists_deleted(self, mock_request): + """A 404 is the only answer that means the post is gone.""" + remote_ref = self.SocialPostAccountLinkedin.remote_ref + mock_response = MagicMock() + mock_response.status_code = 404 + mock_request.return_value = mock_response + self.assertFalse(self.SocialPostAccountLinkedin.check_post_exists()) + self.assertEqual(self.SocialPostAccountLinkedin.state, "deleted") + self.assertFalse(self.SocialPostAccountLinkedin.post_account_url) + self.assertEqual(self.SocialPostAccountLinkedin.remote_ref, remote_ref) + + @patch(PATCH_ACCOUNT_LINKEDIN.format("_request_linkedin")) + def test_check_remote_post_exists_forbidden(self, mock_request): + """A lost permission is not a deletion: nothing may be written.""" + post_account = self.SocialPostAccountLinkedin + post_account.write({"state": "posted"}) + remote_ref = post_account.remote_ref + post_account_url = post_account.post_account_url + mock_response = MagicMock() + mock_response.status_code = 403 + mock_request.return_value = mock_response + with mute_logger(LOGGER_POST_ACCOUNT_LINKEDIN): + self.assertTrue(post_account.check_post_exists()) + self.assertEqual(post_account.state, "posted") + self.assertEqual(post_account.remote_ref, remote_ref) + self.assertEqual(post_account.post_account_url, post_account_url) + + @patch(PATCH_ACCOUNT_LINKEDIN.format("_request_linkedin")) + def test_check_remote_post_exists_unreachable(self, mock_request): + """LinkedIn out of reach leaves the publication untouched.""" + post_account = self.SocialPostAccountLinkedin + post_account.write({"state": "posted"}) + mock_request.side_effect = UserError("boom") + with mute_logger(LOGGER_POST_ACCOUNT_LINKEDIN): + self.assertTrue(post_account.check_post_exists()) + self.assertEqual(post_account.state, "posted") + + 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_compute_message_info_recomputed_on_media_change(self): + 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) + + post.video_ids = [Command.set([self.create_attachment("test_video.mp4").id])] + self.assertIn( + "You have selected images and videos for this post", + post.message_info, + ) + + post.image_ids = [Command.clear()] + self.assertFalse(post.message_info) + + def test_compute_message_info_video_wins_over_the_images(self): + """The warning must say what the connector really publishes.""" + post = 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.assertIn("only the video will be published", post.message_info) + + def test_post_preview_video_wins_over_the_images(self): + """The preview must show what LinkedIn publishes, not the rest.""" + image = self.create_attachment("preview_image.jpg") + video = self.create_attachment("preview_video.mp4") + post = self.SocialPost.create( + { + "message": self.test_message, + "account_ids": [Command.set(self.SocialAccountLinkedin.ids)], + "image_ids": [Command.set([image.id])], + "video_ids": [Command.set([video.id])], + } + ) + self.assertNotIn(f"/web/image/{image.id}", post.post_preview) + self.assertIn("preview_video.mp4", post.post_preview) + + def test_post_preview_keeps_the_images_without_a_video(self): + image = self.create_attachment("preview_image.jpg") + post = self.SocialPost.create( + { + "message": self.test_message, + "account_ids": [Command.set(self.SocialAccountLinkedin.ids)], + "image_ids": [Command.set([image.id])], + } + ) + self.assertIn(f"/web/image/{image.id}", post.post_preview) + + 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.assertFalse(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(UserError): + self.SocialPostAccountLinkedin._delete_post_account() + self.assertEqual(mock_request_linkedin.call_count, 2) + + @patch(PATCH_ACCOUNT_LINKEDIN.format("_request_linkedin")) + def test_delete_post_account_refreshes_the_token_first(self, mock_request_linkedin): + """A 401 here would be reported as an undeletable post.""" + mock_request_linkedin.return_value = MagicMock(status_code=204) + with patch.object( + type(self.SocialAccount), "validate_access_token", autospec=True + ) as mock_validate: + self.SocialPostAccountLinkedin._delete_post_account() + mock_validate.assert_called_once() + self.assertTrue( + mock_validate.call_args.args[0].env.context.get("not_notify"), + msg="Deleting a publication must not report that the token is valid.", + ) + + def test_action_post(self): + self.SocialPostAccountLinkedin.write({"state": "ready"}) + post_account_urn = "urn:li:share:122809890045" + attachment = self.env["ir.attachment"].create( + {"name": "fake-asset.png", "datas": self.image_base64} + ) + fake_response = [ + { + "id": post_account_urn, + "content": {"media": {"id": "urn:li:image:1"}}, + } + ] + 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), + "_linkedin_create_post", + autospec=True, + return_value=(post_account_urn, []), + ) as mock_linkedin_create_post, 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=[attachment.id], + ) as mock_get_assets_save: + self.SocialPostAccountLinkedin._action_post(self.SocialPostLinkedin) + self.assertEqual( + self.SocialPostAccountLinkedin.remote_ref, + post_account_urn, + ) + self.assertEqual(self.SocialPostAccountLinkedin.state, "posted") + self.assertFalse(self.SocialPostAccountLinkedin.has_video) + self.assertEqual( + self.SocialPostAccountLinkedin.post_account_url, + f"https://www.linkedin.com/feed/update/{post_account_urn}", + ) + mock_filter_by_media_types.assert_called_once() + mock_linkedin_create_post.assert_called_once() + mock_get_posts.assert_called_once() + mock_get_assets_save.assert_called_once() + + def test_action_post_video_sets_has_video(self): + self.SocialPostAccountLinkedin.write({"state": "ready", "remote_ref": False}) + self.SocialPostLinkedin.write( + {"video_ids": [Command.set([self.create_attachment("test_video.mp4").id])]} + ) + post_account_urn = "urn:li:ugcPost:122809890045" + fake_response = [ + { + "id": post_account_urn, + "content": {"media": {"id": "urn:li:image:1"}}, + } + ] + with patch.object( + type(self.SocialPostLinkedin), + "_filter_by_media_types", + autospec=True, + return_value=self.SocialPostAccountLinkedin, + ), patch.object( + type(self.SocialPostAccountLinkedin.account_id), + "_linkedin_create_post", + autospec=True, + return_value=(post_account_urn, []), + ), patch.object( + type(self.SocialPostAccountLinkedin.account_id), + "_get_posts", + autospec=True, + return_value=fake_response, + ), patch.object( + type(self.SocialPostAccountLinkedin), + "_get_assets_save", + autospec=True, + return_value=[], + ): + self.SocialPostAccountLinkedin._action_post(self.SocialPostLinkedin) + self.assertEqual(self.SocialPostAccountLinkedin.state, "posted") + self.assertTrue(self.SocialPostAccountLinkedin.has_video) + + @mute_logger(LOGGER_POST_ACCOUNT_BASE) + def test_action_post_with_several_videos_is_refused(self): + """The check runs before uploading anything to LinkedIn.""" + self.SocialPostAccountLinkedin.write({"state": "ready", "remote_ref": False}) + self.SocialPostLinkedin.write( + { + "video_ids": [ + Command.set( + [ + self.create_attachment("one.mp4").id, + self.create_attachment("two.mp4").id, + ] + ) + ] + } + ) + with patch.object( + type(self.SocialPostLinkedin), + "_filter_by_media_types", + autospec=True, + return_value=self.SocialPostAccountLinkedin, + ), patch.object( + type(self.SocialPostAccountLinkedin.account_id), + "_linkedin_prepare_videos_for_post", + autospec=True, + ) as mock_prepare_videos, patch.object( + type(self.SocialPostAccountLinkedin.account_id), + "_linkedin_create_post", + autospec=True, + ) as mock_linkedin_create_post: + self.SocialPostAccountLinkedin._action_post(self.SocialPostLinkedin) + mock_prepare_videos.assert_not_called() + mock_linkedin_create_post.assert_not_called() + self.assertEqual(self.SocialPostAccountLinkedin.state, "failed") + self.assertIn( + "single video per post", self.SocialPostAccountLinkedin.failed_description + ) + + def test_check_linkedin_post_format_accepts_a_gif(self): + """The Images API takes GIF, so nothing may refuse it here.""" + self.SocialPostAccountLinkedin.write({"state": "ready", "remote_ref": False}) + self.SocialPostLinkedin.write( + {"image_ids": [Command.set([self.create_attachment("animation.gif").id])]} + ) + self.SocialPostAccountLinkedin._check_linkedin_post_format() + self.assertNotIn("GIF", self.SocialPostLinkedin.message_info or "") + + def test_check_linkedin_post_format_rejects_a_mov_video(self): + """The Videos API only takes MP4, whatever the file dialog let through.""" + self.SocialPostAccountLinkedin.write({"state": "ready", "remote_ref": False}) + self.SocialPostLinkedin.write( + {"video_ids": [Command.set([self.create_attachment("holidays.mov").id])]} + ) + with self.assertRaises(UserError): + self.SocialPostAccountLinkedin._check_linkedin_post_format() + self.assertIn("MP4 videos", self.SocialPostLinkedin.message_info) + + def test_check_linkedin_post_format_ignores_the_images_when_there_is_a_video(self): + """A video drops the images, so their format decides nothing.""" + self.SocialPostAccountLinkedin.write({"state": "ready", "remote_ref": False}) + self.SocialPostLinkedin.write( + { + "image_ids": [Command.set([self.create_attachment("picture.webp").id])], + "video_ids": [Command.set([self.create_attachment("clip.mp4").id])], + } + ) + self.SocialPostAccountLinkedin._check_linkedin_post_format() + self.assertNotIn("JPG, PNG and GIF", self.SocialPostLinkedin.message_info) + + def test_check_linkedin_post_format_rejects_an_unsupported_image(self): + self.SocialPostAccountLinkedin.write({"state": "ready", "remote_ref": False}) + self.SocialPostLinkedin.write( + {"image_ids": [Command.set([self.create_attachment("picture.webp").id])]} + ) + with self.assertRaises(UserError): + self.SocialPostAccountLinkedin._check_linkedin_post_format() + self.assertIn("JPG, PNG and GIF", self.SocialPostLinkedin.message_info) + + def test_action_post_fails_only_the_line_with_the_wrong_format(self): + """The format is checked before anything is uploaded to LinkedIn.""" + self.SocialPostAccountLinkedin.write({"state": "ready", "remote_ref": False}) + self.SocialPostLinkedin.write( + {"video_ids": [Command.set([self.create_attachment("holidays.mov").id])]} + ) + with patch.object( + type(self.SocialPostLinkedin), + "_filter_by_media_types", + autospec=True, + return_value=self.SocialPostAccountLinkedin, + ), patch.object( + type(self.SocialPostAccountLinkedin.account_id), + "_linkedin_create_post", + autospec=True, + ) as mock_linkedin_create_post: + self.SocialPostAccountLinkedin._action_post(self.SocialPostLinkedin) + mock_linkedin_create_post.assert_not_called() + self.assertEqual(self.SocialPostAccountLinkedin.state, "failed") + self.assertIn("MP4 videos", self.SocialPostAccountLinkedin.failed_description) + + def test_action_post_keeps_the_upload_order_of_the_images(self): + """LinkedIn draws the images in the order it receives them.""" + self.SocialPostAccountLinkedin.write({"state": "ready", "remote_ref": False}) + images = self.env["ir.attachment"].create( + [ + { + "name": f"image_{number}.jpg", + "type": "binary", + "datas": self.VALID_PNG_B64, + } + for number in range(3) + ] + ) + self.SocialPostLinkedin.write({"image_ids": [Command.set(images.ids)]}) + # Read back from database: a many2many follows the ``id desc`` order + # of ``ir.attachment``, which is the order the cron would publish. + self.SocialPostLinkedin.invalidate_recordset() + with patch.object( + type(self.SocialPostLinkedin), + "_filter_by_media_types", + autospec=True, + return_value=self.SocialPostAccountLinkedin, + ), patch.object( + type(self.SocialPostAccountLinkedin.account_id), + "_linkedin_create_post", + autospec=True, + return_value=("urn:li:share:1", []), + ) as mock_linkedin_create_post, patch.object( + type(self.SocialPostAccountLinkedin), + "_linkedin_enrich_published_post", + autospec=True, + ): + self.SocialPostAccountLinkedin._action_post(self.SocialPostLinkedin) + self.assertEqual( + list(mock_linkedin_create_post.call_args.kwargs["image_ids"].ids), + images.ids, + ) + + def test_compute_message_info_several_videos(self): + post = self.SocialPost.create( + { + "message": self.test_message, + "account_ids": [Command.set(self.SocialAccountLinkedin.ids)], + "video_ids": [ + Command.set( + [ + self.create_attachment("one.mp4").id, + self.create_attachment("two.mp4").id, + ] + ) + ], + } + ) + self.assertIn("single video per post", post.message_info) + + @mute_logger(LOGGER_POST_ACCOUNT_BASE) + def test_action_post_isolates_the_failing_account(self): + """One account failing must not undo the one that did publish.""" + post_account_urn = "urn:li:share:122809890046" + second_account = self.SocialAccountLinkedin.copy( + {"name": "Second LinkedIn account", "remote_ref": "urn:li:organization:2"} + ) + second_post_account = self.SocialPostAccountLinkedin.copy( + {"account_id": second_account.id, "state": "ready", "remote_ref": False} + ) + self.SocialPostAccountLinkedin.write({"state": "ready", "remote_ref": False}) + post_accounts = self.SocialPostAccountLinkedin | second_post_account + with patch.object( + type(self.SocialPostLinkedin), + "_filter_by_media_types", + autospec=True, + return_value=post_accounts, + ), patch.object( + type(self.SocialPostAccountLinkedin.account_id), + "_linkedin_create_post", + autospec=True, + side_effect=[ + (post_account_urn, []), + UserError("LinkedIn refused the post"), + ], + ), patch.object( + type(self.SocialPostAccountLinkedin.account_id), + "_get_posts", + autospec=True, + return_value=[], + ): + self.SocialPostAccountLinkedin._action_post(self.SocialPostLinkedin) + self.assertEqual(self.SocialPostAccountLinkedin.state, "posted") + self.assertEqual(self.SocialPostAccountLinkedin.remote_ref, post_account_urn) + self.assertEqual(second_post_account.state, "failed") + self.assertIn( + "LinkedIn refused the post", second_post_account.failed_description + ) + self.assertFalse(second_post_account.remote_ref) + + @mute_logger(LOGGER_POST_ACCOUNT_BASE) + def test_check_linkedin_publishable_fails_only_its_own_line(self): + """The extension point refusing a publication stops that one alone.""" + post_account_urn = "urn:li:share:122809890060" + second_account = self.SocialAccountLinkedin.copy( + {"name": "Second LinkedIn account", "remote_ref": "urn:li:organization:3"} + ) + second_post_account = self.SocialPostAccountLinkedin.copy( + {"account_id": second_account.id, "state": "ready", "remote_ref": False} + ) + self.SocialPostAccountLinkedin.write({"state": "ready", "remote_ref": False}) + post_accounts = self.SocialPostAccountLinkedin | second_post_account + refusal = "The extension refused this publication" + + def refuse_the_second(post_account): + if post_account == second_post_account: + raise UserError(refusal) + + with patch.object( + type(self.SocialPostLinkedin), + "_filter_by_media_types", + autospec=True, + return_value=post_accounts, + ), patch.object( + type(self.SocialPostAccountLinkedin), + "_check_linkedin_publishable", + autospec=True, + side_effect=refuse_the_second, + ), patch.object( + type(self.SocialPostAccountLinkedin.account_id), + "_linkedin_create_post", + autospec=True, + return_value=(post_account_urn, []), + ), patch.object( + type(self.SocialPostAccountLinkedin.account_id), + "_get_posts", + autospec=True, + return_value=[], + ): + self.SocialPostAccountLinkedin._action_post(self.SocialPostLinkedin) + self.assertEqual(self.SocialPostAccountLinkedin.state, "posted") + self.assertEqual(self.SocialPostAccountLinkedin.remote_ref, post_account_urn) + self.assertEqual(second_post_account.state, "failed") + self.assertFalse(second_post_account.remote_ref) + self.assertIn(refusal, second_post_account.failed_description) + + def test_linkedin_published_values_are_written(self): + """What the extension point returns is stored with the publication.""" + post_account_urn = "urn:li:share:122809890061" + self.SocialPostAccountLinkedin.write({"state": "ready", "remote_ref": False}) + with patch.object( + type(self.SocialPostLinkedin), + "_filter_by_media_types", + autospec=True, + return_value=self.SocialPostAccountLinkedin, + ), patch.object( + type(self.SocialPostAccountLinkedin), + "_linkedin_published_values", + autospec=True, + return_value={"message": "Written by the extension"}, + ), patch.object( + type(self.SocialPostAccountLinkedin.account_id), + "_linkedin_create_post", + autospec=True, + return_value=(post_account_urn, []), + ), patch.object( + type(self.SocialPostAccountLinkedin.account_id), + "_get_posts", + autospec=True, + return_value=[], + ): + self.SocialPostAccountLinkedin._action_post(self.SocialPostLinkedin) + self.assertEqual(self.SocialPostAccountLinkedin.remote_ref, post_account_urn) + self.assertEqual( + self.SocialPostAccountLinkedin.message, "Written by the extension" + ) + + @mute_logger(LOGGER_POST_ACCOUNT_BASE) + def test_linkedin_published_values_failing_loses_the_publication(self): + """An error of the extension point undoes a post that is already online. + + This pins what the code does today, which is not what + :meth:`_linkedin_enrich_published_post` documents: the call to + _linkedin_published_values sits outside its try, so the error reaches + _publish_guard and the savepoint takes the remote reference of a + published post with it. Deferred as LNK-28 of the OCA review, since + moving the call inside the try changes what is written. + """ + post_account_urn = "urn:li:share:122809890062" + self.SocialPostAccountLinkedin.write({"state": "ready", "remote_ref": False}) + with patch.object( + type(self.SocialPostLinkedin), + "_filter_by_media_types", + autospec=True, + return_value=self.SocialPostAccountLinkedin, + ), patch.object( + type(self.SocialPostAccountLinkedin), + "_linkedin_published_values", + autospec=True, + side_effect=ValueError("The extension broke"), + ), patch.object( + type(self.SocialPostAccountLinkedin.account_id), + "_linkedin_create_post", + autospec=True, + return_value=(post_account_urn, []), + ), patch.object( + type(self.SocialPostAccountLinkedin.account_id), + "_get_posts", + autospec=True, + return_value=[], + ): + self.SocialPostAccountLinkedin._action_post(self.SocialPostLinkedin) + self.assertEqual(self.SocialPostAccountLinkedin.state, "failed") + self.assertFalse(self.SocialPostAccountLinkedin.remote_ref) + + def test_action_post_renews_the_token_and_publishes_again(self): + """A token refused at the last moment is renewed and the post goes out.""" + post_account_urn = "urn:li:share:122809890050" + self.SocialPostAccountLinkedin.write({"state": "ready", "remote_ref": False}) + with patch.object( + type(self.SocialPostLinkedin), + "_filter_by_media_types", + autospec=True, + return_value=self.SocialPostAccountLinkedin, + ), patch.object( + type(self.SocialPostAccountLinkedin.account_id), + "_linkedin_create_post", + autospec=True, + side_effect=[ + SocialCredentialsError("The access token expired"), + (post_account_urn, []), + ], + ) as mock_create_post, patch.object( + type(self.SocialPostAccountLinkedin.account_id), + "_refresh_credentials", + autospec=True, + return_value=True, + ) as mock_refresh, patch.object( + type(self.SocialPostAccountLinkedin.account_id), + "_get_posts", + autospec=True, + return_value=[], + ): + self.SocialPostAccountLinkedin._action_post(self.SocialPostLinkedin) + mock_refresh.assert_called_once() + self.assertEqual(mock_create_post.call_count, 2) + self.assertEqual(self.SocialPostAccountLinkedin.state, "posted") + self.assertEqual(self.SocialPostAccountLinkedin.remote_ref, post_account_urn) + + @mute_logger(LOGGER_POST_ACCOUNT_BASE) + def test_action_post_without_token_says_so_on_the_line(self): + """A publication skipped for lack of token must explain itself.""" + self.SocialPostAccountLinkedin.write({"state": "ready", "remote_ref": False}) + self.SocialAccountLinkedin.sudo().access_token = False + with patch.object( + type(self.SocialPostLinkedin), + "_filter_by_media_types", + autospec=True, + return_value=self.SocialPostAccountLinkedin, + ): + self.SocialPostAccountLinkedin._action_post(self.SocialPostLinkedin) + self.assertEqual(self.SocialPostAccountLinkedin.state, "failed") + self.assertIn( + "no LinkedIn access token", + self.SocialPostAccountLinkedin.failed_description, + ) + self.assertTrue(self.SocialAccountLinkedin.need_update) + + def test_action_post_falls_back_to_the_local_images(self): + """LinkedIn not exposing the images yet must not empty the card.""" + post_account_urn = "urn:li:share:122809890048" + image = self.env["ir.attachment"].create( + { + "name": "local.jpg", + "type": "binary", + "datas": base64.b64encode(b"local image").decode(), + } + ) + self.SocialPostAccountLinkedin.write({"state": "ready", "remote_ref": False}) + self.SocialPostLinkedin.write({"image_ids": [Command.set(image.ids)]}) + with patch.object( + type(self.SocialPostLinkedin), + "_filter_by_media_types", + autospec=True, + return_value=self.SocialPostAccountLinkedin, + ), patch.object( + type(self.SocialPostAccountLinkedin.account_id), + "_linkedin_create_post", + autospec=True, + return_value=(post_account_urn, ["urn:li:image:published"]), + ), patch.object( + type(self.SocialPostAccountLinkedin.account_id), + "_get_posts", + autospec=True, + return_value=[], + ): + self.SocialPostAccountLinkedin._action_post(self.SocialPostLinkedin) + self.assertEqual(self.SocialPostAccountLinkedin.state, "posted") + copied = self.SocialPostAccountLinkedin.image_ids + self.assertEqual(len(copied), 1) + self.assertEqual(copied.name, "urn:li:image:published") + self.assertEqual(copied.datas, image.datas) + self.assertNotEqual(self.SocialPostAccountLinkedin.image_urls, "[]") + + def test_action_post_prefers_the_images_downloaded_from_linkedin(self): + """The fallback only applies when the download brings nothing back.""" + post_account_urn = "urn:li:share:122809890049" + image = self.env["ir.attachment"].create( + { + "name": "local.jpg", + "type": "binary", + "datas": base64.b64encode(b"local image").decode(), + } + ) + self.SocialPostAccountLinkedin.write({"state": "ready", "remote_ref": False}) + self.SocialPostLinkedin.write({"image_ids": [Command.set(image.ids)]}) + downloaded = self.create_attachment(attach_name="urn:li:image:downloaded") + with patch.object( + type(self.SocialPostLinkedin), + "_filter_by_media_types", + autospec=True, + return_value=self.SocialPostAccountLinkedin, + ), patch.object( + type(self.SocialPostAccountLinkedin.account_id), + "_linkedin_create_post", + autospec=True, + return_value=(post_account_urn, ["urn:li:image:published"]), + ), patch.object( + type(self.SocialPostAccountLinkedin.account_id), + "_get_posts", + autospec=True, + return_value=[{"id": post_account_urn, "content": {"media": {"id": "x"}}}], + ), patch.object( + type(self.SocialPostAccountLinkedin), + "_get_assets_save", + autospec=True, + return_value=[Command.link(downloaded.id)], + ): + self.SocialPostAccountLinkedin._action_post(self.SocialPostLinkedin) + self.assertEqual(self.SocialPostAccountLinkedin.image_ids, downloaded) + + def test_action_post_does_not_empty_the_images_on_a_retry(self): + """Republishing a failed line must not clear what it already had.""" + post_account_urn = "urn:li:share:122809890050" + stored = self.create_attachment(attach_name="urn:li:image:stored") + self.SocialPostAccountLinkedin.write( + { + "state": "failed", + "remote_ref": False, + "image_ids": [Command.set(stored.ids)], + } + ) + with patch.object( + type(self.SocialPostLinkedin), + "_filter_by_media_types", + autospec=True, + return_value=self.SocialPostAccountLinkedin, + ), patch.object( + type(self.SocialPostAccountLinkedin.account_id), + "_linkedin_create_post", + autospec=True, + return_value=(post_account_urn, []), + ), patch.object( + type(self.SocialPostAccountLinkedin.account_id), + "_get_posts", + autospec=True, + return_value=[], + ): + self.SocialPostAccountLinkedin._action_post(self.SocialPostLinkedin) + self.assertEqual(self.SocialPostAccountLinkedin.state, "posted") + self.assertEqual(self.SocialPostAccountLinkedin.image_ids, stored) + + @mute_logger(LOGGER_POST_ACCOUNT_LINKEDIN) + def test_action_post_keeps_remote_ref_when_the_medias_fail(self): + """The enrichment is best-effort: it never reverts a published post.""" + post_account_urn = "urn:li:share:122809890047" + self.SocialPostAccountLinkedin.write({"state": "ready", "remote_ref": False}) + with patch.object( + type(self.SocialPostLinkedin), + "_filter_by_media_types", + autospec=True, + return_value=self.SocialPostAccountLinkedin, + ), patch.object( + type(self.SocialPostAccountLinkedin.account_id), + "_linkedin_create_post", + autospec=True, + return_value=(post_account_urn, []), + ), patch.object( + type(self.SocialPostAccountLinkedin.account_id), + "_get_posts", + autospec=True, + side_effect=UserError("LinkedIn is not available"), + ): + self.SocialPostAccountLinkedin._action_post(self.SocialPostLinkedin) + self.assertEqual(self.SocialPostAccountLinkedin.state, "posted") + self.assertEqual(self.SocialPostAccountLinkedin.remote_ref, post_account_urn) + + 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), + "_linkedin_create_post", + autospec=True, + return_value=(False, []), + ) as mock_linkedin_create_post: + self.SocialPostAccountLinkedin._action_post(self.SocialPostLinkedin) + self.assertEqual(self.SocialPostAccountLinkedin.state, "failed") + mock_filter_by_media_types.assert_called_once() + mock_linkedin_create_post.assert_called_once() + + def test_default_account_ids_only_the_active_company(self): + """An account of another allowed company is not preselected.""" + company = self.env["res.company"].create({"name": "Other Company"}) + other_account = self.SocialAccountLinkedin.copy({"company_id": company.id}) + self.assertNotIn(other_account.id, self.SocialPost._default_account_ids()) + self.assertIn( + other_account.id, + self.SocialPost.with_company(company)._default_account_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..669c36d73a --- /dev/null +++ b/social_media_linkedin/views/social_account_views.xml @@ -0,0 +1,80 @@ + + + + + + social.account.view.form.inherit.linkedin + social.account + + + +