diff --git a/social_media_advertising_linkedin/README.rst b/social_media_advertising_linkedin/README.rst new file mode 100644 index 0000000000..35fdcf8b8d --- /dev/null +++ b/social_media_advertising_linkedin/README.rst @@ -0,0 +1,411 @@ +================================= +Social Media Advertising LinkedIn +================================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:62b80ea53d651f4fbf4c8badfc7042c8646121807846b42c5ecdda8acaf57891 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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_advertising_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_advertising_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 plugs LinkedIn Ads into *Social Media Advertising*. + +Main features: + +- Campaign group and campaign management synchronized with LinkedIn Ads: + create them in LinkedIn from Odoo (in draft state) or import the + existing ones, and link posts to campaigns as sponsored creatives. +- The LinkedIn statuses of campaigns, campaign groups and ads (Draft, + Active, Paused, Archived...) are declared as stages of *Social Media + Advertising*, so the badges and the status bars show the real LinkedIn + vocabulary. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +The LinkedIn application. +------------------------- + +- The campaign features (*Create in LinkedIn* and *Import campaigns*) + require an advertising account in the LinkedIn Campaign Manager and + the following products, which the *Social Media Linkedin* module does + not request on its own. Ask for them on the *Products* tab of your + LinkedIn Developer App, following the configuration steps of that + module: + + - Advertising API + - LinkedIn Ad Library + + The access levels of those products are described in the `LinkedIn + Marketing API + documentation `__. + +- The member who authorizes the account must also have a role on that + advertising account (``ACCOUNT_BILLING_ADMIN``, ``ACCOUNT_MANAGER``, + ``CAMPAIGN_MANAGER``, ``CREATIVE_MANAGER`` or ``VIEWER``, the last one + read only), as listed in the `account users + documentation `__. + LinkedIn only answers the advertising accounts of the authorized + member, so without a role the import finds nothing. + +- That requirement is unrelated to the formats LinkedIn accepts as an + ad: a post with several images cannot be sponsored with any product + nor with any advertising account, because LinkedIn does not turn a + multi-image post into an ad. See the ROADMAP section about it. + +Re-authorize the already associated accounts. +--------------------------------------------- + +- Installing this module adds the ``r_ads``, ``rw_ads`` and + ``r_ads_reporting`` OAuth scopes to the LinkedIn authorization. The + tokens already issued keep the scopes they were granted with — + refreshing a token does not renegotiate them — so every LinkedIn + account that was authorized **before** installing this module must be + re-authorized from the account wizard (*Update account* > *Update + token*) before it can call the Ads API. +- These scopes are only requested while this module is installed, + because LinkedIn rejects the authorization of an application that asks + for them without the *Advertising API* product enabled. + +Test and production advertising accounts. +----------------------------------------- + +- The *Environment* of the account maps to the ``test`` flag LinkedIn + puts on an advertising account. **LinkedIn sets that flag when the + advertising account is created and it can never be changed + afterwards**, so a test advertising account stays a test one for good. +- A test advertising account can only be created through the API, never + from the Campaign Manager, and **each developer application may only + have one**. Only ``BUSINESS`` advertising accounts can be test ones: + ``ENTERPRISE`` cannot. +- Inside a test advertising account the creatives are **never served and + are automatically rejected** in the review process, and + ``/adAnalytics`` returns no data at all. Empty statistics in *Test* + are therefore expected, not a failure of the module. +- The role requirement above is what makes the list of advertising + accounts return anything: LinkedIn only answers the advertising + accounts the authorized member holds a role on. + +Usage +===== + +Choose the LinkedIn advertising account. +---------------------------------------- + +- Go to *Social Media* > Configuration > Accounts, open the LinkedIn + account and its *Advertising* tab, then click *Fetch advertising + accounts*. +- Besides the generic columns, the list shows what LinkedIn reports: + + - *LinkedIn Status*: ``DRAFT``, ``ACTIVE``, ``CANCELED``, + ``PENDING_DELETION`` or ``REMOVED``. + - *LinkedIn Serving Status*: ``RUNNABLE`` when the advertising account + is eligible for serving, otherwise the reasons why it is not, such + as ``BILLING_HOLD``, ``ACCOUNT_TOTAL_BUDGET_HOLD``, + ``ACCOUNT_END_DATE_HOLD``, ``RESTRICTED_HOLD``, ``INTERNAL_HOLD`` or + ``STOPPED``. This is the column to look at when nothing is being + served. + - *LinkedIn Type*: ``BUSINESS`` or ``ENTERPRISE``. + - *LinkedIn Owner*: the organization or person the advertising account + advertises on behalf of, which tells apart two advertising accounts + sharing a name. + +- *Campaign Manager URL* opens the advertising account on LinkedIn. +- *Create in LinkedIn*, *Import campaigns* and the sponsored creatives + all work against the advertising account marked *In Use*. + +Create a campaign in LinkedIn. +------------------------------ + +To link a post to a campaign, the campaign must already exist in +LinkedIn (only campaigns already synchronized with LinkedIn can be +selected on the post). Publishing a post does not create the campaign +anymore. + +- Go to *Social Media* > Advertising > Campaigns and open the campaign +- Fill in the campaign group, account, budget and currency fields +- Choose the *LinkedIn Ad Format*: *Standard update* for posts with text + or images, *Single video* for posts with a video. LinkedIn fixes the + format when the campaign is created and only accepts posts of that + format afterwards, so a post with a video needs its own video + campaign. The fields of a campaign are the ones of the `campaigns + documentation `__. +- The *LinkedIn Objective* is required by LinkedIn for the video format. + See the ROADMAP section about the objectives that are not offered + here. +- The *LinkedIn Political Intent* is the declaration LinkedIn requires + to create any campaign since the EU political advertising regulation. + It defaults to *Not political advertising*, which states that none of + your ads qualify as political advertising under the law of the + targeted countries, including EU law for ads targeted to the EU. + Change it to *Political advertising* when they do. +- Click on the *Create in LinkedIn* button +- The campaign group and the campaign are created in LinkedIn in + **DRAFT** status, so no budget is spent until you activate them from + the LinkedIn Campaign Manager. +- If the campaign cannot be created, the error is shown in a + notification and logged in the chatter instead of being raised: the + campaign group is already created on LinkedIn, which does not allow + deleting it, so its reference is kept to avoid importing it later as a + duplicate. Fix the reported problem and press *Create in LinkedIn* + again; the existing group is reused. +- A campaign group can also be created on LinkedIn on its own: open the + group and click its *Create in LinkedIn* button (it requires a + positive total budget and a currency). If you forget to do it, the + group is created automatically when the first campaign of the group is + published. +- When a post linked to a campaign is published, it is attached to the + campaign as a `sponsored + creative `__. + If the post is published but the creative cannot be linked, the post + is kept and the error is logged in the chatter. +- A post with more than one image cannot be sponsored: LinkedIn + publishes it as a multi-image post and does not accept that format as + an ad, so linking it to a campaign is refused before publishing. The + post form warns about it as soon as the campaign is selected, without + waiting for the *Post* button. + +Import campaigns from LinkedIn. +------------------------------- + +- Go to *Social Media* > Configuration > Accounts +- Select the account +- Click on the *Import campaigns* button +- The campaign groups, campaigns and their sponsored creatives are + imported from LinkedIn Ads. Every creative is matched with the + publication it promotes by its remote reference. +- A publication brought from the wall with *Update* has no post in Odoo, + so its campaign is taken from the creative and its badge appears on + the dashboard. Publications published from Odoo keep the campaign of + their post and are never overwritten by the import. +- The Posts API does not return the campaign of a post, so only this + import can resolve it. The order does not matter, but the publications + have to be already in Odoo: if they are brought **after** importing + the campaigns, press *Import campaigns* again. +- Campaign groups created on LinkedIn often carry no total budget, which + is optional there, as described in the `campaign groups + documentation `__. + Those groups set no limit in Odoo either: the rule keeping the daily + budgets of the campaigns within the total budget of their group only + applies when the group has one, and it is never checked on the values + brought by the import. The rule is enforced from both sides: raising + the daily budget of a campaign, moving it to another group, and + lowering the total budget of a group are all refused when the sum goes + over it. +- The real LinkedIn status of each campaign and campaign group (Draft, + Active, Paused, Archived...) is stored in its *Stage*, shown as a + status bar on the form and refreshed on every import. Campaigns + flagged as test campaigns on LinkedIn are marked as well. +- Campaigns and campaign groups deleted on LinkedIn (*Pending deletion*, + *Removed*) are hidden in the LinkedIn Campaign Manager, but the API + keeps returning them because of their performance data. They are + therefore kept in Odoo as history, and a note is logged in their + chatter when the deletion is detected. Keep in mind that deleting a + campaign group on LinkedIn also deletes all its campaigns and ads. + +Update a campaign in LinkedIn. +------------------------------ + +- When you modify in Odoo the editable fields of a campaign (name, unit + cost, daily budget, campaign group) or of a campaign group (name, + total budget) that already exists on LinkedIn, the record shows a + *Pending changes* ribbon, a warning banner explaining that the local + changes have not been pushed yet, and an *Update in LinkedIn* button. + A record that does not exist on LinkedIn yet carries the *Not synced* + ribbon instead. The tracked fields also log their changes in the + chatter. + + |CAMPAIGN_NOT_SYNCED| + +- Click *Update in LinkedIn* to push the local values to LinkedIn. To + move the campaign to another campaign group, the target group must + already exist on LinkedIn. The political advertising declaration is + pushed the same way, since LinkedIn allows updating it when the + targeting changes. + +- Synchronization priority is hybrid: the LinkedIn-only fields (stage, + test flag) are always refreshed by *Import campaigns*; for the + editable fields, your local pending changes are preserved by the + import — the LinkedIn values are logged in the chatter so you can + decide whether to keep your changes (push them with *Update in + LinkedIn*) or re-type the LinkedIn ones. + +- When the LinkedIn stage is *Archived*, *Canceled*, *Pending deletion* + or *Removed*, the campaign or campaign group cannot be modified in + Odoo (LinkedIn does not allow editing them either): the editable + fields become read-only, the *Update in LinkedIn* button is hidden and + a red banner explains the situation. The *Media* of a campaign that + exists on LinkedIn is read-only as well, so the lock cannot be escaped + by taking the campaign out of LinkedIn. The import keeps refreshing + their stage, so an archived record becomes editable again once it is + unarchived on LinkedIn. + +Archive a campaign or a campaign group in LinkedIn. +--------------------------------------------------- + +- LinkedIn does **not** allow deleting a campaign or a campaign group + through its API, whose statuses are the ones of the `campaigns + documentation `__. + Archiving is the only way to end them, and it is what the *Archive in + LinkedIn* button does. + +- Go to *Social Media* > Advertising, open the campaign or the campaign + group and click *Archive in LinkedIn*. The button is only shown when + the record already exists on LinkedIn and is not already archived, + canceled or deleted. + + |CAMPAIGN_ARCHIVE| + +- LinkedIn refuses to archive a campaign while its campaign group is + still in **Draft**: activate the group in the Campaign Manager first, + or archive the group instead, which archives its campaigns. Odoo + checks it before calling LinkedIn and explains it. + +- A campaign or a campaign group still in Draft keeps the start date + given when it was created, and LinkedIn rejects any later change + because that date already belongs to the past. Odoo therefore sends a + new run schedule when it updates or archives a record in Draft, which + moves its start date to the moment of the change. + +- The campaign stops running on LinkedIn and disappears from the + Campaign Manager active lists, but it is kept in Odoo with its + performance data. Its stage becomes *Archived*, which makes the record + read-only in Odoo, because LinkedIn no longer accepts changes on it. + +- **It cannot be reactivated from Odoo.** To unarchive it, use the + LinkedIn Campaign Manager and then run *Import campaigns* to refresh + the stage. + +- Archiving a campaign group archives its campaigns on LinkedIn as well, + except the ones still in Draft, which LinkedIn leaves untouched. Odoo + does not know about any of it until you run *Import campaigns*, so run + it afterwards to refresh them, and archive the draft campaigns one by + one if you want them archived too. + +- Deleting the campaign in Odoo does not do anything on LinkedIn: the + campaign keeps running there and the next *Import campaigns* brings it + back. Use *Archive in LinkedIn* to actually end it. + +.. |CAMPAIGN_NOT_SYNCED| image:: https://raw.githubusercontent.com/OCA/social/17.0/social_media_advertising_linkedin/static/img/readme/CAMPAIGN_NOT_SYNCED.png +.. |CAMPAIGN_ARCHIVE| image:: https://raw.githubusercontent.com/OCA/social/17.0/social_media_advertising_linkedin/static/img/readme/CAMPAIGN_ARCHIVE.png + +Known issues / Roadmap +====================== + +Campaign objectives +------------------- + +- LinkedIn defines seven campaign objectives: brand awareness, video + views, website visits, engagement, lead generation, website + conversions and job applicants, as listed in the `campaigns + documentation `__. +- Only the first four are offered by this module. The other three need a + configuration that lives on LinkedIn and that the module does not + create nor check: lead generation needs a lead gen form, website + conversions need conversion tracking, and job applicants needs + LinkedIn Talent Solutions and is not compatible with the video format. + Selecting them without that configuration is refused by LinkedIn when + the campaign is created. +- Campaigns imported from LinkedIn keep their real objective; the ones + that are not offered here are simply not shown in the field. + +Sponsored posts with several images +----------------------------------- + +- A post with more than one image is published as a multi-image post, + and LinkedIn does not sponsor that format: *"API partners can only + create non-sponsored multiImage posts"*. Linking such a post to a + campaign is refused before publishing, so the post is not left online + without its ad, and the post form warns about it as soon as the + campaign is selected. + + https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/multiimage-post-api + +- This is a restriction of the content format, not of the access level + of the application nor of the advertising account: no LinkedIn plan + sponsors a multi-image post. + +- The format LinkedIn does offer for several sponsored images is + Carousel ads (``content.carousel`` with an ``adContext``, on a + campaign whose format is set to carousel when it is created, see the + `creatives + documentation `__). + This module does not implement it, so the case is not supported here + even though the platform allows it. + +- Publish the post with a single image if it has to be sponsored. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* BinhexTeam + +Contributors +------------ + +- [Binhex] (https://www.binhex.cloud): + + - Edilio Escalona Almira e.escalona@binhex.cloud + +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_advertising_linkedin/__init__.py b/social_media_advertising_linkedin/__init__.py new file mode 100644 index 0000000000..6f3d3b53c1 --- /dev/null +++ b/social_media_advertising_linkedin/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import models diff --git a/social_media_advertising_linkedin/__manifest__.py b/social_media_advertising_linkedin/__manifest__.py new file mode 100644 index 0000000000..945b307ec3 --- /dev/null +++ b/social_media_advertising_linkedin/__manifest__.py @@ -0,0 +1,23 @@ +# Copyright 2026 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Social Media Advertising LinkedIn", + "summary": "LinkedIn Ads campaigns, campaign groups and sponsored creatives.", + "version": "17.0.1.0.0", + "category": "Social Network", + "development_status": "Beta", + "license": "AGPL-3", + "author": "BinhexTeam,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/social", + "maintainers": ["edescalona"], + "depends": ["social_media_advertising", "social_media_linkedin"], + "data": [ + "data/social_stage_data.xml", + "views/social_campaign_group_views.xml", + "views/social_campaign_views.xml", + "views/social_account_views.xml", + "views/social_advertising_account_views.xml", + ], + "exclude": ["social"], + "installable": True, +} diff --git a/social_media_advertising_linkedin/data/social_stage_data.xml b/social_media_advertising_linkedin/data/social_stage_data.xml new file mode 100644 index 0000000000..2b405c72f1 --- /dev/null +++ b/social_media_advertising_linkedin/data/social_stage_data.xml @@ -0,0 +1,194 @@ + + + + + Draft + DRAFT + + campaign + info + 10 + + + Active + ACTIVE + + campaign + success + 20 + + + Paused + PAUSED + + campaign + warning + 30 + + + Completed + COMPLETED + + campaign + secondary + 40 + + + + Archived + ARCHIVED + + campaign + secondary + 50 + + + + Canceled + CANCELED + + campaign + danger + 60 + + + + Pending deletion + PENDING_DELETION + + campaign + danger + 70 + + + + Removed + REMOVED + + campaign + danger + 80 + + + + Draft + DRAFT + + group + info + 10 + + + Active + ACTIVE + + group + success + 20 + + + Paused + PAUSED + + group + warning + 30 + + + Archived + ARCHIVED + + group + secondary + 40 + + + + Canceled + CANCELED + + group + danger + 50 + + + + Pending deletion + PENDING_DELETION + + group + danger + 60 + + + + Removed + REMOVED + + group + danger + 70 + + + + Draft + DRAFT + + ad + info + 10 + + + Active + ACTIVE + + ad + success + 20 + + + Paused + PAUSED + + ad + warning + 30 + + + Archived + ARCHIVED + + ad + secondary + 40 + + + + Canceled + CANCELED + + ad + danger + 50 + + + + Pending deletion + PENDING_DELETION + + ad + danger + 60 + + + + Removed + REMOVED + + ad + danger + 70 + + + diff --git a/social_media_advertising_linkedin/i18n/es.po b/social_media_advertising_linkedin/i18n/es.po new file mode 100644 index 0000000000..f4503cfdb6 --- /dev/null +++ b/social_media_advertising_linkedin/i18n/es.po @@ -0,0 +1,1122 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * social_media_advertising_linkedin +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-03 02:57+0000\n" +"PO-Revision-Date: 2026-08-03 02:57+0000\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_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_account.py:0 +#, python-format +msgid "" +"%(groups)s campaign group(s), %(campaigns)s campaign(s) and %(ads)s " +"sponsored post(s) imported from LinkedIn." +msgstr "" +"%(groups)s grupo(s) de campaña, %(campaigns)s campaña(s) y %(ads)s " +"publicación(es) patrocinada(s) importadas de LinkedIn." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "%(message_error)s %(error_response)s" +msgstr "%(message_error)s %(error_response)s" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "A LinkedIn campaign can only have one LinkedIn account." +msgstr "Una campaña de LinkedIn solo puede tener una cuenta de LinkedIn." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_post.py:0 +#, python-format +msgid "" +"A post with several images is published as a multi-image post, and LinkedIn " +"does not sponsor that format, so this post cannot be linked to the campaign " +"%(campaign)s. Leave a single image or remove the campaign." +msgstr "" +"Una publicación con varias imágenes se publica como publicación de varias " +"imágenes, y LinkedIn no patrocina ese formato, por lo que esta publicación " +"no se puede vincular a la campaña %(campaign)s. Deje una sola imagen o quite" +" la campaña." + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields.selection,name:social_media_advertising_linkedin.selection__social_advertising_account__linkedin_status__active +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_ad_active +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_campaign_active +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_group_active +msgid "Active" +msgstr "Activa" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,help:social_media_advertising_linkedin.field_social_campaign__linkedin_format +msgid "" +"Ad format of the campaign on LinkedIn. A campaign only accepts posts of its " +"format, and LinkedIn does not allow changing it once the campaign is " +"created, so a post with a video needs a campaign of the 'Single video' " +"format." +msgstr "" +"Formato de anuncio de la campaña en LinkedIn. Una campaña solo acepta " +"publicaciones de su formato, y LinkedIn no permite cambiarlo una vez creada " +"la campaña, por lo que una publicación con vídeo necesita una campaña con el" +" formato 'Vídeo único'." + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_group_view_form +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_view_form +msgid "Archive in LinkedIn" +msgstr "Archivar en LinkedIn" + +#. module: social_media_advertising_linkedin +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_ad_archived +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_campaign_archived +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_group_archived +msgid "Archived" +msgstr "Archivada" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,help:social_media_advertising_linkedin.field_social_campaign__unit_cost +msgid "" +"Bid amount per click, impression or other event depending on the pricing " +"model. LinkedIn only applies it with manual, target cost or cost cap " +"bidding; with automatic bidding it is ignored." +msgstr "" +"Importe de la puja por clic, impresión u otro evento según el modelo de " +"precios. LinkedIn solo la aplica con puja manual, target cost o cost cap; " +"con puja automática se ignora." + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields.selection,name:social_media_advertising_linkedin.selection__social_campaign__linkedin_objective__brand_awareness +msgid "Brand awareness" +msgstr "Notoriedad de marca" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields.selection,name:social_media_advertising_linkedin.selection__social_advertising_account__linkedin_type__business +msgid "Business" +msgstr "Empresa" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "Campaign archived on LinkedIn." +msgstr "Campaña archivada en LinkedIn." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "Campaign created on LinkedIn in draft status." +msgstr "Campaña creada en LinkedIn en estado borrador." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "Campaign group archived on LinkedIn." +msgstr "Grupo de campañas archivado en LinkedIn." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "Campaign group created on LinkedIn in draft status." +msgstr "Grupo de campañas creado en LinkedIn en estado borrador." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "Campaign group updated on LinkedIn." +msgstr "Grupo de campañas actualizado en LinkedIn." + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_account_view_form_inherit +msgid "" +"Campaign groups and campaigns of the advertising account will be imported " +"from LinkedIn. Continue?" +msgstr "" +"Se importarán desde LinkedIn los grupos de campañas y las campañas de la " +"cuenta publicitaria. ¿Continuar?" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "Campaign not created on LinkedIn" +msgstr "Campaña no creada en LinkedIn" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "Campaign updated on LinkedIn." +msgstr "Campaña actualizada en LinkedIn." + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields.selection,name:social_media_advertising_linkedin.selection__social_advertising_account__linkedin_status__canceled +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_ad_canceled +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_campaign_canceled +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_group_canceled +msgid "Canceled" +msgstr "Cancelada" + +#. module: social_media_advertising_linkedin +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_campaign_completed +msgid "Completed" +msgstr "Completada" + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_group_view_form +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_view_form +msgid "Create in LinkedIn" +msgstr "Crear en LinkedIn" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_post_account__creative_urn +msgid "Creative Urn" +msgstr "URN de la creatividad" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_campaign__currency_id +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_campaign_group__currency_id +msgid "Currency" +msgstr "Moneda" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_campaign__daily_budget +msgid "Daily Budget" +msgstr "Presupuesto diario" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,help:social_media_advertising_linkedin.field_social_campaign__linkedin_political_intent +msgid "" +"Declaration required by LinkedIn to create a campaign: I confirm this is not" +" political advertising. None of my ads qualify as political advertising " +"under the law of the targeted countries, including EU law for ads targeted " +"to the EU. Change it to 'Political advertising' when the campaign does " +"qualify as such." +msgstr "" +"Declaración que exige LinkedIn para crear una campaña: confirmo que no se " +"trata de publicidad política. Ninguno de mis anuncios se considera " +"publicidad política según la legislación de los países a los que se dirigen," +" incluida la normativa de la UE para los anuncios dirigidos a la UE. " +"Cámbielo a 'Publicidad política' cuando la campaña sí lo sea." + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_group_view_form +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_view_form +msgid "Do you want to update the values on LinkedIn?" +msgstr "¿Desea actualizar los valores en LinkedIn?" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields.selection,name:social_media_advertising_linkedin.selection__social_advertising_account__linkedin_status__draft +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_ad_draft +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_campaign_draft +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_group_draft +msgid "Draft" +msgstr "Borrador" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields.selection,name:social_media_advertising_linkedin.selection__social_campaign__linkedin_objective__engagement +msgid "Engagement" +msgstr "Engagement" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields.selection,name:social_media_advertising_linkedin.selection__social_advertising_account__linkedin_type__enterprise +msgid "Enterprise" +msgstr "Corporativa" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,help:social_media_advertising_linkedin.field_social_advertising_account__linkedin_type +msgid "" +"Enterprise advertising accounts are created by LinkedIn itself and cannot be" +" test accounts." +msgstr "" +"Las cuentas de publicidad Corporativas las crea LinkedIn y no pueden ser " +"cuentas de prueba." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "Error archiving campaign group in Linkedin: %(error)s" +msgstr "Error al archivar el grupo de campañas en LinkedIn: %(error)s" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "Error archiving campaign in Linkedin: %(error)s" +msgstr "Error al archivar la campaña en LinkedIn: %(error)s" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_post_account.py:0 +#, python-format +msgid "Error creating campaign post in Linkedin: %(error)s" +msgstr "Error al crear el post de campaña en LinkedIn: %(error)s" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "Error creating group campaign in Linkedin: %(error)s" +msgstr "Error al crear el grupo de campañas en LinkedIn: %(error)s" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_account.py:0 +#, python-format +msgid "Error get advertising account in Linkedin: %(error)s" +msgstr "Error al obtener la cuenta publicitaria de LinkedIn: %(error)s" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_account.py:0 +#, python-format +msgid "Error getting campaigns from Linkedin: %(error)s" +msgstr "Error al obtener las campañas de LinkedIn: %(error)s" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "Error updating campaign group in Linkedin: %(error)s" +msgstr "Error al actualizar el grupo de campañas en LinkedIn: %(error)s" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "Error updating campaign in Linkedin: %(error)s" +msgstr "Error al actualizar la campaña en LinkedIn: %(error)s" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_account.py:0 +#, python-format +msgid "GET ADS: %(error)s" +msgstr "GET ADS: %(error)s" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_account.py:0 +#, python-format +msgid "GET CAMPAIGNS STATISTICS: %(error)s" +msgstr "GET CAMPAIGNS STATISTICS: %(error)s" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,help:social_media_advertising_linkedin.field_social_campaign__linkedin_objective +msgid "" +"Goal of the campaign on LinkedIn, required for the 'Single video' format:\n" +"- Brand awareness: reach as many people as possible.\n" +"- Video views: get the video of the post watched.\n" +"- Website visits: send traffic to a page.\n" +"- Engagement: get likes, comments and follows.\n" +"LinkedIn has three more objectives (lead generation, website conversions and job applicants) that need a lead gen form, conversion tracking or LinkedIn Talent Solutions, which are configured outside Odoo and are therefore not offered here." +msgstr "" +"Objetivo de la campaña en LinkedIn, obligatorio para el formato 'Vídeo único':\n" +"- Notoriedad de marca: llegar al mayor número de personas posible.\n" +"- Visualizaciones de vídeo: conseguir que se vea el vídeo de la publicación.\n" +"- Visitas al sitio web: enviar tráfico a una página.\n" +"- Interacción: conseguir me gusta, comentarios y seguidores.\n" +"LinkedIn tiene tres objetivos más (generación de contactos, conversiones en el sitio web y candidaturas de empleo) que necesitan un formulario de generación de contactos, seguimiento de conversiones o LinkedIn Talent Solutions, que se configuran fuera de Odoo y por eso no se ofrecen aquí." + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_account_view_form_inherit +msgid "Import campaigns" +msgstr "Importar campañas" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_account.py:0 +#, python-format +msgid "" +"Import kept the local pending changes. LinkedIn values: name: %(name)s, " +"total budget: %(total_budget)s %(currency)s" +msgstr "" +"La importación conservó los cambios locales pendientes. Valores de LinkedIn:" +" nombre: %(name)s, presupuesto total: %(total_budget)s %(currency)s" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_account.py:0 +#, python-format +msgid "" +"Import kept the local pending changes. LinkedIn values: name: %(name)s, unit" +" cost: %(unit_cost)s, daily budget: %(daily_budget)s, campaign group: " +"%(group)s" +msgstr "" +"La importación conservó los cambios locales pendientes. Valores de LinkedIn:" +" nombre: %(name)s, coste unitario: %(unit_cost)s, presupuesto diario: " +"%(daily_budget)s, grupo de campañas: %(group)s" + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_view_form +msgid "LinkedIn" +msgstr "LinkedIn" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_campaign__linkedin_format +msgid "LinkedIn Ad Format" +msgstr "Formato de anuncio de LinkedIn" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_campaign__linkedin_objective +msgid "LinkedIn Objective" +msgstr "Objetivo de LinkedIn" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_advertising_account__linkedin_reference +msgid "LinkedIn Owner" +msgstr "Propietario de LinkedIn" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_campaign__linkedin_needs_update +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_campaign_group__linkedin_needs_update +msgid "LinkedIn Pending Changes" +msgstr "Cambios pendientes de LinkedIn" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_campaign__linkedin_political_intent +msgid "LinkedIn Political Intent" +msgstr "Intención política de LinkedIn" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_advertising_account__linkedin_serving_status +msgid "LinkedIn Serving Status" +msgstr "Estado de publicación de LinkedIn" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_advertising_account__linkedin_status +msgid "LinkedIn Status" +msgstr "Estado de LinkedIn" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_campaign__linkedin_is_test +msgid "LinkedIn Test Campaign" +msgstr "Campaña de prueba de LinkedIn" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_advertising_account__linkedin_type +msgid "LinkedIn Type" +msgstr "Tipo de LinkedIn" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "" +"LinkedIn does not archive a campaign whose campaign group is still in draft " +"status. Activate the group %(group)s in Campaign Manager first, or archive " +"the group itself, which archives its campaigns." +msgstr "" +"LinkedIn no archiva una campaña cuyo grupo de campañas sigue en estado " +"borrador. Active primero el grupo %(group)s en Campaign Manager, o archive " +"el propio grupo, lo que archiva sus campañas." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_post_account.py:0 +#, python-format +msgid "" +"LinkedIn does not sponsor posts with several images: they are published as a" +" multi-image post, a format its API cannot turn into an ad, so this post " +"cannot be linked to the campaign %(campaign)s. Publish it with a single " +"image or without a campaign. LinkedIn offers the Carousel ad format for this" +" case, which this module does not support yet." +msgstr "" +"LinkedIn no patrocina publicaciones con varias imágenes: se publican como " +"publicación de varias imágenes, un formato que su API no puede convertir en " +"anuncio, por lo que esta publicación no se puede vincular a la campaña " +"%(campaign)s. Publíquela con una sola imagen o sin campaña. LinkedIn ofrece " +"el formato de anuncio Carrusel para este caso, que este módulo todavía no " +"admite." + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,help:social_media_advertising_linkedin.field_social_campaign__linkedin_locked +#: model:ir.model.fields,help:social_media_advertising_linkedin.field_social_campaign_group__linkedin_locked +msgid "LinkedIn no longer accepts changes on this record." +msgstr "LinkedIn ya no acepta cambios en este registro." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "LinkedIn requires an objective for the video format." +msgstr "LinkedIn exige un objetivo para el formato de vídeo." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "" +"LinkedIn requires declaring whether the campaign is political advertising." +msgstr "LinkedIn exige declarar si la campaña es publicidad política." + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_campaign__linkedin_locked +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_campaign_group__linkedin_locked +msgid "Linkedin Locked" +msgstr "Bloqueado en LinkedIn" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,help:social_media_advertising_linkedin.field_social_campaign_group__total_budget +msgid "" +"Maximum amount to spend across all the campaigns and creatives of this group" +" for its entire duration. It cannot be lower than the daily budgets of its " +"campaigns." +msgstr "" +"Importe máximo a gastar entre todas las campañas y creatividades de este " +"grupo durante toda su duración. No puede ser inferior a los presupuestos " +"diarios de sus campañas." + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,help:social_media_advertising_linkedin.field_social_campaign__daily_budget +msgid "" +"Maximum amount to spend on this campaign per day. The campaign group total " +"budget limits the overall spending." +msgstr "" +"Importe máximo a gastar en esta campaña por día. El presupuesto total del " +"grupo de campañas limita el gasto global." + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_campaign_group__media_type +msgid "Media Type" +msgstr "Tipo de red social" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_account.py:0 +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#: code:addons/social_media_advertising_linkedin/models/social_post_account.py:0 +#, python-format +msgid "" +"No LinkedIn advertising account is in use for the account %(account)s. Open " +"its Advertising tab, fetch the advertising accounts and choose one." +msgstr "" +"No hay ninguna cuenta de publicidad de LinkedIn en uso para la cuenta " +"%(account)s. Abra su pestaña Publicidad, obtenga las cuentas de publicidad y" +" elija una." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "No LinkedIn social account is available to archive the group." +msgstr "" +"No hay ninguna cuenta social de LinkedIn disponible para archivar el grupo." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "No LinkedIn social account is available to create the group." +msgstr "" +"No hay ninguna cuenta social de LinkedIn disponible para crear el grupo." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "No LinkedIn social account is available to update the group." +msgstr "" +"No hay ninguna cuenta social de LinkedIn disponible para actualizar el " +"grupo." + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields.selection,name:social_media_advertising_linkedin.selection__social_campaign__linkedin_political_intent__not_declared +msgid "Not declared" +msgstr "Sin declarar" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields.selection,name:social_media_advertising_linkedin.selection__social_campaign__linkedin_political_intent__not_political +msgid "Not political advertising" +msgstr "Publicidad no política" + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_group_view_form +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_view_form +msgid "Not synced" +msgstr "Sin sincronizar" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,help:social_media_advertising_linkedin.field_social_advertising_account__linkedin_reference +msgid "" +"Organization or person the advertising account advertises on behalf of." +msgstr "" +"Organización o persona en cuyo nombre anuncia la cuenta de publicidad." + +#. module: social_media_advertising_linkedin +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_ad_paused +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_campaign_paused +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_group_paused +msgid "Paused" +msgstr "Pausada" + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_group_view_form +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_view_form +msgid "Pending changes" +msgstr "Cambios pendientes" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields.selection,name:social_media_advertising_linkedin.selection__social_advertising_account__linkedin_status__pending_deletion +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_ad_pending_deletion +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_campaign_pending_deletion +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_group_pending_deletion +msgid "Pending deletion" +msgstr "Pendiente de eliminación" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields.selection,name:social_media_advertising_linkedin.selection__social_campaign__linkedin_political_intent__political +msgid "Political advertising" +msgstr "Publicidad política" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields.selection,name:social_media_advertising_linkedin.selection__social_advertising_account__linkedin_status__removed +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_ad_removed +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_campaign_removed +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_group_removed +msgid "Removed" +msgstr "Eliminada" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields.selection,name:social_media_advertising_linkedin.selection__social_campaign__linkedin_format__single_video +msgid "Single video" +msgstr "Vídeo único" + +#. module: social_media_advertising_linkedin +#: model:ir.model,name:social_media_advertising_linkedin.model_social_account +msgid "Social Account" +msgstr "Cuenta de red social" + +#. module: social_media_advertising_linkedin +#: model:ir.model,name:social_media_advertising_linkedin.model_social_advertising_account +msgid "Social Advertising Account" +msgstr "Cuenta de publicidad social" + +#. module: social_media_advertising_linkedin +#: model:ir.model,name:social_media_advertising_linkedin.model_social_campaign +msgid "Social Campaign" +msgstr "Campaña social" + +#. module: social_media_advertising_linkedin +#: model:ir.model,name:social_media_advertising_linkedin.model_social_campaign_group +msgid "Social Campaign Group" +msgstr "Grupo de campañas sociales" + +#. module: social_media_advertising_linkedin +#: model:ir.model,name:social_media_advertising_linkedin.model_social_media +msgid "Social Media" +msgstr "Redes sociales" + +#. module: social_media_advertising_linkedin +#: model:ir.model,name:social_media_advertising_linkedin.model_social_post +msgid "Social Post" +msgstr "Post social" + +#. module: social_media_advertising_linkedin +#: model:ir.model,name:social_media_advertising_linkedin.model_social_post_account +msgid "Social Post Account" +msgstr "Post social de la cuenta" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields.selection,name:social_media_advertising_linkedin.selection__social_campaign__linkedin_format__standard_update +msgid "Standard update" +msgstr "Publicación estándar" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,help:social_media_advertising_linkedin.field_social_advertising_account__linkedin_status +msgid "Status of the advertising account on LinkedIn." +msgstr "Estado de la cuenta de publicidad en LinkedIn." + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_group_view_form +msgid "" +"Stops the campaign group on LinkedIn and takes it out of the Campaign " +"Manager lists. LinkedIn does not allow deleting a campaign group, archiving " +"is the only way to end it. LinkedIn applies the same status to the campaigns" +" of the group, except the ones still in draft, so run Import campaigns " +"afterwards to refresh them in Odoo." +msgstr "" +"Detiene el grupo de campañas en LinkedIn y lo saca de las listas de Campaign" +" Manager. LinkedIn no permite eliminar un grupo de campañas, archivarlo es " +"la única forma de terminarlo. LinkedIn aplica el mismo estado a las campañas" +" del grupo, salvo a las que siguen en borrador, así que ejecute después " +"Importar campañas para actualizarlas en Odoo." + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_view_form +msgid "" +"Stops the campaign on LinkedIn and takes it out of the Campaign Manager " +"lists. LinkedIn does not allow deleting a campaign, archiving is the only " +"way to end it, and it refuses to archive a campaign whose campaign group is " +"still in draft status. The campaign is kept in Odoo with its performance " +"data, and it can no longer be modified." +msgstr "" +"Detiene la campaña en LinkedIn y la saca de las listas de Campaign Manager. " +"LinkedIn no permite eliminar una campaña, archivarla es la única forma de " +"terminarla, y se niega a archivar una campaña cuyo grupo de campañas sigue " +"en estado borrador. La campaña se conserva en Odoo con sus datos de " +"rendimiento, y ya no se puede modificar." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "" +"The LinkedIn campaign group stage with code %(code)s is missing. Update the " +"social_media_advertising_linkedin module to restore the LinkedIn stages." +msgstr "" +"Falta la etapa de grupo de campañas de LinkedIn con el código %(code)s. " +"Actualice el módulo social_media_advertising_linkedin para restaurar las " +"etapas de LinkedIn." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "" +"The LinkedIn campaign stage with code %(code)s is missing. Update the " +"social_media_advertising_linkedin module to restore the LinkedIn stages." +msgstr "" +"Falta la etapa de campaña de LinkedIn con el código %(code)s. Actualice el " +"módulo social_media_advertising_linkedin para restaurar las etapas de " +"LinkedIn." + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_view_form +msgid "" +"The LinkedIn status of this campaign does not allow\n" +" modifications." +msgstr "" +"El estado en LinkedIn de esta campaña no permite\n" +" modificaciones." + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_group_view_form +msgid "" +"The LinkedIn status of this campaign group does not allow\n" +" modifications." +msgstr "" +"El estado en LinkedIn de este grupo de campañas no permite\n" +" modificaciones." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "The campaign %(campaign)s does not belong to a campaign group." +msgstr "La campaña %(campaign)s no pertenece a ningún grupo de campañas." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_post_account.py:0 +#, python-format +msgid "" +"The campaign %(campaign)s has not been created on LinkedIn yet. Use the " +"'Create in LinkedIn' button on the campaign before posting." +msgstr "" +"La campaña %(campaign)s aún no se ha creado en LinkedIn. Use el botón 'Crear" +" en LinkedIn' de la campaña antes de publicar." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_post_account.py:0 +#, python-format +msgid "" +"The campaign %(campaign)s uses the 'Single video' format, so it only accepts" +" posts containing a video." +msgstr "" +"La campaña %(campaign)s usa el formato 'Vídeo único', por lo que solo acepta" +" publicaciones que contengan un vídeo." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "" +"The campaign %(names)s cannot be modified because of its LinkedIn status " +"(%(status)s)." +msgstr "" +"La campaña %(names)s no se puede modificar por su estado en LinkedIn " +"(%(status)s)." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "" +"The campaign cannot be archived because of its LinkedIn status (%(status)s)." +msgstr "" +"La campaña no se puede archivar por su estado en LinkedIn (%(status)s)." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "" +"The campaign cannot be updated because of its LinkedIn status (%(status)s)." +msgstr "" +"La campaña no se puede actualizar por su estado en LinkedIn (%(status)s)." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "The campaign could not be created on LinkedIn: %(error)s" +msgstr "No se pudo crear la campaña en LinkedIn: %(error)s" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_post_account.py:0 +#, python-format +msgid "" +"The campaign could not be generated for the post, please try again later." +msgstr "" +"La campaña no se pudo generar para el post, inténtelo de nuevo más tarde." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "The campaign daily budget must be positive." +msgstr "El presupuesto diario de la campaña debe ser positivo." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "The campaign does not exist on LinkedIn yet." +msgstr "La campaña aún no existe en LinkedIn." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "" +"The campaign group %(group)s does not exist on LinkedIn. Create or import it" +" before updating the campaign." +msgstr "" +"El grupo de campañas %(group)s no existe en LinkedIn. Créelo o impórtelo " +"antes de actualizar la campaña." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "" +"The campaign group %(group)s is not linked to any LinkedIn account and the " +"database holds several of them. Set the account on one of its campaigns to " +"choose the advertising account to use." +msgstr "" +"El grupo de campañas %(group)s no está vinculado a ninguna cuenta de " +"LinkedIn y la base de datos tiene varias. Indique la cuenta en una de sus " +"campañas para elegir la cuenta publicitaria que se debe usar." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "" +"The campaign group %(names)s cannot be modified because of its LinkedIn " +"status (%(status)s)." +msgstr "" +"El grupo de campañas %(names)s no se puede modificar por su estado en " +"LinkedIn (%(status)s)." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "The campaign group already exists on LinkedIn." +msgstr "El grupo de campañas ya existe en LinkedIn." + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_view_form +msgid "" +"The campaign group and the campaign will be created on LinkedIn in draft " +"status. Continue?" +msgstr "" +"El grupo de campañas y la campaña se crearán en LinkedIn en estado borrador." +" ¿Continuar?" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "" +"The campaign group cannot be archived because of its LinkedIn status " +"(%(status)s)." +msgstr "" +"El grupo de campañas no se puede archivar por su estado en LinkedIn " +"(%(status)s)." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "" +"The campaign group cannot be updated because of its LinkedIn status " +"(%(status)s)." +msgstr "" +"El grupo de campañas no se puede actualizar por su estado en LinkedIn " +"(%(status)s)." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "The campaign group does not exist on LinkedIn yet." +msgstr "El grupo de campañas aún no existe en LinkedIn." + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_group_view_form +msgid "" +"The campaign group has local changes that are not\n" +" synchronized with LinkedIn. Use the Update in\n" +" LinkedIn button to push them." +msgstr "" +"El grupo de campañas tiene cambios locales que no están\n" +" sincronizados con LinkedIn. Use el botón Actualizar en\n" +" LinkedIn para enviarlos." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "The campaign group must have a currency." +msgstr "El grupo de campañas debe tener una moneda." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "The campaign group total budget must be positive." +msgstr "El presupuesto total del grupo de campañas debe ser positivo." + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,help:social_media_advertising_linkedin.field_social_campaign_group__linkedin_needs_update +msgid "" +"The campaign group was modified in Odoo after being synchronized with " +"LinkedIn. Use the 'Update in LinkedIn' button to push the local values." +msgstr "" +"El grupo de campañas se modificó en Odoo después de sincronizarse con " +"LinkedIn. Use el botón 'Actualizar en LinkedIn' para enviar los valores " +"locales." + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_group_view_form +msgid "" +"The campaign group will be archived on LinkedIn, and LinkedIn will archive " +"its campaigns too, except the ones still in draft status. It cannot be " +"reactivated from Odoo. Continue?" +msgstr "" +"El grupo de campañas se archivará en LinkedIn, y LinkedIn archivará también " +"sus campañas, salvo las que sigan en estado borrador. No se puede reactivar " +"desde Odoo. ¿Continuar?" + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_group_view_form +msgid "" +"The campaign group will be created on LinkedIn in draft status. Continue?" +msgstr "" +"El grupo de campañas se creará en LinkedIn en estado borrador. ¿Continuar?" + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_view_form +msgid "" +"The campaign has local changes that are not synchronized\n" +" with LinkedIn. Use the Update in LinkedIn button to\n" +" push them." +msgstr "" +"La campaña tiene cambios locales que no están sincronizados\n" +" con LinkedIn. Use el botón Actualizar en LinkedIn para\n" +" enviarlos." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "The campaign media must be LinkedIn." +msgstr "El medio de la campaña debe ser LinkedIn." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "The campaign must belong to a campaign group." +msgstr "La campaña debe pertenecer a un grupo de campañas." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "The campaign must have a social account and a currency." +msgstr "La campaña debe tener una cuenta social y una moneda." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "The campaign must have a social account." +msgstr "La campaña debe tener una cuenta social." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "The campaign unit cost must be positive." +msgstr "El coste unitario de la campaña debe ser positivo." + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,help:social_media_advertising_linkedin.field_social_campaign__linkedin_needs_update +msgid "" +"The campaign was modified in Odoo after being synchronized with LinkedIn. " +"Use the 'Update in LinkedIn' button to push the local values." +msgstr "" +"La campaña se modificó en Odoo después de sincronizarse con LinkedIn. Use el" +" botón 'Actualizar en LinkedIn' para enviar los valores locales." + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_view_form +msgid "" +"The campaign will be archived on LinkedIn and will stop running. It cannot " +"be reactivated from Odoo. It stays in Odoo with its performance data. " +"Continue?" +msgstr "" +"La campaña se archivará en LinkedIn y dejará de ejecutarse. No se puede " +"reactivar desde Odoo. Se mantiene en Odoo con sus datos de rendimiento. " +"¿Continuar?" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "" +"The campaigns of the group %(group)s point to several LinkedIn accounts " +"(%(accounts)s). Leave a single one so the advertising account to use is " +"unambiguous." +msgstr "" +"Las campañas del grupo %(group)s apuntan a varias cuentas de LinkedIn " +"(%(accounts)s). Deje una sola para que la cuenta publicitaria que se debe " +"usar no sea ambigua." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "" +"The daily budgets of the campaigns of the group %(group)s add up to " +"%(daily_budgets)s, which exceeds its total budget of %(total_budget)s." +msgstr "" +"Los presupuestos diarios de las campañas del grupo %(group)s suman " +"%(daily_budgets)s, lo que supera su presupuesto total de %(total_budget)s." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_post_account.py:0 +#, python-format +msgid "" +"The post contains a video, so it needs a campaign of the 'Single video' " +"format. The campaign %(campaign)s uses the 'Standard update' format and " +"LinkedIn does not allow changing it once the campaign is created." +msgstr "" +"La publicación contiene un vídeo, por lo que necesita una campaña con el " +"formato 'Vídeo único'. La campaña %(campaign)s usa el formato 'Publicación " +"estándar' y LinkedIn no permite cambiarlo una vez creada la campaña." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_post_account.py:0 +#, python-format +msgid "" +"The post was published on LinkedIn but the campaign creative could not be " +"created. Check the Ads permissions of the account." +msgstr "" +"El post se publicó en LinkedIn pero la creatividad de la campaña no se pudo " +"crear. Revise los permisos de Ads de la cuenta." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_account.py:0 +#, python-format +msgid "" +"This campaign group was deleted on LinkedIn. It is kept in Odoo as history " +"because LinkedIn still returns it with its performance data." +msgstr "" +"Este grupo de campañas se eliminó en LinkedIn. Se conserva en Odoo como " +"histórico porque LinkedIn lo sigue devolviendo con sus datos de rendimiento." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_account.py:0 +#, python-format +msgid "" +"This campaign was deleted on LinkedIn. It is kept in Odoo as history because" +" LinkedIn still returns it with its performance data." +msgstr "" +"Esta campaña se eliminó en LinkedIn. Se conserva en Odoo como histórico " +"porque LinkedIn la sigue devolviendo con sus datos de rendimiento." + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_campaign_group__total_budget +msgid "Total Budget" +msgstr "Presupuesto total" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_campaign__unit_cost +msgid "Unit Cost" +msgstr "Costo unitario" + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_group_view_form +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_view_form +msgid "Update in LinkedIn" +msgstr "Actualizar en LinkedIn" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields.selection,name:social_media_advertising_linkedin.selection__social_campaign__linkedin_objective__video_view +msgid "Video views" +msgstr "Visualizaciones de vídeo" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields.selection,name:social_media_advertising_linkedin.selection__social_campaign__linkedin_objective__website_visit +msgid "Website visits" +msgstr "Visitas al sitio web" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,help:social_media_advertising_linkedin.field_social_campaign__linkedin_is_test +msgid "Whether LinkedIn flags this campaign as a test campaign." +msgstr "Indica si LinkedIn marca esta campaña como campaña de prueba." + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,help:social_media_advertising_linkedin.field_social_advertising_account__linkedin_serving_status +msgid "" +"Why LinkedIn is serving the ads of this advertising account, or not: " +"RUNNABLE when it is eligible, otherwise the reasons why it is not, such as " +"BILLING_HOLD or ACCOUNT_TOTAL_BUDGET_HOLD." +msgstr "" +"Por qué LinkedIn publica o no los anuncios de esta cuenta de publicidad: " +"RUNNABLE cuando es elegible, en caso contrario los motivos por los que no lo" +" es, como BILLING_HOLD o ACCOUNT_TOTAL_BUDGET_HOLD." diff --git a/social_media_advertising_linkedin/i18n/es_ES.po b/social_media_advertising_linkedin/i18n/es_ES.po new file mode 100644 index 0000000000..3fd6dbf7a1 --- /dev/null +++ b/social_media_advertising_linkedin/i18n/es_ES.po @@ -0,0 +1,1122 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * social_media_advertising_linkedin +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-03 02:57+0000\n" +"PO-Revision-Date: 2026-08-03 02:57+0000\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_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_account.py:0 +#, python-format +msgid "" +"%(groups)s campaign group(s), %(campaigns)s campaign(s) and %(ads)s " +"sponsored post(s) imported from LinkedIn." +msgstr "" +"%(groups)s grupo(s) de campaña, %(campaigns)s campaña(s) y %(ads)s " +"publicación(es) patrocinada(s) importadas de LinkedIn." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "%(message_error)s %(error_response)s" +msgstr "%(message_error)s %(error_response)s" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "A LinkedIn campaign can only have one LinkedIn account." +msgstr "Una campaña de LinkedIn solo puede tener una cuenta de LinkedIn." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_post.py:0 +#, python-format +msgid "" +"A post with several images is published as a multi-image post, and LinkedIn " +"does not sponsor that format, so this post cannot be linked to the campaign " +"%(campaign)s. Leave a single image or remove the campaign." +msgstr "" +"Una publicación con varias imágenes se publica como publicación de varias " +"imágenes, y LinkedIn no patrocina ese formato, por lo que esta publicación " +"no se puede vincular a la campaña %(campaign)s. Deje una sola imagen o quite" +" la campaña." + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields.selection,name:social_media_advertising_linkedin.selection__social_advertising_account__linkedin_status__active +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_ad_active +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_campaign_active +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_group_active +msgid "Active" +msgstr "Activa" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,help:social_media_advertising_linkedin.field_social_campaign__linkedin_format +msgid "" +"Ad format of the campaign on LinkedIn. A campaign only accepts posts of its " +"format, and LinkedIn does not allow changing it once the campaign is " +"created, so a post with a video needs a campaign of the 'Single video' " +"format." +msgstr "" +"Formato de anuncio de la campaña en LinkedIn. Una campaña solo acepta " +"publicaciones de su formato, y LinkedIn no permite cambiarlo una vez creada " +"la campaña, por lo que una publicación con vídeo necesita una campaña con el" +" formato 'Vídeo único'." + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_group_view_form +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_view_form +msgid "Archive in LinkedIn" +msgstr "Archivar en LinkedIn" + +#. module: social_media_advertising_linkedin +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_ad_archived +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_campaign_archived +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_group_archived +msgid "Archived" +msgstr "Archivada" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,help:social_media_advertising_linkedin.field_social_campaign__unit_cost +msgid "" +"Bid amount per click, impression or other event depending on the pricing " +"model. LinkedIn only applies it with manual, target cost or cost cap " +"bidding; with automatic bidding it is ignored." +msgstr "" +"Importe de la puja por clic, impresión u otro evento según el modelo de " +"precios. LinkedIn solo la aplica con puja manual, target cost o cost cap; " +"con puja automática se ignora." + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields.selection,name:social_media_advertising_linkedin.selection__social_campaign__linkedin_objective__brand_awareness +msgid "Brand awareness" +msgstr "Notoriedad de marca" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields.selection,name:social_media_advertising_linkedin.selection__social_advertising_account__linkedin_type__business +msgid "Business" +msgstr "Empresa" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "Campaign archived on LinkedIn." +msgstr "Campaña archivada en LinkedIn." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "Campaign created on LinkedIn in draft status." +msgstr "Campaña creada en LinkedIn en estado borrador." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "Campaign group archived on LinkedIn." +msgstr "Grupo de campañas archivado en LinkedIn." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "Campaign group created on LinkedIn in draft status." +msgstr "Grupo de campañas creado en LinkedIn en estado borrador." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "Campaign group updated on LinkedIn." +msgstr "Grupo de campañas actualizado en LinkedIn." + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_account_view_form_inherit +msgid "" +"Campaign groups and campaigns of the advertising account will be imported " +"from LinkedIn. Continue?" +msgstr "" +"Se importarán desde LinkedIn los grupos de campañas y las campañas de la " +"cuenta publicitaria. ¿Continuar?" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "Campaign not created on LinkedIn" +msgstr "Campaña no creada en LinkedIn" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "Campaign updated on LinkedIn." +msgstr "Campaña actualizada en LinkedIn." + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields.selection,name:social_media_advertising_linkedin.selection__social_advertising_account__linkedin_status__canceled +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_ad_canceled +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_campaign_canceled +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_group_canceled +msgid "Canceled" +msgstr "Cancelada" + +#. module: social_media_advertising_linkedin +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_campaign_completed +msgid "Completed" +msgstr "Completada" + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_group_view_form +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_view_form +msgid "Create in LinkedIn" +msgstr "Crear en LinkedIn" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_post_account__creative_urn +msgid "Creative Urn" +msgstr "URN de la creatividad" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_campaign__currency_id +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_campaign_group__currency_id +msgid "Currency" +msgstr "Moneda" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_campaign__daily_budget +msgid "Daily Budget" +msgstr "Presupuesto diario" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,help:social_media_advertising_linkedin.field_social_campaign__linkedin_political_intent +msgid "" +"Declaration required by LinkedIn to create a campaign: I confirm this is not" +" political advertising. None of my ads qualify as political advertising " +"under the law of the targeted countries, including EU law for ads targeted " +"to the EU. Change it to 'Political advertising' when the campaign does " +"qualify as such." +msgstr "" +"Declaración que exige LinkedIn para crear una campaña: confirmo que no se " +"trata de publicidad política. Ninguno de mis anuncios se considera " +"publicidad política según la legislación de los países a los que se dirigen," +" incluida la normativa de la UE para los anuncios dirigidos a la UE. " +"Cámbielo a 'Publicidad política' cuando la campaña sí lo sea." + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_group_view_form +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_view_form +msgid "Do you want to update the values on LinkedIn?" +msgstr "¿Desea actualizar los valores en LinkedIn?" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields.selection,name:social_media_advertising_linkedin.selection__social_advertising_account__linkedin_status__draft +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_ad_draft +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_campaign_draft +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_group_draft +msgid "Draft" +msgstr "Borrador" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields.selection,name:social_media_advertising_linkedin.selection__social_campaign__linkedin_objective__engagement +msgid "Engagement" +msgstr "Engagement" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields.selection,name:social_media_advertising_linkedin.selection__social_advertising_account__linkedin_type__enterprise +msgid "Enterprise" +msgstr "Corporativa" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,help:social_media_advertising_linkedin.field_social_advertising_account__linkedin_type +msgid "" +"Enterprise advertising accounts are created by LinkedIn itself and cannot be" +" test accounts." +msgstr "" +"Las cuentas de publicidad Corporativas las crea LinkedIn y no pueden ser " +"cuentas de prueba." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "Error archiving campaign group in Linkedin: %(error)s" +msgstr "Error al archivar el grupo de campañas en LinkedIn: %(error)s" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "Error archiving campaign in Linkedin: %(error)s" +msgstr "Error al archivar la campaña en LinkedIn: %(error)s" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_post_account.py:0 +#, python-format +msgid "Error creating campaign post in Linkedin: %(error)s" +msgstr "Error al crear el post de campaña en LinkedIn: %(error)s" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "Error creating group campaign in Linkedin: %(error)s" +msgstr "Error al crear el grupo de campañas en LinkedIn: %(error)s" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_account.py:0 +#, python-format +msgid "Error get advertising account in Linkedin: %(error)s" +msgstr "Error al obtener la cuenta publicitaria de LinkedIn: %(error)s" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_account.py:0 +#, python-format +msgid "Error getting campaigns from Linkedin: %(error)s" +msgstr "Error al obtener las campañas de LinkedIn: %(error)s" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "Error updating campaign group in Linkedin: %(error)s" +msgstr "Error al actualizar el grupo de campañas en LinkedIn: %(error)s" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "Error updating campaign in Linkedin: %(error)s" +msgstr "Error al actualizar la campaña en LinkedIn: %(error)s" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_account.py:0 +#, python-format +msgid "GET ADS: %(error)s" +msgstr "GET ADS: %(error)s" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_account.py:0 +#, python-format +msgid "GET CAMPAIGNS STATISTICS: %(error)s" +msgstr "GET CAMPAIGNS STATISTICS: %(error)s" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,help:social_media_advertising_linkedin.field_social_campaign__linkedin_objective +msgid "" +"Goal of the campaign on LinkedIn, required for the 'Single video' format:\n" +"- Brand awareness: reach as many people as possible.\n" +"- Video views: get the video of the post watched.\n" +"- Website visits: send traffic to a page.\n" +"- Engagement: get likes, comments and follows.\n" +"LinkedIn has three more objectives (lead generation, website conversions and job applicants) that need a lead gen form, conversion tracking or LinkedIn Talent Solutions, which are configured outside Odoo and are therefore not offered here." +msgstr "" +"Objetivo de la campaña en LinkedIn, obligatorio para el formato 'Vídeo único':\n" +"- Notoriedad de marca: llegar al mayor número de personas posible.\n" +"- Visualizaciones de vídeo: conseguir que se vea el vídeo de la publicación.\n" +"- Visitas al sitio web: enviar tráfico a una página.\n" +"- Interacción: conseguir me gusta, comentarios y seguidores.\n" +"LinkedIn tiene tres objetivos más (generación de contactos, conversiones en el sitio web y candidaturas de empleo) que necesitan un formulario de generación de contactos, seguimiento de conversiones o LinkedIn Talent Solutions, que se configuran fuera de Odoo y por eso no se ofrecen aquí." + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_account_view_form_inherit +msgid "Import campaigns" +msgstr "Importar campañas" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_account.py:0 +#, python-format +msgid "" +"Import kept the local pending changes. LinkedIn values: name: %(name)s, " +"total budget: %(total_budget)s %(currency)s" +msgstr "" +"La importación conservó los cambios locales pendientes. Valores de LinkedIn:" +" nombre: %(name)s, presupuesto total: %(total_budget)s %(currency)s" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_account.py:0 +#, python-format +msgid "" +"Import kept the local pending changes. LinkedIn values: name: %(name)s, unit" +" cost: %(unit_cost)s, daily budget: %(daily_budget)s, campaign group: " +"%(group)s" +msgstr "" +"La importación conservó los cambios locales pendientes. Valores de LinkedIn:" +" nombre: %(name)s, coste unitario: %(unit_cost)s, presupuesto diario: " +"%(daily_budget)s, grupo de campañas: %(group)s" + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_view_form +msgid "LinkedIn" +msgstr "LinkedIn" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_campaign__linkedin_format +msgid "LinkedIn Ad Format" +msgstr "Formato de anuncio de LinkedIn" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_campaign__linkedin_objective +msgid "LinkedIn Objective" +msgstr "Objetivo de LinkedIn" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_advertising_account__linkedin_reference +msgid "LinkedIn Owner" +msgstr "Propietario de LinkedIn" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_campaign__linkedin_needs_update +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_campaign_group__linkedin_needs_update +msgid "LinkedIn Pending Changes" +msgstr "Cambios pendientes de LinkedIn" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_campaign__linkedin_political_intent +msgid "LinkedIn Political Intent" +msgstr "Intención política de LinkedIn" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_advertising_account__linkedin_serving_status +msgid "LinkedIn Serving Status" +msgstr "Estado de publicación de LinkedIn" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_advertising_account__linkedin_status +msgid "LinkedIn Status" +msgstr "Estado de LinkedIn" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_campaign__linkedin_is_test +msgid "LinkedIn Test Campaign" +msgstr "Campaña de prueba de LinkedIn" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_advertising_account__linkedin_type +msgid "LinkedIn Type" +msgstr "Tipo de LinkedIn" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "" +"LinkedIn does not archive a campaign whose campaign group is still in draft " +"status. Activate the group %(group)s in Campaign Manager first, or archive " +"the group itself, which archives its campaigns." +msgstr "" +"LinkedIn no archiva una campaña cuyo grupo de campañas sigue en estado " +"borrador. Active primero el grupo %(group)s en Campaign Manager, o archive " +"el propio grupo, lo que archiva sus campañas." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_post_account.py:0 +#, python-format +msgid "" +"LinkedIn does not sponsor posts with several images: they are published as a" +" multi-image post, a format its API cannot turn into an ad, so this post " +"cannot be linked to the campaign %(campaign)s. Publish it with a single " +"image or without a campaign. LinkedIn offers the Carousel ad format for this" +" case, which this module does not support yet." +msgstr "" +"LinkedIn no patrocina publicaciones con varias imágenes: se publican como " +"publicación de varias imágenes, un formato que su API no puede convertir en " +"anuncio, por lo que esta publicación no se puede vincular a la campaña " +"%(campaign)s. Publíquela con una sola imagen o sin campaña. LinkedIn ofrece " +"el formato de anuncio Carrusel para este caso, que este módulo todavía no " +"admite." + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,help:social_media_advertising_linkedin.field_social_campaign__linkedin_locked +#: model:ir.model.fields,help:social_media_advertising_linkedin.field_social_campaign_group__linkedin_locked +msgid "LinkedIn no longer accepts changes on this record." +msgstr "LinkedIn ya no acepta cambios en este registro." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "LinkedIn requires an objective for the video format." +msgstr "LinkedIn exige un objetivo para el formato de vídeo." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "" +"LinkedIn requires declaring whether the campaign is political advertising." +msgstr "LinkedIn exige declarar si la campaña es publicidad política." + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_campaign__linkedin_locked +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_campaign_group__linkedin_locked +msgid "Linkedin Locked" +msgstr "Bloqueado en LinkedIn" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,help:social_media_advertising_linkedin.field_social_campaign_group__total_budget +msgid "" +"Maximum amount to spend across all the campaigns and creatives of this group" +" for its entire duration. It cannot be lower than the daily budgets of its " +"campaigns." +msgstr "" +"Importe máximo a gastar entre todas las campañas y creatividades de este " +"grupo durante toda su duración. No puede ser inferior a los presupuestos " +"diarios de sus campañas." + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,help:social_media_advertising_linkedin.field_social_campaign__daily_budget +msgid "" +"Maximum amount to spend on this campaign per day. The campaign group total " +"budget limits the overall spending." +msgstr "" +"Importe máximo a gastar en esta campaña por día. El presupuesto total del " +"grupo de campañas limita el gasto global." + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_campaign_group__media_type +msgid "Media Type" +msgstr "Tipo de red social" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_account.py:0 +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#: code:addons/social_media_advertising_linkedin/models/social_post_account.py:0 +#, python-format +msgid "" +"No LinkedIn advertising account is in use for the account %(account)s. Open " +"its Advertising tab, fetch the advertising accounts and choose one." +msgstr "" +"No hay ninguna cuenta de publicidad de LinkedIn en uso para la cuenta " +"%(account)s. Abra su pestaña Publicidad, obtenga las cuentas de publicidad y" +" elija una." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "No LinkedIn social account is available to archive the group." +msgstr "" +"No hay ninguna cuenta social de LinkedIn disponible para archivar el grupo." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "No LinkedIn social account is available to create the group." +msgstr "" +"No hay ninguna cuenta social de LinkedIn disponible para crear el grupo." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "No LinkedIn social account is available to update the group." +msgstr "" +"No hay ninguna cuenta social de LinkedIn disponible para actualizar el " +"grupo." + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields.selection,name:social_media_advertising_linkedin.selection__social_campaign__linkedin_political_intent__not_declared +msgid "Not declared" +msgstr "Sin declarar" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields.selection,name:social_media_advertising_linkedin.selection__social_campaign__linkedin_political_intent__not_political +msgid "Not political advertising" +msgstr "Publicidad no política" + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_group_view_form +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_view_form +msgid "Not synced" +msgstr "Sin sincronizar" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,help:social_media_advertising_linkedin.field_social_advertising_account__linkedin_reference +msgid "" +"Organization or person the advertising account advertises on behalf of." +msgstr "" +"Organización o persona en cuyo nombre anuncia la cuenta de publicidad." + +#. module: social_media_advertising_linkedin +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_ad_paused +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_campaign_paused +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_group_paused +msgid "Paused" +msgstr "Pausada" + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_group_view_form +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_view_form +msgid "Pending changes" +msgstr "Cambios pendientes" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields.selection,name:social_media_advertising_linkedin.selection__social_advertising_account__linkedin_status__pending_deletion +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_ad_pending_deletion +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_campaign_pending_deletion +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_group_pending_deletion +msgid "Pending deletion" +msgstr "Pendiente de eliminación" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields.selection,name:social_media_advertising_linkedin.selection__social_campaign__linkedin_political_intent__political +msgid "Political advertising" +msgstr "Publicidad política" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields.selection,name:social_media_advertising_linkedin.selection__social_advertising_account__linkedin_status__removed +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_ad_removed +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_campaign_removed +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_group_removed +msgid "Removed" +msgstr "Eliminada" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields.selection,name:social_media_advertising_linkedin.selection__social_campaign__linkedin_format__single_video +msgid "Single video" +msgstr "Vídeo único" + +#. module: social_media_advertising_linkedin +#: model:ir.model,name:social_media_advertising_linkedin.model_social_account +msgid "Social Account" +msgstr "Cuenta de red social" + +#. module: social_media_advertising_linkedin +#: model:ir.model,name:social_media_advertising_linkedin.model_social_advertising_account +msgid "Social Advertising Account" +msgstr "Cuenta de publicidad social" + +#. module: social_media_advertising_linkedin +#: model:ir.model,name:social_media_advertising_linkedin.model_social_campaign +msgid "Social Campaign" +msgstr "Campaña social" + +#. module: social_media_advertising_linkedin +#: model:ir.model,name:social_media_advertising_linkedin.model_social_campaign_group +msgid "Social Campaign Group" +msgstr "Grupo de campañas sociales" + +#. module: social_media_advertising_linkedin +#: model:ir.model,name:social_media_advertising_linkedin.model_social_media +msgid "Social Media" +msgstr "Redes sociales" + +#. module: social_media_advertising_linkedin +#: model:ir.model,name:social_media_advertising_linkedin.model_social_post +msgid "Social Post" +msgstr "Post social" + +#. module: social_media_advertising_linkedin +#: model:ir.model,name:social_media_advertising_linkedin.model_social_post_account +msgid "Social Post Account" +msgstr "Post social de la cuenta" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields.selection,name:social_media_advertising_linkedin.selection__social_campaign__linkedin_format__standard_update +msgid "Standard update" +msgstr "Publicación estándar" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,help:social_media_advertising_linkedin.field_social_advertising_account__linkedin_status +msgid "Status of the advertising account on LinkedIn." +msgstr "Estado de la cuenta de publicidad en LinkedIn." + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_group_view_form +msgid "" +"Stops the campaign group on LinkedIn and takes it out of the Campaign " +"Manager lists. LinkedIn does not allow deleting a campaign group, archiving " +"is the only way to end it. LinkedIn applies the same status to the campaigns" +" of the group, except the ones still in draft, so run Import campaigns " +"afterwards to refresh them in Odoo." +msgstr "" +"Detiene el grupo de campañas en LinkedIn y lo saca de las listas de Campaign" +" Manager. LinkedIn no permite eliminar un grupo de campañas, archivarlo es " +"la única forma de terminarlo. LinkedIn aplica el mismo estado a las campañas" +" del grupo, salvo a las que siguen en borrador, así que ejecute después " +"Importar campañas para actualizarlas en Odoo." + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_view_form +msgid "" +"Stops the campaign on LinkedIn and takes it out of the Campaign Manager " +"lists. LinkedIn does not allow deleting a campaign, archiving is the only " +"way to end it, and it refuses to archive a campaign whose campaign group is " +"still in draft status. The campaign is kept in Odoo with its performance " +"data, and it can no longer be modified." +msgstr "" +"Detiene la campaña en LinkedIn y la saca de las listas de Campaign Manager. " +"LinkedIn no permite eliminar una campaña, archivarla es la única forma de " +"terminarla, y se niega a archivar una campaña cuyo grupo de campañas sigue " +"en estado borrador. La campaña se conserva en Odoo con sus datos de " +"rendimiento, y ya no se puede modificar." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "" +"The LinkedIn campaign group stage with code %(code)s is missing. Update the " +"social_media_advertising_linkedin module to restore the LinkedIn stages." +msgstr "" +"Falta la etapa de grupo de campañas de LinkedIn con el código %(code)s. " +"Actualice el módulo social_media_advertising_linkedin para restaurar las " +"etapas de LinkedIn." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "" +"The LinkedIn campaign stage with code %(code)s is missing. Update the " +"social_media_advertising_linkedin module to restore the LinkedIn stages." +msgstr "" +"Falta la etapa de campaña de LinkedIn con el código %(code)s. Actualice el " +"módulo social_media_advertising_linkedin para restaurar las etapas de " +"LinkedIn." + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_view_form +msgid "" +"The LinkedIn status of this campaign does not allow\n" +" modifications." +msgstr "" +"El estado en LinkedIn de esta campaña no permite\n" +" modificaciones." + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_group_view_form +msgid "" +"The LinkedIn status of this campaign group does not allow\n" +" modifications." +msgstr "" +"El estado en LinkedIn de este grupo de campañas no permite\n" +" modificaciones." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "The campaign %(campaign)s does not belong to a campaign group." +msgstr "La campaña %(campaign)s no pertenece a ningún grupo de campañas." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_post_account.py:0 +#, python-format +msgid "" +"The campaign %(campaign)s has not been created on LinkedIn yet. Use the " +"'Create in LinkedIn' button on the campaign before posting." +msgstr "" +"La campaña %(campaign)s aún no se ha creado en LinkedIn. Use el botón 'Crear" +" en LinkedIn' de la campaña antes de publicar." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_post_account.py:0 +#, python-format +msgid "" +"The campaign %(campaign)s uses the 'Single video' format, so it only accepts" +" posts containing a video." +msgstr "" +"La campaña %(campaign)s usa el formato 'Vídeo único', por lo que solo acepta" +" publicaciones que contengan un vídeo." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "" +"The campaign %(names)s cannot be modified because of its LinkedIn status " +"(%(status)s)." +msgstr "" +"La campaña %(names)s no se puede modificar por su estado en LinkedIn " +"(%(status)s)." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "" +"The campaign cannot be archived because of its LinkedIn status (%(status)s)." +msgstr "" +"La campaña no se puede archivar por su estado en LinkedIn (%(status)s)." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "" +"The campaign cannot be updated because of its LinkedIn status (%(status)s)." +msgstr "" +"La campaña no se puede actualizar por su estado en LinkedIn (%(status)s)." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "The campaign could not be created on LinkedIn: %(error)s" +msgstr "No se pudo crear la campaña en LinkedIn: %(error)s" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_post_account.py:0 +#, python-format +msgid "" +"The campaign could not be generated for the post, please try again later." +msgstr "" +"La campaña no se pudo generar para el post, inténtelo de nuevo más tarde." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "The campaign daily budget must be positive." +msgstr "El presupuesto diario de la campaña debe ser positivo." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "The campaign does not exist on LinkedIn yet." +msgstr "La campaña aún no existe en LinkedIn." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "" +"The campaign group %(group)s does not exist on LinkedIn. Create or import it" +" before updating the campaign." +msgstr "" +"El grupo de campañas %(group)s no existe en LinkedIn. Créelo o impórtelo " +"antes de actualizar la campaña." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "" +"The campaign group %(group)s is not linked to any LinkedIn account and the " +"database holds several of them. Set the account on one of its campaigns to " +"choose the advertising account to use." +msgstr "" +"El grupo de campañas %(group)s no está vinculado a ninguna cuenta de " +"LinkedIn y la base de datos tiene varias. Indique la cuenta en una de sus " +"campañas para elegir la cuenta publicitaria que se debe usar." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "" +"The campaign group %(names)s cannot be modified because of its LinkedIn " +"status (%(status)s)." +msgstr "" +"El grupo de campañas %(names)s no se puede modificar por su estado en " +"LinkedIn (%(status)s)." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "The campaign group already exists on LinkedIn." +msgstr "El grupo de campañas ya existe en LinkedIn." + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_view_form +msgid "" +"The campaign group and the campaign will be created on LinkedIn in draft " +"status. Continue?" +msgstr "" +"El grupo de campañas y la campaña se crearán en LinkedIn en estado borrador." +" ¿Continuar?" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "" +"The campaign group cannot be archived because of its LinkedIn status " +"(%(status)s)." +msgstr "" +"El grupo de campañas no se puede archivar por su estado en LinkedIn " +"(%(status)s)." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "" +"The campaign group cannot be updated because of its LinkedIn status " +"(%(status)s)." +msgstr "" +"El grupo de campañas no se puede actualizar por su estado en LinkedIn " +"(%(status)s)." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "The campaign group does not exist on LinkedIn yet." +msgstr "El grupo de campañas aún no existe en LinkedIn." + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_group_view_form +msgid "" +"The campaign group has local changes that are not\n" +" synchronized with LinkedIn. Use the Update in\n" +" LinkedIn button to push them." +msgstr "" +"El grupo de campañas tiene cambios locales que no están\n" +" sincronizados con LinkedIn. Use el botón Actualizar en\n" +" LinkedIn para enviarlos." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "The campaign group must have a currency." +msgstr "El grupo de campañas debe tener una moneda." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "The campaign group total budget must be positive." +msgstr "El presupuesto total del grupo de campañas debe ser positivo." + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,help:social_media_advertising_linkedin.field_social_campaign_group__linkedin_needs_update +msgid "" +"The campaign group was modified in Odoo after being synchronized with " +"LinkedIn. Use the 'Update in LinkedIn' button to push the local values." +msgstr "" +"El grupo de campañas se modificó en Odoo después de sincronizarse con " +"LinkedIn. Use el botón 'Actualizar en LinkedIn' para enviar los valores " +"locales." + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_group_view_form +msgid "" +"The campaign group will be archived on LinkedIn, and LinkedIn will archive " +"its campaigns too, except the ones still in draft status. It cannot be " +"reactivated from Odoo. Continue?" +msgstr "" +"El grupo de campañas se archivará en LinkedIn, y LinkedIn archivará también " +"sus campañas, salvo las que sigan en estado borrador. No se puede reactivar " +"desde Odoo. ¿Continuar?" + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_group_view_form +msgid "" +"The campaign group will be created on LinkedIn in draft status. Continue?" +msgstr "" +"El grupo de campañas se creará en LinkedIn en estado borrador. ¿Continuar?" + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_view_form +msgid "" +"The campaign has local changes that are not synchronized\n" +" with LinkedIn. Use the Update in LinkedIn button to\n" +" push them." +msgstr "" +"La campaña tiene cambios locales que no están sincronizados\n" +" con LinkedIn. Use el botón Actualizar en LinkedIn para\n" +" enviarlos." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "The campaign media must be LinkedIn." +msgstr "El medio de la campaña debe ser LinkedIn." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "The campaign must belong to a campaign group." +msgstr "La campaña debe pertenecer a un grupo de campañas." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "The campaign must have a social account and a currency." +msgstr "La campaña debe tener una cuenta social y una moneda." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "The campaign must have a social account." +msgstr "La campaña debe tener una cuenta social." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "The campaign unit cost must be positive." +msgstr "El coste unitario de la campaña debe ser positivo." + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,help:social_media_advertising_linkedin.field_social_campaign__linkedin_needs_update +msgid "" +"The campaign was modified in Odoo after being synchronized with LinkedIn. " +"Use the 'Update in LinkedIn' button to push the local values." +msgstr "" +"La campaña se modificó en Odoo después de sincronizarse con LinkedIn. Use el" +" botón 'Actualizar en LinkedIn' para enviar los valores locales." + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_view_form +msgid "" +"The campaign will be archived on LinkedIn and will stop running. It cannot " +"be reactivated from Odoo. It stays in Odoo with its performance data. " +"Continue?" +msgstr "" +"La campaña se archivará en LinkedIn y dejará de ejecutarse. No se puede " +"reactivar desde Odoo. Se mantiene en Odoo con sus datos de rendimiento. " +"¿Continuar?" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "" +"The campaigns of the group %(group)s point to several LinkedIn accounts " +"(%(accounts)s). Leave a single one so the advertising account to use is " +"unambiguous." +msgstr "" +"Las campañas del grupo %(group)s apuntan a varias cuentas de LinkedIn " +"(%(accounts)s). Deje una sola para que la cuenta publicitaria que se debe " +"usar no sea ambigua." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "" +"The daily budgets of the campaigns of the group %(group)s add up to " +"%(daily_budgets)s, which exceeds its total budget of %(total_budget)s." +msgstr "" +"Los presupuestos diarios de las campañas del grupo %(group)s suman " +"%(daily_budgets)s, lo que supera su presupuesto total de %(total_budget)s." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_post_account.py:0 +#, python-format +msgid "" +"The post contains a video, so it needs a campaign of the 'Single video' " +"format. The campaign %(campaign)s uses the 'Standard update' format and " +"LinkedIn does not allow changing it once the campaign is created." +msgstr "" +"La publicación contiene un vídeo, por lo que necesita una campaña con el " +"formato 'Vídeo único'. La campaña %(campaign)s usa el formato 'Publicación " +"estándar' y LinkedIn no permite cambiarlo una vez creada la campaña." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_post_account.py:0 +#, python-format +msgid "" +"The post was published on LinkedIn but the campaign creative could not be " +"created. Check the Ads permissions of the account." +msgstr "" +"El post se publicó en LinkedIn pero la creatividad de la campaña no se pudo " +"crear. Revise los permisos de Ads de la cuenta." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_account.py:0 +#, python-format +msgid "" +"This campaign group was deleted on LinkedIn. It is kept in Odoo as history " +"because LinkedIn still returns it with its performance data." +msgstr "" +"Este grupo de campañas se eliminó en LinkedIn. Se conserva en Odoo como " +"histórico porque LinkedIn lo sigue devolviendo con sus datos de rendimiento." + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_account.py:0 +#, python-format +msgid "" +"This campaign was deleted on LinkedIn. It is kept in Odoo as history because" +" LinkedIn still returns it with its performance data." +msgstr "" +"Esta campaña se eliminó en LinkedIn. Se conserva en Odoo como histórico " +"porque LinkedIn la sigue devolviendo con sus datos de rendimiento." + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_campaign_group__total_budget +msgid "Total Budget" +msgstr "Presupuesto total" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_campaign__unit_cost +msgid "Unit Cost" +msgstr "Costo unitario" + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_group_view_form +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_view_form +msgid "Update in LinkedIn" +msgstr "Actualizar en LinkedIn" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields.selection,name:social_media_advertising_linkedin.selection__social_campaign__linkedin_objective__video_view +msgid "Video views" +msgstr "Visualizaciones de vídeo" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields.selection,name:social_media_advertising_linkedin.selection__social_campaign__linkedin_objective__website_visit +msgid "Website visits" +msgstr "Visitas al sitio web" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,help:social_media_advertising_linkedin.field_social_campaign__linkedin_is_test +msgid "Whether LinkedIn flags this campaign as a test campaign." +msgstr "Indica si LinkedIn marca esta campaña como campaña de prueba." + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,help:social_media_advertising_linkedin.field_social_advertising_account__linkedin_serving_status +msgid "" +"Why LinkedIn is serving the ads of this advertising account, or not: " +"RUNNABLE when it is eligible, otherwise the reasons why it is not, such as " +"BILLING_HOLD or ACCOUNT_TOTAL_BUDGET_HOLD." +msgstr "" +"Por qué LinkedIn publica o no los anuncios de esta cuenta de publicidad: " +"RUNNABLE cuando es elegible, en caso contrario los motivos por los que no lo" +" es, como BILLING_HOLD o ACCOUNT_TOTAL_BUDGET_HOLD." diff --git a/social_media_advertising_linkedin/i18n/social_media_advertising_linkedin.pot b/social_media_advertising_linkedin/i18n/social_media_advertising_linkedin.pot new file mode 100644 index 0000000000..8dbf080e5c --- /dev/null +++ b/social_media_advertising_linkedin/i18n/social_media_advertising_linkedin.pot @@ -0,0 +1,1000 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * social_media_advertising_linkedin +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-03 02:57+0000\n" +"PO-Revision-Date: 2026-08-03 02:57+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_account.py:0 +#, python-format +msgid "" +"%(groups)s campaign group(s), %(campaigns)s campaign(s) and %(ads)s " +"sponsored post(s) imported from LinkedIn." +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "%(message_error)s %(error_response)s" +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "A LinkedIn campaign can only have one LinkedIn account." +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_post.py:0 +#, python-format +msgid "" +"A post with several images is published as a multi-image post, and LinkedIn " +"does not sponsor that format, so this post cannot be linked to the campaign " +"%(campaign)s. Leave a single image or remove the campaign." +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields.selection,name:social_media_advertising_linkedin.selection__social_advertising_account__linkedin_status__active +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_ad_active +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_campaign_active +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_group_active +msgid "Active" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,help:social_media_advertising_linkedin.field_social_campaign__linkedin_format +msgid "" +"Ad format of the campaign on LinkedIn. A campaign only accepts posts of its " +"format, and LinkedIn does not allow changing it once the campaign is " +"created, so a post with a video needs a campaign of the 'Single video' " +"format." +msgstr "" + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_group_view_form +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_view_form +msgid "Archive in LinkedIn" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_ad_archived +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_campaign_archived +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_group_archived +msgid "Archived" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,help:social_media_advertising_linkedin.field_social_campaign__unit_cost +msgid "" +"Bid amount per click, impression or other event depending on the pricing " +"model. LinkedIn only applies it with manual, target cost or cost cap " +"bidding; with automatic bidding it is ignored." +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields.selection,name:social_media_advertising_linkedin.selection__social_campaign__linkedin_objective__brand_awareness +msgid "Brand awareness" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields.selection,name:social_media_advertising_linkedin.selection__social_advertising_account__linkedin_type__business +msgid "Business" +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "Campaign archived on LinkedIn." +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "Campaign created on LinkedIn in draft status." +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "Campaign group archived on LinkedIn." +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "Campaign group created on LinkedIn in draft status." +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "Campaign group updated on LinkedIn." +msgstr "" + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_account_view_form_inherit +msgid "" +"Campaign groups and campaigns of the advertising account will be imported " +"from LinkedIn. Continue?" +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "Campaign not created on LinkedIn" +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "Campaign updated on LinkedIn." +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields.selection,name:social_media_advertising_linkedin.selection__social_advertising_account__linkedin_status__canceled +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_ad_canceled +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_campaign_canceled +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_group_canceled +msgid "Canceled" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_campaign_completed +msgid "Completed" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_group_view_form +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_view_form +msgid "Create in LinkedIn" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_post_account__creative_urn +msgid "Creative Urn" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_campaign__currency_id +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_campaign_group__currency_id +msgid "Currency" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_campaign__daily_budget +msgid "Daily Budget" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,help:social_media_advertising_linkedin.field_social_campaign__linkedin_political_intent +msgid "" +"Declaration required by LinkedIn to create a campaign: I confirm this is not" +" political advertising. None of my ads qualify as political advertising " +"under the law of the targeted countries, including EU law for ads targeted " +"to the EU. Change it to 'Political advertising' when the campaign does " +"qualify as such." +msgstr "" + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_group_view_form +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_view_form +msgid "Do you want to update the values on LinkedIn?" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields.selection,name:social_media_advertising_linkedin.selection__social_advertising_account__linkedin_status__draft +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_ad_draft +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_campaign_draft +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_group_draft +msgid "Draft" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields.selection,name:social_media_advertising_linkedin.selection__social_campaign__linkedin_objective__engagement +msgid "Engagement" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields.selection,name:social_media_advertising_linkedin.selection__social_advertising_account__linkedin_type__enterprise +msgid "Enterprise" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,help:social_media_advertising_linkedin.field_social_advertising_account__linkedin_type +msgid "" +"Enterprise advertising accounts are created by LinkedIn itself and cannot be" +" test accounts." +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "Error archiving campaign group in Linkedin: %(error)s" +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "Error archiving campaign in Linkedin: %(error)s" +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_post_account.py:0 +#, python-format +msgid "Error creating campaign post in Linkedin: %(error)s" +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "Error creating group campaign in Linkedin: %(error)s" +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_account.py:0 +#: code:addons/social_media_advertising_linkedin/models/social_account.py:0 +#, python-format +msgid "Error get advertising account in Linkedin: %(error)s" +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_account.py:0 +#, python-format +msgid "Error getting campaigns from Linkedin: %(error)s" +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "Error updating campaign group in Linkedin: %(error)s" +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "Error updating campaign in Linkedin: %(error)s" +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_account.py:0 +#, python-format +msgid "GET ADS: %(error)s" +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_account.py:0 +#, python-format +msgid "GET CAMPAIGNS STATISTICS: %(error)s" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,help:social_media_advertising_linkedin.field_social_campaign__linkedin_objective +msgid "" +"Goal of the campaign on LinkedIn, required for the 'Single video' format:\n" +"- Brand awareness: reach as many people as possible.\n" +"- Video views: get the video of the post watched.\n" +"- Website visits: send traffic to a page.\n" +"- Engagement: get likes, comments and follows.\n" +"LinkedIn has three more objectives (lead generation, website conversions and job applicants) that need a lead gen form, conversion tracking or LinkedIn Talent Solutions, which are configured outside Odoo and are therefore not offered here." +msgstr "" + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_account_view_form_inherit +msgid "Import campaigns" +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_account.py:0 +#, python-format +msgid "" +"Import kept the local pending changes. LinkedIn values: name: %(name)s, " +"total budget: %(total_budget)s %(currency)s" +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_account.py:0 +#, python-format +msgid "" +"Import kept the local pending changes. LinkedIn values: name: %(name)s, unit" +" cost: %(unit_cost)s, daily budget: %(daily_budget)s, campaign group: " +"%(group)s" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_view_form +msgid "LinkedIn" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_campaign__linkedin_format +msgid "LinkedIn Ad Format" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_campaign__linkedin_objective +msgid "LinkedIn Objective" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_advertising_account__linkedin_reference +msgid "LinkedIn Owner" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_campaign__linkedin_needs_update +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_campaign_group__linkedin_needs_update +msgid "LinkedIn Pending Changes" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_campaign__linkedin_political_intent +msgid "LinkedIn Political Intent" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_advertising_account__linkedin_serving_status +msgid "LinkedIn Serving Status" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_advertising_account__linkedin_status +msgid "LinkedIn Status" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_campaign__linkedin_is_test +msgid "LinkedIn Test Campaign" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_advertising_account__linkedin_type +msgid "LinkedIn Type" +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "" +"LinkedIn does not archive a campaign whose campaign group is still in draft " +"status. Activate the group %(group)s in Campaign Manager first, or archive " +"the group itself, which archives its campaigns." +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_post_account.py:0 +#, python-format +msgid "" +"LinkedIn does not sponsor posts with several images: they are published as a" +" multi-image post, a format its API cannot turn into an ad, so this post " +"cannot be linked to the campaign %(campaign)s. Publish it with a single " +"image or without a campaign. LinkedIn offers the Carousel ad format for this" +" case, which this module does not support yet." +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,help:social_media_advertising_linkedin.field_social_campaign__linkedin_locked +#: model:ir.model.fields,help:social_media_advertising_linkedin.field_social_campaign_group__linkedin_locked +msgid "LinkedIn no longer accepts changes on this record." +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "LinkedIn requires an objective for the video format." +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "" +"LinkedIn requires declaring whether the campaign is political advertising." +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_campaign__linkedin_locked +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_campaign_group__linkedin_locked +msgid "Linkedin Locked" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,help:social_media_advertising_linkedin.field_social_campaign_group__total_budget +msgid "" +"Maximum amount to spend across all the campaigns and creatives of this group" +" for its entire duration. It cannot be lower than the daily budgets of its " +"campaigns." +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,help:social_media_advertising_linkedin.field_social_campaign__daily_budget +msgid "" +"Maximum amount to spend on this campaign per day. The campaign group total " +"budget limits the overall spending." +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_campaign_group__media_type +msgid "Media Type" +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_account.py:0 +#: code:addons/social_media_advertising_linkedin/models/social_account.py:0 +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#: code:addons/social_media_advertising_linkedin/models/social_post_account.py:0 +#, python-format +msgid "" +"No LinkedIn advertising account is in use for the account %(account)s. Open " +"its Advertising tab, fetch the advertising accounts and choose one." +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "No LinkedIn social account is available to archive the group." +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "No LinkedIn social account is available to create the group." +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "No LinkedIn social account is available to update the group." +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields.selection,name:social_media_advertising_linkedin.selection__social_campaign__linkedin_political_intent__not_declared +msgid "Not declared" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields.selection,name:social_media_advertising_linkedin.selection__social_campaign__linkedin_political_intent__not_political +msgid "Not political advertising" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_group_view_form +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_view_form +msgid "Not synced" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,help:social_media_advertising_linkedin.field_social_advertising_account__linkedin_reference +msgid "" +"Organization or person the advertising account advertises on behalf of." +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_ad_paused +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_campaign_paused +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_group_paused +msgid "Paused" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_group_view_form +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_view_form +msgid "Pending changes" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields.selection,name:social_media_advertising_linkedin.selection__social_advertising_account__linkedin_status__pending_deletion +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_ad_pending_deletion +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_campaign_pending_deletion +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_group_pending_deletion +msgid "Pending deletion" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields.selection,name:social_media_advertising_linkedin.selection__social_campaign__linkedin_political_intent__political +msgid "Political advertising" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields.selection,name:social_media_advertising_linkedin.selection__social_advertising_account__linkedin_status__removed +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_ad_removed +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_campaign_removed +#: model:social.stage,name:social_media_advertising_linkedin.social_stage_linkedin_group_removed +msgid "Removed" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields.selection,name:social_media_advertising_linkedin.selection__social_campaign__linkedin_format__single_video +msgid "Single video" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model,name:social_media_advertising_linkedin.model_social_account +msgid "Social Account" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model,name:social_media_advertising_linkedin.model_social_advertising_account +msgid "Social Advertising Account" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model,name:social_media_advertising_linkedin.model_social_campaign +msgid "Social Campaign" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model,name:social_media_advertising_linkedin.model_social_campaign_group +msgid "Social Campaign Group" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model,name:social_media_advertising_linkedin.model_social_media +msgid "Social Media" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model,name:social_media_advertising_linkedin.model_social_post +msgid "Social Post" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model,name:social_media_advertising_linkedin.model_social_post_account +msgid "Social Post Account" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields.selection,name:social_media_advertising_linkedin.selection__social_campaign__linkedin_format__standard_update +msgid "Standard update" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,help:social_media_advertising_linkedin.field_social_advertising_account__linkedin_status +msgid "Status of the advertising account on LinkedIn." +msgstr "" + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_group_view_form +msgid "" +"Stops the campaign group on LinkedIn and takes it out of the Campaign " +"Manager lists. LinkedIn does not allow deleting a campaign group, archiving " +"is the only way to end it. LinkedIn applies the same status to the campaigns" +" of the group, except the ones still in draft, so run Import campaigns " +"afterwards to refresh them in Odoo." +msgstr "" + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_view_form +msgid "" +"Stops the campaign on LinkedIn and takes it out of the Campaign Manager " +"lists. LinkedIn does not allow deleting a campaign, archiving is the only " +"way to end it, and it refuses to archive a campaign whose campaign group is " +"still in draft status. The campaign is kept in Odoo with its performance " +"data, and it can no longer be modified." +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "" +"The LinkedIn campaign group stage with code %(code)s is missing. Update the " +"social_media_advertising_linkedin module to restore the LinkedIn stages." +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "" +"The LinkedIn campaign stage with code %(code)s is missing. Update the " +"social_media_advertising_linkedin module to restore the LinkedIn stages." +msgstr "" + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_view_form +msgid "" +"The LinkedIn status of this campaign does not allow\n" +" modifications." +msgstr "" + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_group_view_form +msgid "" +"The LinkedIn status of this campaign group does not allow\n" +" modifications." +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "The campaign %(campaign)s does not belong to a campaign group." +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_post_account.py:0 +#: code:addons/social_media_advertising_linkedin/models/social_post_account.py:0 +#, python-format +msgid "" +"The campaign %(campaign)s has not been created on LinkedIn yet. Use the " +"'Create in LinkedIn' button on the campaign before posting." +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_post_account.py:0 +#, python-format +msgid "" +"The campaign %(campaign)s uses the 'Single video' format, so it only accepts" +" posts containing a video." +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "" +"The campaign %(names)s cannot be modified because of its LinkedIn status " +"(%(status)s)." +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "" +"The campaign cannot be archived because of its LinkedIn status (%(status)s)." +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "" +"The campaign cannot be updated because of its LinkedIn status (%(status)s)." +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "The campaign could not be created on LinkedIn: %(error)s" +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_post_account.py:0 +#, python-format +msgid "" +"The campaign could not be generated for the post, please try again later." +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "The campaign daily budget must be positive." +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "The campaign does not exist on LinkedIn yet." +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "" +"The campaign group %(group)s does not exist on LinkedIn. Create or import it" +" before updating the campaign." +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "" +"The campaign group %(group)s is not linked to any LinkedIn account and the " +"database holds several of them. Set the account on one of its campaigns to " +"choose the advertising account to use." +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "" +"The campaign group %(names)s cannot be modified because of its LinkedIn " +"status (%(status)s)." +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "The campaign group already exists on LinkedIn." +msgstr "" + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_view_form +msgid "" +"The campaign group and the campaign will be created on LinkedIn in draft " +"status. Continue?" +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "" +"The campaign group cannot be archived because of its LinkedIn status " +"(%(status)s)." +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "" +"The campaign group cannot be updated because of its LinkedIn status " +"(%(status)s)." +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "The campaign group does not exist on LinkedIn yet." +msgstr "" + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_group_view_form +msgid "" +"The campaign group has local changes that are not\n" +" synchronized with LinkedIn. Use the Update in\n" +" LinkedIn button to push them." +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "The campaign group must have a currency." +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "The campaign group total budget must be positive." +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,help:social_media_advertising_linkedin.field_social_campaign_group__linkedin_needs_update +msgid "" +"The campaign group was modified in Odoo after being synchronized with " +"LinkedIn. Use the 'Update in LinkedIn' button to push the local values." +msgstr "" + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_group_view_form +msgid "" +"The campaign group will be archived on LinkedIn, and LinkedIn will archive " +"its campaigns too, except the ones still in draft status. It cannot be " +"reactivated from Odoo. Continue?" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_group_view_form +msgid "" +"The campaign group will be created on LinkedIn in draft status. Continue?" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_view_form +msgid "" +"The campaign has local changes that are not synchronized\n" +" with LinkedIn. Use the Update in LinkedIn button to\n" +" push them." +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "The campaign media must be LinkedIn." +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "The campaign must belong to a campaign group." +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "The campaign must have a social account and a currency." +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "The campaign must have a social account." +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign.py:0 +#, python-format +msgid "The campaign unit cost must be positive." +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,help:social_media_advertising_linkedin.field_social_campaign__linkedin_needs_update +msgid "" +"The campaign was modified in Odoo after being synchronized with LinkedIn. " +"Use the 'Update in LinkedIn' button to push the local values." +msgstr "" + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_view_form +msgid "" +"The campaign will be archived on LinkedIn and will stop running. It cannot " +"be reactivated from Odoo. It stays in Odoo with its performance data. " +"Continue?" +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "" +"The campaigns of the group %(group)s point to several LinkedIn accounts " +"(%(accounts)s). Leave a single one so the advertising account to use is " +"unambiguous." +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_campaign_group.py:0 +#, python-format +msgid "" +"The daily budgets of the campaigns of the group %(group)s add up to " +"%(daily_budgets)s, which exceeds its total budget of %(total_budget)s." +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_post_account.py:0 +#, python-format +msgid "" +"The post contains a video, so it needs a campaign of the 'Single video' " +"format. The campaign %(campaign)s uses the 'Standard update' format and " +"LinkedIn does not allow changing it once the campaign is created." +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_post_account.py:0 +#, python-format +msgid "" +"The post was published on LinkedIn but the campaign creative could not be " +"created. Check the Ads permissions of the account." +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_account.py:0 +#, python-format +msgid "" +"This campaign group was deleted on LinkedIn. It is kept in Odoo as history " +"because LinkedIn still returns it with its performance data." +msgstr "" + +#. module: social_media_advertising_linkedin +#. odoo-python +#: code:addons/social_media_advertising_linkedin/models/social_account.py:0 +#, python-format +msgid "" +"This campaign was deleted on LinkedIn. It is kept in Odoo as history because" +" LinkedIn still returns it with its performance data." +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_campaign_group__total_budget +msgid "Total Budget" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,field_description:social_media_advertising_linkedin.field_social_campaign__unit_cost +msgid "Unit Cost" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_group_view_form +#: model_terms:ir.ui.view,arch_db:social_media_advertising_linkedin.social_campaign_view_form +msgid "Update in LinkedIn" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields.selection,name:social_media_advertising_linkedin.selection__social_campaign__linkedin_objective__video_view +msgid "Video views" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields.selection,name:social_media_advertising_linkedin.selection__social_campaign__linkedin_objective__website_visit +msgid "Website visits" +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,help:social_media_advertising_linkedin.field_social_campaign__linkedin_is_test +msgid "Whether LinkedIn flags this campaign as a test campaign." +msgstr "" + +#. module: social_media_advertising_linkedin +#: model:ir.model.fields,help:social_media_advertising_linkedin.field_social_advertising_account__linkedin_serving_status +msgid "" +"Why LinkedIn is serving the ads of this advertising account, or not: " +"RUNNABLE when it is eligible, otherwise the reasons why it is not, such as " +"BILLING_HOLD or ACCOUNT_TOTAL_BUDGET_HOLD." +msgstr "" diff --git a/social_media_advertising_linkedin/models/__init__.py b/social_media_advertising_linkedin/models/__init__.py new file mode 100644 index 0000000000..3820458f7f --- /dev/null +++ b/social_media_advertising_linkedin/models/__init__.py @@ -0,0 +1,10 @@ +# Copyright 2026 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import social_media +from . import social_campaign +from . import social_campaign_group +from . import social_advertising_account +from . import social_account +from . import social_post +from . import social_post_account diff --git a/social_media_advertising_linkedin/models/social_account.py b/social_media_advertising_linkedin/models/social_account.py new file mode 100644 index 0000000000..fdfc7f9998 --- /dev/null +++ b/social_media_advertising_linkedin/models/social_account.py @@ -0,0 +1,804 @@ +# Copyright 2026 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import itertools +import logging +from urllib.parse import quote + +from odoo import Command, _, models +from odoo.exceptions import UserError +from odoo.tools import DEFAULT_SERVER_DATE_FORMAT + +from odoo.addons.social_media_base.social_utils import ( + _generate_timestamps, + convert_to_date, +) + +from ..social_advertising_linkedin_utils import ( + _ENDPOINT_AD_ACCOUNT_USERS_LINKEDIN, + _ENDPOINT_AD_ACCOUNTS_LINKEDIN, + _ENDPOINT_AD_ANALYTICS_LINKEDIN, + _ENDPOINT_AD_CAMPAIGN_GROUPS_LINKEDIN, + _ENDPOINT_AD_CAMPAIGNS_LINKEDIN, + _FIELDS_CAMPAIGN_LINKEDIN, + _FIELDS_STATISTIC_LINKEDIN, + _PAGE_SIZE_LINKEDIN, + _URL_CAMPAIGN_MANAGER_LINKEDIN, +) +from .social_campaign import LINKEDIN_DELETED_CODES + +_logger = logging.getLogger(__name__) + + +class SocialAccount(models.Model): + """LinkedIn Ads side of a social media account.""" + + _inherit = "social.account" + + def _advertising_media_types(self): + return super()._advertising_media_types() + ["linkedin"] + + def _fetch_advertising_accounts(self): + """Return every LinkedIn advertising account the member may reach.""" + res = super()._fetch_advertising_accounts() + if self.media_id.media_type != "linkedin": + return res + account_urns = dict.fromkeys(self._fetch_linkedin_ad_account_urns()) + return res + [ + self._prepare_linkedin_advertising_account( + account_urn, self._fetch_linkedin_ad_account(account_urn) + ) + for account_urn in account_urns + ] + + def _prepare_linkedin_advertising_account(self, account_urn, data): + """Map an ``adAccounts`` element to a ``social.advertising.account``. + + :param account_urn: The advertising account URN. + :param data: The ``adAccounts`` element of that URN. + :rtype: dict + """ + currency = self.env["res.currency"].search( + [("name", "=", data.get("currency"))], limit=1 + ) + return { + "remote_ref": account_urn, + "name": data.get("name") or account_urn.split(":")[-1], + "environment": "test" if data.get("test") else "production", + "currency_id": currency.id, + "linkedin_status": data.get("status") or False, + "linkedin_type": data.get("type") or False, + "linkedin_reference": data.get("reference") or "", + "linkedin_serving_status": ", ".join(data.get("servingStatuses") or []), + } + + def _get_linkedin_advertising_account(self): + """Return the advertising account URN this account works with. + + Nothing is resolved on the fly: the advertising account is the one + the user marked as in use. Campaigns, campaign groups and ads all + belong to a single advertising account on LinkedIn, so guessing one + would silently work against the wrong advertiser. + + :return: The advertising account URN or False when none is in use. + :rtype: str | bool + """ + return self.advertising_account_urn + + def _fetch_linkedin_ad_account_urns(self): + """Return the advertising accounts the authorized member can reach. + + The ``search`` finder of ``adAccounts`` answers only to the partners + of the Marketing API (``partnerApiAdAccounts``), so the accounts are + discovered through the roles of the member instead, which every + application with the Advertising API product may read. + + :return: The advertising account URNs. + :rtype: list + """ + urns = [] + start = 0 + while True: + response = self._request_linkedin( + endpoint=_ENDPOINT_AD_ACCOUNT_USERS_LINKEDIN, + headers=self.media_id._get_linkedin_headers(self.sudo().access_token), + params_fields=["q", "start", "count"], + params_values={ + "q": "authenticatedUser", + "start": start, + "count": _PAGE_SIZE_LINKEDIN, + }, + return_json=False, + ) + if response.status_code != 200: + raise UserError( + _( + "Error get advertising account in Linkedin: %(error)s", + error=self._linkedin_error_message(response), + ) + ) + data = response.json() + elements = data.get("elements", []) + urns += [ + element["account"] for element in elements if element.get("account") + ] + start += _PAGE_SIZE_LINKEDIN + if not elements or start >= data.get("paging", {}).get("total", 0): + break + return urns + + def _fetch_linkedin_ad_account(self, account_urn): + """Return the advertising account of a URN. + + Its ``test`` flag is what tells the test accounts from the production + ones, and the role of the member does not carry it. + + :rtype: dict + """ + response = self._request_linkedin( + endpoint=(f"{_ENDPOINT_AD_ACCOUNTS_LINKEDIN}/{account_urn.split(':')[-1]}"), + headers=self.media_id._get_linkedin_headers(self.sudo().access_token), + return_json=False, + ) + if response.status_code != 200: + raise UserError( + _( + "Error get advertising account in Linkedin: %(error)s", + error=self._linkedin_error_message(response), + ) + ) + return response.json() + + def _linkedin_is_test(self): + """Return the ``test`` flag of the finders, as LinkedIn spells it. + + LinkedIn only returns the entities matching the requested flag, so it + must follow the environment of the account: a production account must + never see the test entities of the application. + + :rtype: str + """ + return "true" if self.environment == "test" else "false" + + def _get_linkedin_ad_account_id(self): + """Return the identifier of the advertising account, without its URN. + + :return: The identifier or False when the account has no advertising + account. + :rtype: str | bool + """ + advertising_account = self._get_linkedin_advertising_account() + return advertising_account.split(":")[-1] if advertising_account else False + + def _require_linkedin_ad_account_id(self): + """Return the identifier of the advertising account, or raise. + + Every campaign and campaign group endpoint is scoped by advertising + account, so an account without one cannot write anything on LinkedIn. + + :rtype: str + """ + ad_account_id = self._get_linkedin_ad_account_id() + if not ad_account_id: + raise UserError( + _( + "No LinkedIn advertising account is in use for the " + "account %(account)s. Open its Advertising tab, " + "fetch the advertising accounts and choose one.", + account=self.display_name, + ) + ) + return ad_account_id + + def _fetch_linkedin_creatives(self, campaign_urns=None): + """Fetch the creatives of the advertising account. + + The Creatives API replaces ``adCreativesV2``: it answers the status + set by the advertiser and accepts both share and ugcPost references, + and it paginates with a cursor instead of an index. + + :param campaign_urns: Restrict the search to these campaign URNs. + :return: The creative elements, filtered by the environment of the + account. + :rtype: list + """ + ad_account_id = self._get_linkedin_ad_account_id() + if not ad_account_id: + return [] + is_test = self.environment == "test" + elements = [] + page_token = None + while True: + params_fields = ["q", "sortOrder", "pageSize"] + params_values = { + "q": "criteria", + "sortOrder": "ASCENDING", + "pageSize": _PAGE_SIZE_LINKEDIN, + } + if campaign_urns: + params_fields.append("campaigns") + params_values["campaigns"] = list(campaign_urns) + if page_token: + params_fields.append("pageToken") + params_values["pageToken"] = page_token + response = self._request_linkedin( + endpoint=f"/adAccounts/{ad_account_id}/creatives", + headers=self.media_id._get_linkedin_headers(self.sudo().access_token), + params_fields=params_fields, + params_values=params_values, + return_json=False, + format_quote=True, + ) + if response.status_code != 200: + raise UserError( + _( + "GET ADS: %(error)s", + error=self._linkedin_error_message(response), + ) + ) + data = response.json() + page_elements = data.get("elements", []) + elements += [ + element + for element in page_elements + if bool(element.get("isTest", False)) == is_test + ] + page_token = data.get("metadata", {}).get("nextPageToken") + if not page_elements or not page_token: + break + return elements + + def _fetch_linkedin_ad_entities(self, endpoint, search=None, fields=None): + """Fetch every element of an Ads search finder, following pagination. + + The search finders paginate with a cursor since the version 202401: + the answer carries the token of the next page in its metadata. + + :param endpoint: The Ads API endpoint (e.g. the campaigns of an + advertising account). + :param search: The Rest.li search criteria, without the ``search=`` + prefix (e.g. ``"(test:false)"``). + :param fields: The fields to project, comma separated. + :return: The list of elements. + :rtype: list + """ + elements = [] + page_token = None + while True: + params_fields = ["q", "pageSize"] + params_values = {"q": "search", "pageSize": _PAGE_SIZE_LINKEDIN} + params_values_char_ignore = None + if search: + params_fields.append("search") + params_values["search"] = search + params_values_char_ignore = {"search": [{"all": ":"}]} + if fields: + params_fields.append("fields") + params_values["fields"] = fields + if page_token: + params_fields.append("pageToken") + params_values["pageToken"] = page_token + response = self._request_linkedin( + endpoint=endpoint, + headers=self.media_id._get_linkedin_headers(self.sudo().access_token), + params_fields=params_fields, + params_values=params_values, + params_values_char_ignore=params_values_char_ignore, + return_json=False, + format_quote=True, + ) + if response.status_code != 200: + raise UserError( + _( + "Error getting campaigns from Linkedin: %(error)s", + error=self._linkedin_error_message(response), + ) + ) + data = response.json() + page_elements = data.get("elements", []) + elements += page_elements + page_token = data.get("metadata", {}).get("nextPageToken") + if not page_elements or not page_token: + break + return elements + + def _upsert_linkedin_campaigns(self, groups, campaigns): + """Create or update the campaign groups and campaigns from Ads elements. + + Records are searched and written with ``sudo()``: the record rule + scopes campaigns by ``user_id``, so without it an importer would not + see the records owned by other users and would create duplicates + with the same URN. + + :param groups: adCampaignGroups elements. + :param campaigns: adCampaigns elements. + :return: The number of created groups and campaigns. + :rtype: dict + """ + SocialGroup = ( + self.env["social.campaign.group"] + .sudo() + .with_context(active_test=False, skip_linkedin_budget_check=True) + ) + SocialCampaign = ( + self.env["social.campaign"] + .sudo() + .with_context(skip_linkedin_budget_check=True, active_test=False) + ) + Currency = self.env["res.currency"] + AdvertisingAccount = self.env["social.advertising.account"].sudo() + current_advertising_account = self.advertising_account_ids.filtered( + "is_current" + )[:1] + advertising_account_by_urn = { + advertising_account.remote_ref: advertising_account + for advertising_account in AdvertisingAccount.search( + [("account_id", "=", self.id)] + ) + } + counts = {"groups": 0, "campaigns": 0} + groups_by_urn = {} + stages = self.env["social.stage"].search( + [("media_id.media_type", "=", "linkedin")] + ) + stage_by_scope = {(stage.applies_to, stage.code): stage.id for stage in stages} + campaign_selections = { + key: (field_name, dict(SocialCampaign._fields[field_name].selection)) + for key, field_name in ( + ("format", "linkedin_format"), + ("objectiveType", "linkedin_objective"), + ("politicalIntent", "linkedin_political_intent"), + ) + } + for element in groups: + urn = f"urn:li:sponsoredCampaignGroup:{element['id']}" + total_budget = element.get("totalBudget", {}) + status = element.get("status") or "" + vals = { + "name": element.get("name", ""), + "remote_ref": urn, + "total_budget": float(total_budget.get("amount", 0) or 0), + "stage_id": stage_by_scope.get(("group", status), False), + "advertising_account_id": current_advertising_account.id, + } + currency = Currency.search( + [("name", "=", total_budget.get("currencyCode"))], limit=1 + ) + if currency: + vals["currency_id"] = currency.id + group = SocialGroup.search([("remote_ref", "=", urn)], limit=1) + if group: + if group.linkedin_needs_update: + group.message_post( + body=_( + "Import kept the local pending changes. " + "LinkedIn values: name: %(name)s, " + "total budget: %(total_budget)s %(currency)s", + name=vals["name"], + total_budget=vals["total_budget"], + currency=total_budget.get("currencyCode", ""), + ) + ) + for field in ("name", "total_budget", "currency_id"): + vals.pop(field, None) + was_deleted = group.stage_id.code in LINKEDIN_DELETED_CODES + group.with_context(skip_linkedin_needs_update=True).write(vals) + if not was_deleted and group.stage_id.code in LINKEDIN_DELETED_CODES: + group.message_post( + body=_( + "This campaign group was deleted on LinkedIn. It " + "is kept in Odoo as history because LinkedIn " + "still returns it with its performance data." + ) + ) + else: + group = SocialGroup.create(vals) + counts["groups"] += 1 + groups_by_urn[urn] = group + for element in campaigns: + urn = f"urn:li:sponsoredCampaign:{element['id']}" + group_urn = element.get("campaignGroup", "") + group = groups_by_urn.get(group_urn) or SocialGroup.search( + [("remote_ref", "=", group_urn)], limit=1 + ) + status = element.get("status") or "" + vals = { + "name": element.get("name", ""), + "remote_ref": urn, + "unit_cost": float(element.get("unitCost", {}).get("amount", 0) or 0), + "daily_budget": float( + element.get("dailyBudget", {}).get("amount", 0) or 0 + ), + "media_id": self.media_id.id, + "account_ids": [Command.link(self.id)], + "stage_id": stage_by_scope.get(("campaign", status), False), + "linkedin_is_test": element.get("test", False), + "advertising_account_id": advertising_account_by_urn.get( + element.get("account", ""), current_advertising_account + ).id, + } + vals.update( + { + field_name: element[key] + for key, (field_name, values) in campaign_selections.items() + if element.get(key) in values + } + ) + if group: + vals["campaign_group_id"] = group.id + campaign = SocialCampaign.search([("remote_ref", "=", urn)], limit=1) + if campaign: + if campaign.name == vals["name"]: + vals.pop("name") + if campaign.linkedin_needs_update: + campaign.message_post( + body=_( + "Import kept the local pending changes. " + "LinkedIn values: name: %(name)s, " + "unit cost: %(unit_cost)s, " + "daily budget: %(daily_budget)s, " + "campaign group: %(group)s", + name=element.get("name", ""), + unit_cost=vals["unit_cost"], + daily_budget=vals["daily_budget"], + group=group.name if group else "", + ) + ) + for field in ( + "name", + "unit_cost", + "daily_budget", + "campaign_group_id", + ): + vals.pop(field, None) + was_deleted = campaign.stage_id.code in LINKEDIN_DELETED_CODES + campaign.with_context(skip_linkedin_needs_update=True).write(vals) + if not was_deleted and campaign.stage_id.code in LINKEDIN_DELETED_CODES: + campaign.message_post( + body=_( + "This campaign was deleted on LinkedIn. It is " + "kept in Odoo as history because LinkedIn still " + "returns it with its performance data." + ) + ) + else: + SocialCampaign.create(vals) + counts["campaigns"] += 1 + return counts + + def _link_linkedin_creatives(self, creatives): + """Link the creatives with the publications they promote. + + The creative is the only source relating a post with its campaign: + the Posts API does not return it, so a publication imported from the + wall gets its campaign here. Publications published from Odoo take it + from their parent post instead and are never overwritten. + + :param creatives: Creatives API elements. + :return: The number of newly linked posts. + :rtype: int + """ + PostAccount = self.env["social.post.account"].sudo() + campaigns_by_urn = { + campaign.remote_ref: campaign + for campaign in self.env["social.campaign"] + .sudo() + .search( + [ + ( + "remote_ref", + "in", + [ + element["campaign"] + for element in creatives + if element.get("campaign") + ], + ) + ] + ) + } + linked = 0 + for element in creatives: + reference = element.get("content", {}).get("reference") + if not reference: + continue + post_account = PostAccount.search( + [ + ("remote_ref", "=", reference), + ("account_id", "=", self.id), + ], + limit=1, + ) + if not post_account: + continue + values = {} + creative_urn = str(element["id"]) + if post_account.creative_urn != creative_urn: + values["creative_urn"] = creative_urn + campaign = campaigns_by_urn.get(element.get("campaign")) + if ( + campaign + and not post_account.post_id + and post_account.social_campaign_id != campaign + ): + values["social_campaign_id"] = campaign.id + if values: + post_account.write(values) + linked += 1 + return linked + + def action_import_campaigns(self): + res = super().action_import_campaigns() + if self.media_id.media_type != "linkedin": + return res + try: + advertising_account_urn = self._get_linkedin_advertising_account() + if not advertising_account_urn: + return { + "success": False, + "message": _( + "No LinkedIn advertising account is in use for " + "the account %(account)s. Open its Advertising " + "tab, fetch the advertising accounts and " + "choose one.", + account=self.display_name, + ), + "groups": 0, + "campaigns": 0, + "ads": 0, + } + ad_account_id = advertising_account_urn.split(":")[-1] + groups = self._fetch_linkedin_ad_entities( + _ENDPOINT_AD_CAMPAIGN_GROUPS_LINKEDIN % ad_account_id + ) + campaigns = self._fetch_linkedin_ad_entities( + _ENDPOINT_AD_CAMPAIGNS_LINKEDIN % ad_account_id + ) + campaign_urns = { + f"urn:li:sponsoredCampaign:{element['id']}" for element in campaigns + } + creatives = self._fetch_linkedin_creatives(campaign_urns=campaign_urns) + except UserError as error: + return { + "success": False, + "message": str(error), + "groups": 0, + "campaigns": 0, + "ads": 0, + } + counts = self._upsert_linkedin_campaigns(groups, campaigns) + counts["ads"] = self._link_linkedin_creatives(creatives) + return { + "success": True, + "message": _( + "%(groups)s campaign group(s), %(campaigns)s campaign(s) " + "and %(ads)s sponsored post(s) imported from LinkedIn.", + **counts, + ), + "groups": counts["groups"], + "campaigns": counts["campaigns"], + "ads": counts["ads"], + } + + def _get_campaigns(self, start_date=None, end_date=None, campaign_ids=None): + """Return the campaigns of the advertising account in a date range. + + The search finder has no date criteria, so the run schedule is + compared here: a campaign is kept when its schedule overlaps the + requested range, and a campaign without schedule is never dropped. + + :param campaign_ids: Restrict the answer to these campaign URNs. + :rtype: list + """ + ad_account_id = self._get_linkedin_ad_account_id() + if not ad_account_id: + return [] + campaigns = self._fetch_linkedin_ad_entities( + _ENDPOINT_AD_CAMPAIGNS_LINKEDIN % ad_account_id, + search=f"(test:{self._linkedin_is_test()})", + fields=_FIELDS_CAMPAIGN_LINKEDIN, + ) + if campaign_ids: + wanted = set(campaign_ids) + campaigns = [ + campaign + for campaign in campaigns + if f"urn:li:sponsoredCampaign:{campaign.get('id')}" in wanted + ] + start_time, end_time = _generate_timestamps(start_date, end_date) + return [ + campaign + for campaign in campaigns + if self._linkedin_campaign_in_range(campaign, start_time, end_time) + ] + + def _linkedin_campaign_in_range(self, campaign, start_time, end_time): + """Tell whether the run schedule of a campaign overlaps a range. + + :param campaign: An adCampaigns element. + :param start_time: Start of the range, in milliseconds. + :param end_time: End of the range, in milliseconds. + :rtype: bool + """ + run_schedule = campaign.get("runSchedule") or {} + schedule_start = run_schedule.get("start") + schedule_end = run_schedule.get("end") + if schedule_start and schedule_start > end_time: + return False + if schedule_end and schedule_end < start_time: + return False + return True + + def _get_statistics(self, ads_ids=None, start_date=None, end_date=None): + start_date, end_date = self._get_default_filter_date(start_date, end_date) + start_date = ( + start_date.strftime(DEFAULT_SERVER_DATE_FORMAT).split("-") + if not isinstance(start_date, str) + else start_date + ) + parse_start_date = ( + f"(year:{start_date[0]},month:{int(start_date[1])}," + f"day:{int(start_date[2])})" + ) + end_date = ( + end_date.strftime(DEFAULT_SERVER_DATE_FORMAT).split("-") + if not isinstance(end_date, str) + else end_date + ) + parse_end_date = ( + f"(year:{end_date[0]},month:{int(end_date[1])},day:{int(end_date[2])})" + ) + date_statistics_range = f"(start:{parse_start_date},end:{parse_end_date})" + + params_fields = [ + "q", + "pivots", + "timeGranularity", + "dateRange", + "fields", + ] + params_values = { + "q": "statistics", + "pivots": ["CAMPAIGN"], + "timeGranularity": "ALL", + "dateRange": date_statistics_range, + "fields": _FIELDS_STATISTIC_LINKEDIN, + } + if ads_ids: + params_fields.append("creatives") + params_values.update( + { + "pivots": ["CREATIVE"], + "creatives": list(ads_ids), + } + ) + response = self._request_linkedin( + endpoint=_ENDPOINT_AD_ANALYTICS_LINKEDIN, + headers=self.media_id._get_linkedin_headers(self.sudo().access_token), + params_fields=params_fields, + params_values=params_values, + params_values_char_ignore={"dateRange": [{"all": ":"}]}, + return_json=False, + format_quote=True, + ) + + if response.status_code == 200: + statistics = response.json().get("elements", []) + else: + raise UserError( + _( + "GET CAMPAIGNS STATISTICS: %(error)s", + error=self._linkedin_error_message(response), + ) + ) + return statistics + + def _get_statistics_ads(self, ads_ids, start_date, end_date): + return self._get_statistics( + ads_ids=ads_ids, start_date=start_date, end_date=end_date + ) + + def _load_ads(self, start_date=None, end_date=None): + start_date, end_date = self._get_default_filter_date(start_date, end_date) + ads = self._fetch_linkedin_creatives() + + ads_parse = [] + if ads: + ads_ids = list(map(lambda x: x["id"], ads)) + ads_statistics = self._get_statistics_ads( + ads_ids, start_date=None, end_date=None + ) + + campaign_ids = list(map(lambda x: x["campaign"], ads)) + ads_campaigns = self._get_campaigns( + start_date, end_date, campaign_ids=campaign_ids + ) + + references = { + ad["id"]: ad.get("content", {}).get("reference", False) for ad in ads + } + post_ids = [reference for reference in references.values() if reference] + ads_ugc_posts = {} + if post_ids: + ads_ugc_posts = { + ugc_post["id"]: ugc_post + for ugc_post in self._get_posts( + params_fields=["ids"], params_values={"ids": post_ids} + ) + } + stage_by_code = { + stage.code: stage + for stage in self.env["social.stage"].search( + [ + ("media_id.media_type", "=", "linkedin"), + ("applies_to", "=", "ad"), + ] + ) + } + advertising_account = self.advertising_account_ids.filtered("is_current")[ + :1 + ] + for ad in ads: + statistic = list( + filter( + lambda x: ad["id"] in x["pivotValues"], + ads_statistics, + ) + ) + campaign = list( + filter( + lambda x: int(ad["campaign"].split(":")[-1]) == x["id"], + ads_campaigns, + ) + ) + post = {} + ugc_post = ads_ugc_posts.get(references.get(ad["id"], False), False) + if ugc_post: + post = { + "id": ugc_post["id"], + "name": ugc_post.get("commentary", ""), + } + campaign_data = campaign[0] if campaign else {} + account_id = campaign_data.get("account", "").split(":")[-1] + stage = stage_by_code.get(ad.get("intendedStatus", "")) + ad.update( + { + "media_type": self.media_type, + "statistic": statistic[0] if len(statistic) > 0 else {}, + "campaign": campaign_data, + "advertising_account": advertising_account.name or "", + "created": convert_to_date( + miliseconds=ad["createdAt"], + expire_date=False, + format_date="%d/%m/%Y", + ), + "status": stage.name if stage else ad.get("intendedStatus", ""), + "status_level": stage.level if stage else "secondary", + "status_detail": ", ".join(ad.get("servingHoldReasons", [])), + "post": post, + "url": f"{_URL_CAMPAIGN_MANAGER_LINKEDIN}{account_id}/" + f"creatives?creativeIds=" + f"{quote(str([ad['id'].split(':')[-1]]))}", + } + ) + ads_parse.append(ad) + return ads_parse + + def _load_ads_accounts(self): + res = super()._load_ads_accounts() + ads = list(res.get("ads", [])) + accounts = self.search([("media_type", "=", "linkedin")]).filtered( + lambda account: account.sudo().access_token + ) + for account in accounts: + try: + ads_linkedin = account._load_ads() + except UserError: + _logger.exception( + "Could not load LinkedIn ads for account %s", + account.display_name, + ) + continue + ads = list(itertools.chain(ads, ads_linkedin)) + res["ads"] = ads + return res diff --git a/social_media_advertising_linkedin/models/social_advertising_account.py b/social_media_advertising_linkedin/models/social_advertising_account.py new file mode 100644 index 0000000000..196493a710 --- /dev/null +++ b/social_media_advertising_linkedin/models/social_advertising_account.py @@ -0,0 +1,68 @@ +# Copyright 2026 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + +from ..social_advertising_linkedin_utils import _URL_CAMPAIGN_MANAGER_LINKEDIN + + +class SocialAdvertisingAccount(models.Model): + """LinkedIn side of an advertising account. + + Everything LinkedIn answers that the other advertising platforms do not + have is declared here, so the generic model only holds what any of them + reports. + """ + + _inherit = "social.advertising.account" + + linkedin_status = fields.Selection( + [ + ("DRAFT", "Draft"), + ("ACTIVE", "Active"), + ("CANCELED", "Canceled"), + ("PENDING_DELETION", "Pending deletion"), + ("REMOVED", "Removed"), + ], + string="LinkedIn Status", + readonly=True, + help="Status of the advertising account on LinkedIn.", + ) + linkedin_type = fields.Selection( + [ + ("BUSINESS", "Business"), + ("ENTERPRISE", "Enterprise"), + ], + string="LinkedIn Type", + readonly=True, + help="Enterprise advertising accounts are created by LinkedIn itself " + "and cannot be test accounts.", + ) + linkedin_serving_status = fields.Char( + string="LinkedIn Serving Status", + readonly=True, + help="Why LinkedIn is serving the ads of this advertising account, " + "or not: RUNNABLE when it is eligible, otherwise the reasons why it " + "is not, such as BILLING_HOLD or ACCOUNT_TOTAL_BUDGET_HOLD.", + ) + linkedin_reference = fields.Char( + string="LinkedIn Owner", + readonly=True, + help="Organization or person the advertising account advertises on " + "behalf of.", + ) + + @api.depends("remote_ref", "media_id.media_type") + def _compute_web_url(self): + res = super()._compute_web_url() + for advertising_account in self.filtered( + lambda advertising_account: ( + advertising_account.media_id.media_type == "linkedin" + and advertising_account.remote_ref + ) + ): + identifier = advertising_account.remote_ref.split(":")[-1] + advertising_account.web_url = ( + f"{_URL_CAMPAIGN_MANAGER_LINKEDIN}{identifier}/" + ) + return res diff --git a/social_media_advertising_linkedin/models/social_campaign.py b/social_media_advertising_linkedin/models/social_campaign.py new file mode 100644 index 0000000000..4871da56d0 --- /dev/null +++ b/social_media_advertising_linkedin/models/social_campaign.py @@ -0,0 +1,668 @@ +# Copyright 2026 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.exceptions import UserError, ValidationError + +from odoo.addons.social_media_base.social_utils import _generate_timestamps + +from ..social_advertising_linkedin_utils import ( + _ENDPOINT_AD_CAMPAIGN_GROUPS_LINKEDIN, + _ENDPOINT_AD_CAMPAIGNS_LINKEDIN, +) + +LINKEDIN_LOCKED_CODES = ("ARCHIVED", "CANCELED", "PENDING_DELETION", "REMOVED") +LINKEDIN_DELETED_CODES = ("PENDING_DELETION", "REMOVED") +LINKEDIN_START_MARGIN = 60 + + +class SocialCampaign(models.Model): + """Synchronization of the campaign with a LinkedIn Ads campaign.""" + + _inherit = "social.campaign" + + linkedin_is_test = fields.Boolean( + string="LinkedIn Test Campaign", + readonly=True, + copy=False, + help="Whether LinkedIn flags this campaign as a test campaign.", + ) + linkedin_format = fields.Selection( + [ + ("STANDARD_UPDATE", "Standard update"), + ("SINGLE_VIDEO", "Single video"), + ], + string="LinkedIn Ad Format", + default="STANDARD_UPDATE", + copy=False, + tracking=True, + help="Ad format of the campaign on LinkedIn. A campaign only accepts " + "posts of its format, and LinkedIn does not allow changing it once " + "the campaign is created, so a post with a video needs a campaign of " + "the 'Single video' format.", + ) + linkedin_objective = fields.Selection( + [ + ("BRAND_AWARENESS", "Brand awareness"), + ("VIDEO_VIEW", "Video views"), + ("WEBSITE_VISIT", "Website visits"), + ("ENGAGEMENT", "Engagement"), + ], + string="LinkedIn Objective", + copy=False, + tracking=True, + help="Goal of the campaign on LinkedIn, required for the 'Single " + "video' format:\n" + "- Brand awareness: reach as many people as possible.\n" + "- Video views: get the video of the post watched.\n" + "- Website visits: send traffic to a page.\n" + "- Engagement: get likes, comments and follows.\n" + "LinkedIn has three more objectives (lead generation, website " + "conversions and job applicants) that need a lead gen form, " + "conversion tracking or LinkedIn Talent Solutions, which are " + "configured outside Odoo and are therefore not offered here.", + ) + linkedin_political_intent = fields.Selection( + [ + ("NOT_POLITICAL", "Not political advertising"), + ("POLITICAL", "Political advertising"), + ("NOT_DECLARED", "Not declared"), + ], + string="LinkedIn Political Intent", + default="NOT_POLITICAL", + copy=False, + tracking=True, + help="Declaration required by LinkedIn to create a campaign: I " + "confirm this is not political advertising. None of my ads qualify " + "as political advertising under the law of the targeted countries, " + "including EU law for ads targeted to the EU. Change it to " + "'Political advertising' when the campaign does qualify as such.", + ) + linkedin_needs_update = fields.Boolean( + string="LinkedIn Pending Changes", + readonly=True, + copy=False, + help="The campaign was modified in Odoo after being synchronized " + "with LinkedIn. Use the 'Update in LinkedIn' button to push the " + "local values.", + ) + unit_cost = fields.Float( + help="Bid amount per click, impression or other event depending on " + "the pricing model. LinkedIn only applies it with manual, target " + "cost or cost cap bidding; with automatic bidding it is ignored.", + tracking=True, + ) + daily_budget = fields.Float( + help="Maximum amount to spend on this campaign per day. The " + "campaign group total budget limits the overall spending.", + tracking=True, + ) + currency_id = fields.Many2one( + "res.currency", related="campaign_group_id.currency_id" + ) + linkedin_locked = fields.Boolean( + compute="_compute_linkedin_locked", + store=True, + help="LinkedIn no longer accepts changes on this record.", + ) + + @api.depends("stage_id", "stage_id.code", "media_id", "media_id.media_type") + def _compute_linkedin_locked(self): + for campaign in self: + campaign.linkedin_locked = ( + campaign.media_id.media_type == "linkedin" + and campaign.stage_id.code in LINKEDIN_LOCKED_CODES + ) + + def _linkedin_account(self): + """Return the LinkedIn account used to call the Ads API. + + LinkedIn uses a single advertising account per campaign, so of the + ``account_ids`` of the base model only the first LinkedIn one is + relevant; :meth:`_check_linkedin_single_account` guarantees there is + at most one. + + :return: the account, empty when the campaign has none. + :rtype: recordset + """ + self.ensure_one() + return self.account_ids.filtered( + lambda account: account.media_id.media_type == "linkedin" + )[:1] + + @api.constrains("account_ids", "media_id") + def _check_linkedin_single_account(self): + """Restrict LinkedIn campaigns to a single LinkedIn account. + + The restriction lives in the connector on purpose: the base module + keeps ``account_ids`` open because another social media could allow + several accounts per campaign. + """ + for campaign in self: + if campaign.media_id.media_type != "linkedin": + continue + linkedin_accounts = campaign.account_ids.filtered( + lambda account: account.media_id.media_type == "linkedin" + ) + if len(linkedin_accounts) > 1: + raise ValidationError( + _("A LinkedIn campaign can only have one LinkedIn account.") + ) + + def _linkedin_runschedule_values(self): + """Return a fresh run schedule when LinkedIn would reject the stored one. + + LinkedIn validates the whole campaign on a partial update, and a + campaign left in DRAFT keeps the start date given on creation, + which is in the past as soon as some time goes by + (``DATE_TOO_EARLY``). Sending a new schedule is the only way to + update or archive it. A campaign already running keeps its own + dates, which LinkedIn accepts. + + :rtype: dict + """ + self.ensure_one() + if self.stage_id.code != "DRAFT": + return {} + start, end = _generate_timestamps() + return { + "runSchedule": { + "start": start + LINKEDIN_START_MARGIN * 1000, + "end": end, + } + } + + def _linkedin_sync_fields(self): + """Fields pushed to LinkedIn, flagged as pending when changed here. + + ``media_id`` is guarded as well although it is not pushed: changing + it is what decides whether the campaign is a LinkedIn one at all, so + without it the lock of an archived campaign is escaped by taking it + out of LinkedIn. + """ + return ( + "name", + "unit_cost", + "daily_budget", + "campaign_group_id", + "linkedin_political_intent", + "media_id", + ) + + def write(self, vals): + sync_change = not self.env.context.get("skip_linkedin_needs_update") and any( + field in vals for field in self._linkedin_sync_fields() + ) + if sync_change: + locked = self.filtered("linkedin_locked") + if locked: + raise UserError( + _( + "The campaign %(names)s cannot be modified because of " + "its LinkedIn status (%(status)s).", + names=", ".join(locked.mapped("display_name")), + status=", ".join(locked.mapped("stage_id.name")), + ) + ) + res = super().write(vals) + if sync_change: + to_flag = self.filtered( + lambda c: c.remote_ref + and c.media_type == "linkedin" + and not c.linkedin_needs_update + ) + if to_flag: + super(SocialCampaign, to_flag).write({"linkedin_needs_update": True}) + return res + + def _available_campaign(self): + media_type = super()._available_campaign() + media_type.append("linkedin") + return media_type + + def _linkedin_publish_campaign_group(self, account, advertising_account_urn): + """Ensure the campaign group of this campaign exists on LinkedIn. + + An existing URN is verified, a missing one is created in DRAFT so it + does not spend budget until it is activated on Campaign Manager. + + :param account: The social.account used to call the LinkedIn API. + :param advertising_account_urn: The advertising account URN. + :return: The campaign group LinkedIn URN or False. + :rtype: str | bool + """ + self.ensure_one() + if not self.campaign_group_id: + raise UserError( + _( + "The campaign %(campaign)s does not belong to a campaign " "group.", + campaign=self.display_name, + ) + ) + group_campaign = False + if advertising_account_urn: + message_error = "Error creating group campaign in Linkedin:" + if self.campaign_group_id.remote_ref: + ad_account_id = advertising_account_urn.split(":")[-1] + group_campaign = account._request_linkedin( + endpoint=( + f"{_ENDPOINT_AD_CAMPAIGN_GROUPS_LINKEDIN % ad_account_id}/" + f"{self.campaign_group_id.remote_ref.split(':')[-1]}" + ), + headers=account.media_id._get_linkedin_headers( + account.sudo().access_token + ), + return_json=False, + ) + if group_campaign and group_campaign.status_code == 200: + return self.campaign_group_id.remote_ref + elif not group_campaign or group_campaign.status_code == 404: + group_campaign = self.campaign_group_id._linkedin_create_group( + account, advertising_account_urn + ) + else: + raise UserError( + _( + "%(message_error)s %(error_response)s", + message_error=message_error, + error_response=self.env[ + "social.account" + ]._linkedin_error_message(group_campaign), + ) + ) + return group_campaign + + def _linkedin_verify_campaign(self, account): + """Check that the stored campaign URN still exists on LinkedIn. + + :param account: The social.account used to call the LinkedIn API. + :return: The campaign URN if it exists, False otherwise. + :rtype: str | bool + """ + self.ensure_one() + if not self.remote_ref: + return False + ad_account_id = account._require_linkedin_ad_account_id() + campaign = account._request_linkedin( + endpoint=( + f"{_ENDPOINT_AD_CAMPAIGNS_LINKEDIN % ad_account_id}/" + f"{self.remote_ref.split(':')[-1]}" + ), + headers=account.media_id._get_linkedin_headers(account.sudo().access_token), + return_json=False, + ) + if campaign.status_code == 200: + return self.remote_ref + elif campaign.status_code == 404: + return False + raise UserError( + _( + "%(message_error)s %(error_response)s", + message_error="Error creating campaign in Linkedin:", + error_response=self.env["social.account"]._linkedin_error_message( + campaign + ), + ) + ) + + def _linkedin_create_campaign( + self, account, advertising_account_urn, campaign_group_linkedin_urn + ): + """Create this campaign on LinkedIn under the given group. + + It is created in DRAFT so it does not spend budget until it is + activated on Campaign Manager. + + :param account: The social.account used to call the LinkedIn API. + :param advertising_account_urn: The advertising account URN. + :param campaign_group_linkedin_urn: The campaign group URN. + :return: The campaign LinkedIn URN or False. + :rtype: str | bool + """ + self.ensure_one() + campaign = False + if campaign_group_linkedin_urn: + message_error = "Error creating campaign in Linkedin:" + stage = self.env["social.stage"]._get_stage("linkedin", "campaign", "DRAFT") + if not stage: + raise UserError( + _( + "The LinkedIn campaign stage with code %(code)s is " + "missing. Update the social_media_advertising_linkedin " + "module to restore the LinkedIn stages.", + code="DRAFT", + ) + ) + start, end = _generate_timestamps() + response = account._request_linkedin( + method="POST", + endpoint=( + _ENDPOINT_AD_CAMPAIGNS_LINKEDIN + % advertising_account_urn.split(":")[-1] + ), + headers=account.media_id._get_linkedin_headers( + account.sudo().access_token + ), + json_data={ + "account": advertising_account_urn, + "campaignGroup": campaign_group_linkedin_urn, + "name": f"{self.name}", + "type": "SPONSORED_UPDATES", + "format": self.linkedin_format or "STANDARD_UPDATE", + **( + {"objectiveType": self.linkedin_objective} + if self.linkedin_objective + else {} + ), + "politicalIntent": self.linkedin_political_intent, + "offsiteDeliveryEnabled": False, + "runSchedule": { + "start": start, + "end": end, + }, + "locale": { + "country": self.env.user.country_id.code or "US", + "language": self.env.user.lang.split("_")[0], + }, + "unitCost": { + "amount": f"{self.unit_cost}", + "currencyCode": self.currency_id.name, + }, + "dailyBudget": { + "amount": f"{self.daily_budget}", + "currencyCode": self.currency_id.name, + }, + "status": "DRAFT", + }, + return_json=False, + ) + if response.status_code == 201: + campaign = ( + "urn:li:sponsoredCampaign:" + f"{response.headers.get('Location').split('/')[-1]}" + ) + self.write( + { + "remote_ref": campaign, + "stage_id": stage.id, + "advertising_account_id": account._get_advertising_account( + advertising_account_urn + ).id, + } + ) + else: + raise UserError( + _( + "%(message_error)s %(error_response)s", + message_error=message_error, + error_response=self.env[ + "social.account" + ]._linkedin_error_message(response), + ) + ) + return campaign + + def _linkedin_publish_campaign( + self, account, advertising_account_urn, campaign_group_linkedin_urn + ): + """Verify the stored campaign URN and create the campaign when missing. + + :param account: The social.account used to call the LinkedIn API. + :param advertising_account_urn: The advertising account URN. + :param campaign_group_linkedin_urn: The campaign group URN. + :return: The campaign LinkedIn URN or False. + :rtype: str | bool + """ + self.ensure_one() + campaign = self._linkedin_verify_campaign(account) + if not campaign: + campaign = self._linkedin_create_campaign( + account, advertising_account_urn, campaign_group_linkedin_urn + ) + return campaign + + def _validate_publish_linkedin(self): + """Check that this campaign is ready to be created on LinkedIn.""" + self.ensure_one() + errors = [] + if self.media_id.media_type != "linkedin": + errors.append(_("The campaign media must be LinkedIn.")) + if not self.campaign_group_id: + errors.append(_("The campaign must belong to a campaign group.")) + if not self._linkedin_account(): + errors.append(_("The campaign must have a social account.")) + if not self.currency_id: + errors.append(_("The campaign group must have a currency.")) + if self.campaign_group_id and self.campaign_group_id.total_budget <= 0: + errors.append(_("The campaign group total budget must be positive.")) + if self.unit_cost <= 0: + errors.append(_("The campaign unit cost must be positive.")) + if self.daily_budget <= 0: + errors.append(_("The campaign daily budget must be positive.")) + if self.linkedin_format == "SINGLE_VIDEO" and not self.linkedin_objective: + errors.append(_("LinkedIn requires an objective for the video format.")) + if not self.linkedin_political_intent: + errors.append( + _( + "LinkedIn requires declaring whether the campaign is " + "political advertising." + ) + ) + if errors: + raise UserError("\n".join(errors)) + + def action_publish_linkedin(self): + """Create the campaign group and the campaign on LinkedIn in DRAFT. + + The campaign can then be selected on posts and activated from + Campaign Manager when desired. + """ + self.ensure_one() + self._validate_publish_linkedin() + account = self._linkedin_account() + advertising_account_urn = account._get_linkedin_advertising_account() + if not advertising_account_urn: + raise UserError( + _( + "No LinkedIn advertising account is in use for the " + "account %(account)s. Open its Advertising tab, " + "fetch the advertising accounts and choose one.", + account=account.display_name, + ) + ) + group_urn = self._linkedin_publish_campaign_group( + account, advertising_account_urn + ) + try: + self._linkedin_publish_campaign(account, advertising_account_urn, group_urn) + except UserError as error: + return self._register_publish_linkedin_failure(error) + self.message_post(body=_("Campaign created on LinkedIn in draft status.")) + return True + + def _register_publish_linkedin_failure(self, error): + """Report a campaign that could not be created on LinkedIn. + + :param error: The exception raised while creating the campaign. + :return: A client action showing the error to the user. + :rtype: dict + """ + self.ensure_one() + message = str(error) + self.message_post( + body=_( + "The campaign could not be created on LinkedIn: %(error)s", + error=message, + ) + ) + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Campaign not created on LinkedIn"), + "message": message, + "type": "danger", + "sticky": True, + "next": {"type": "ir.actions.act_window_close"}, + }, + } + + def action_update_linkedin(self): + """Push the local name, unit cost and daily budget to LinkedIn.""" + self.ensure_one() + if not self.remote_ref: + raise UserError(_("The campaign does not exist on LinkedIn yet.")) + if self.linkedin_locked: + raise UserError( + _( + "The campaign cannot be updated because of its LinkedIn " + "status (%(status)s).", + status=self.stage_id.name, + ) + ) + account = self._linkedin_account() + if not account or not self.currency_id: + raise UserError( + _("The campaign must have a social account and a currency.") + ) + if self.campaign_group_id and not self.campaign_group_id.remote_ref: + raise UserError( + _( + "The campaign group %(group)s does not exist on LinkedIn. " + "Create or import it before updating the campaign.", + group=self.campaign_group_id.display_name, + ) + ) + values = { + "name": f"{self.name}", + "unitCost": { + "amount": f"{self.unit_cost}", + "currencyCode": self.currency_id.name, + }, + "dailyBudget": { + "amount": f"{self.daily_budget}", + "currencyCode": self.currency_id.name, + }, + } + if self.campaign_group_id.remote_ref: + values["campaignGroup"] = self.campaign_group_id.remote_ref + if self.linkedin_political_intent: + values["politicalIntent"] = self.linkedin_political_intent + values.update(self._linkedin_runschedule_values()) + ad_account_id = account._require_linkedin_ad_account_id() + response = account._request_linkedin( + method="POST", + endpoint=( + f"{_ENDPOINT_AD_CAMPAIGNS_LINKEDIN % ad_account_id}/" + f"{self.remote_ref.split(':')[-1]}" + ), + headers=account.media_id._get_linkedin_headers( + account.sudo().access_token, + x_restli_method="PARTIAL_UPDATE", + ), + json_data={"patch": {"$set": values}}, + return_json=False, + ) + if response.status_code in (200, 204): + self.with_context(skip_linkedin_needs_update=True).write( + {"linkedin_needs_update": False} + ) + self.message_post(body=_("Campaign updated on LinkedIn.")) + else: + raise UserError( + _( + "Error updating campaign in Linkedin: %(error)s", + error=self.env["social.account"]._linkedin_error_message(response), + ) + ) + return True + + def action_archive_linkedin(self): + """Archive the campaign on LinkedIn. + + The Ads API does not allow deleting a campaign, so the campaign is + kept in Odoo with its performance data and becomes read only. + """ + self.ensure_one() + if not self.remote_ref: + raise UserError(_("The campaign does not exist on LinkedIn yet.")) + if self.linkedin_locked: + raise UserError( + _( + "The campaign cannot be archived because of its LinkedIn " + "status (%(status)s).", + status=self.stage_id.name, + ) + ) + if self.campaign_group_id.stage_id.code == "DRAFT": + raise UserError( + _( + "LinkedIn does not archive a campaign whose campaign " + "group is still in draft status. Activate the group " + "%(group)s in Campaign Manager first, or archive the " + "group itself, which archives its campaigns.", + group=self.campaign_group_id.display_name, + ) + ) + account = self._linkedin_account() + if not account: + raise UserError(_("The campaign must have a social account.")) + stage = self.env["social.stage"]._get_stage("linkedin", "campaign", "ARCHIVED") + if not stage: + raise UserError( + _( + "The LinkedIn campaign stage with code %(code)s is " + "missing. Update the social_media_advertising_linkedin " + "module to restore the LinkedIn stages.", + code="ARCHIVED", + ) + ) + ad_account_id = account._require_linkedin_ad_account_id() + response = account._request_linkedin( + method="POST", + endpoint=( + f"{_ENDPOINT_AD_CAMPAIGNS_LINKEDIN % ad_account_id}/" + f"{self.remote_ref.split(':')[-1]}" + ), + headers=account.media_id._get_linkedin_headers( + account.sudo().access_token, + x_restli_method="PARTIAL_UPDATE", + ), + json_data={ + "patch": { + "$set": { + "status": "ARCHIVED", + **self._linkedin_runschedule_values(), + } + } + }, + return_json=False, + ) + if response.status_code not in (200, 204): + raise UserError( + _( + "Error archiving campaign in Linkedin: %(error)s", + error=self.env["social.account"]._linkedin_error_message(response), + ) + ) + self.with_context(skip_linkedin_needs_update=True).write( + {"stage_id": stage.id, "linkedin_needs_update": False} + ) + self.message_post(body=_("Campaign archived on LinkedIn.")) + return True + + @api.constrains("daily_budget", "campaign_group_id") + def _check_daily_budget(self): + """Keep the daily budgets of a group within its total budget. + + This is a rule of Odoo, not of LinkedIn: a group without total + budget sets no limit there, and the import must never be stopped by + it, since the values it brings are the ones LinkedIn already + accepted. + + ``campaign_group_id`` is watched as well: moving a campaign into + another group is one more way of going over its total budget. + """ + if self.env.context.get("skip_linkedin_budget_check"): + return + self.campaign_group_id._check_linkedin_total_budget() diff --git a/social_media_advertising_linkedin/models/social_campaign_group.py b/social_media_advertising_linkedin/models/social_campaign_group.py new file mode 100644 index 0000000000..4f98b4c44e --- /dev/null +++ b/social_media_advertising_linkedin/models/social_campaign_group.py @@ -0,0 +1,402 @@ +# Copyright 2026 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.exceptions import UserError, ValidationError + +from odoo.addons.social_media_base.social_utils import _generate_timestamps + +from ..social_advertising_linkedin_utils import ( + _ENDPOINT_AD_CAMPAIGN_GROUPS_LINKEDIN, +) +from .social_campaign import LINKEDIN_LOCKED_CODES, LINKEDIN_START_MARGIN + + +class SocialCampaignGroup(models.Model): + """Synchronization of the group with a LinkedIn Ads campaign group.""" + + _inherit = "social.campaign.group" + + media_type = fields.Selection(related="media_id.media_type") + currency_id = fields.Many2one("res.currency") + total_budget = fields.Float( + tracking=True, + help="Maximum amount to spend across all the campaigns and " + "creatives of this group for its entire duration. It cannot be " + "lower than the daily budgets of its campaigns.", + ) + linkedin_needs_update = fields.Boolean( + string="LinkedIn Pending Changes", + readonly=True, + copy=False, + help="The campaign group was modified in Odoo after being " + "synchronized with LinkedIn. Use the 'Update in LinkedIn' button " + "to push the local values.", + ) + linkedin_locked = fields.Boolean( + compute="_compute_linkedin_locked", + store=True, + help="LinkedIn no longer accepts changes on this record.", + ) + + @api.depends("stage_id", "stage_id.code", "media_id", "media_id.media_type") + def _compute_linkedin_locked(self): + for group in self: + group.linkedin_locked = ( + group.media_id.media_type == "linkedin" + and group.stage_id.code in LINKEDIN_LOCKED_CODES + ) + + def _linkedin_sync_fields(self): + """Fields pushed to LinkedIn, flagged as pending when changed here.""" + return ("name", "total_budget") + + def write(self, vals): + sync_change = not self.env.context.get("skip_linkedin_needs_update") and any( + field in vals for field in self._linkedin_sync_fields() + ) + if sync_change: + locked = self.filtered("linkedin_locked") + if locked: + raise UserError( + _( + "The campaign group %(names)s cannot be modified " + "because of its LinkedIn status (%(status)s).", + names=", ".join(locked.mapped("display_name")), + status=", ".join(locked.mapped("stage_id.name")), + ) + ) + res = super().write(vals) + if sync_change: + to_flag = self.filtered( + lambda g: g.remote_ref + and g.media_id.media_type == "linkedin" + and not g.linkedin_needs_update + ) + if to_flag: + super(SocialCampaignGroup, to_flag).write( + {"linkedin_needs_update": True} + ) + return res + + @api.constrains("total_budget") + def _check_total_budget(self): + """Lowering the total budget must not leave the campaigns over it.""" + if self.env.context.get("skip_linkedin_budget_check"): + return + self._check_linkedin_total_budget() + + def _check_linkedin_total_budget(self): + """Raise when the daily budgets of a group exceed its total budget. + + Shared by both sides of the rule: the campaign checks it when its + daily budget or its group changes, the group when its total budget + does. + """ + for group in self: + if not group.total_budget: + continue + daily_budgets = sum(group.campaign_ids.mapped("daily_budget")) + if group.total_budget < daily_budgets: + raise ValidationError( + _( + "The daily budgets of the campaigns of the group " + "%(group)s add up to %(daily_budgets)s, which exceeds " + "its total budget of %(total_budget)s.", + group=group.display_name, + daily_budgets=daily_budgets, + total_budget=group.total_budget, + ) + ) + + def _get_linkedin_account(self): + """Return the social account used to call the LinkedIn API. + + The account decides which advertising account the group is written + to, so it is never guessed: it comes from the campaigns of the group, + and the database is only used as a fallback when it holds exactly one + LinkedIn account. Anything else is ambiguous and raises, because + picking the wrong one would create, update or archive the group in the + advertising account of another advertiser. + + :return: the account, possibly empty when none exists at all. + :rtype: recordset + :raises UserError: when several accounts could be used. + """ + self.ensure_one() + accounts = self.campaign_ids.account_ids.filtered( + lambda account: account.media_id.media_type == "linkedin" + ) + if not accounts: + accounts = self.env["social.account"].search( + [("media_id.media_type", "=", "linkedin")], order="id" + ) + if len(accounts) > 1: + raise UserError( + _( + "The campaign group %(group)s is not linked to any " + "LinkedIn account and the database holds several of " + "them. Set the account on one of its campaigns to " + "choose the advertising account to use.", + group=self.display_name, + ) + ) + elif len(accounts) > 1: + raise UserError( + _( + "The campaigns of the group %(group)s point to several " + "LinkedIn accounts (%(accounts)s). Leave a single one so " + "the advertising account to use is unambiguous.", + group=self.display_name, + accounts=", ".join(accounts.mapped("display_name")), + ) + ) + return accounts + + def _linkedin_create_group(self, account, advertising_account_urn): + """Create this campaign group on LinkedIn in DRAFT status. + + DRAFT keeps it from spending budget until it is activated on + Campaign Manager. + + :param account: The social.account used to call the LinkedIn API. + :param advertising_account_urn: The advertising account URN. + :return: The campaign group LinkedIn URN. + :rtype: str + """ + self.ensure_one() + stage = self.env["social.stage"]._get_stage("linkedin", "group", "DRAFT") + if not stage: + raise UserError( + _( + "The LinkedIn campaign group stage with code %(code)s is " + "missing. Update the social_media_advertising_linkedin " + "module to restore the LinkedIn stages.", + code="DRAFT", + ) + ) + start, end = _generate_timestamps() + response = account._request_linkedin( + method="POST", + endpoint=( + _ENDPOINT_AD_CAMPAIGN_GROUPS_LINKEDIN + % advertising_account_urn.split(":")[-1] + ), + headers=account.media_id._get_linkedin_headers(account.sudo().access_token), + json_data={ + "account": advertising_account_urn, + "name": self.name, + "runSchedule": { + "start": start, + "end": end, + }, + "status": "DRAFT", + "totalBudget": { + "amount": f"{self.total_budget}", + "currencyCode": self.currency_id.name, + }, + }, + return_json=False, + ) + if response.status_code != 201: + raise UserError( + _( + "Error creating group campaign in Linkedin: %(error)s", + error=self.env["social.account"]._linkedin_error_message(response), + ) + ) + urn = ( + "urn:li:sponsoredCampaignGroup:" + f"{response.headers.get('Location').split('/')[-1]}" + ) + self.with_context(skip_linkedin_needs_update=True).write( + { + "remote_ref": urn, + "stage_id": stage.id, + "advertising_account_id": account._get_advertising_account( + advertising_account_urn + ).id, + } + ) + return urn + + def _linkedin_runschedule_values(self): + """Return a fresh run schedule when LinkedIn would reject the stored one. + + LinkedIn validates the whole group on a partial update, and a group + left in DRAFT keeps the start date given on creation, which is in + the past as soon as some time goes by (``DATE_TOO_EARLY``). A group + already running keeps its own dates, which LinkedIn accepts. + + :rtype: dict + """ + self.ensure_one() + if self.stage_id.code != "DRAFT": + return {} + start, end = _generate_timestamps() + return { + "runSchedule": { + "start": start + LINKEDIN_START_MARGIN * 1000, + "end": end, + } + } + + def action_publish_linkedin(self): + """Create the campaign group on LinkedIn in DRAFT status. + + The group is also created automatically when the first campaign of + the group is published with the 'Create in LinkedIn' button. + """ + self.ensure_one() + if self.remote_ref: + raise UserError(_("The campaign group already exists on LinkedIn.")) + errors = [] + if not self.currency_id: + errors.append(_("The campaign group must have a currency.")) + if self.total_budget <= 0: + errors.append(_("The campaign group total budget must be positive.")) + if errors: + raise UserError("\n".join(errors)) + account = self._get_linkedin_account() + if not account: + raise UserError( + _("No LinkedIn social account is available to create the group.") + ) + advertising_account_urn = account._get_linkedin_advertising_account() + if not advertising_account_urn: + raise UserError( + _( + "No LinkedIn advertising account is in use for the " + "account %(account)s. Open its Advertising tab, " + "fetch the advertising accounts and choose one.", + account=account.display_name, + ) + ) + self._linkedin_create_group(account, advertising_account_urn) + self.message_post(body=_("Campaign group created on LinkedIn in draft status.")) + return True + + def action_update_linkedin(self): + """Push the local name and total budget to LinkedIn.""" + self.ensure_one() + if not self.remote_ref: + raise UserError(_("The campaign group does not exist on LinkedIn yet.")) + if self.linkedin_locked: + raise UserError( + _( + "The campaign group cannot be updated because of its " + "LinkedIn status (%(status)s).", + status=self.stage_id.name, + ) + ) + if not self.currency_id: + raise UserError(_("The campaign group must have a currency.")) + account = self._get_linkedin_account() + if not account: + raise UserError( + _("No LinkedIn social account is available to update the group.") + ) + ad_account_id = account._require_linkedin_ad_account_id() + response = account._request_linkedin( + method="POST", + endpoint=( + f"{_ENDPOINT_AD_CAMPAIGN_GROUPS_LINKEDIN % ad_account_id}/" + f"{self.remote_ref.split(':')[-1]}" + ), + headers=account.media_id._get_linkedin_headers( + account.sudo().access_token, + x_restli_method="PARTIAL_UPDATE", + ), + json_data={ + "patch": { + "$set": { + "name": f"{self.name}", + "totalBudget": { + "amount": f"{self.total_budget}", + "currencyCode": self.currency_id.name, + }, + **self._linkedin_runschedule_values(), + } + } + }, + return_json=False, + ) + if response.status_code in (200, 204): + self.with_context(skip_linkedin_needs_update=True).write( + {"linkedin_needs_update": False} + ) + self.message_post(body=_("Campaign group updated on LinkedIn.")) + else: + raise UserError( + _( + "Error updating campaign group in Linkedin: %(error)s", + error=self.env["social.account"]._linkedin_error_message(response), + ) + ) + return True + + def action_archive_linkedin(self): + """Archive the campaign group on LinkedIn. + + The Ads API does not allow deleting a group. LinkedIn applies the + same status to its campaigns, so run an import afterwards. + """ + self.ensure_one() + if not self.remote_ref: + raise UserError(_("The campaign group does not exist on LinkedIn yet.")) + if self.linkedin_locked: + raise UserError( + _( + "The campaign group cannot be archived because of its " + "LinkedIn status (%(status)s).", + status=self.stage_id.name, + ) + ) + account = self._get_linkedin_account() + if not account: + raise UserError( + _("No LinkedIn social account is available to archive the group.") + ) + stage = self.env["social.stage"]._get_stage("linkedin", "group", "ARCHIVED") + if not stage: + raise UserError( + _( + "The LinkedIn campaign group stage with code %(code)s is " + "missing. Update the social_media_advertising_linkedin " + "module to restore the LinkedIn stages.", + code="ARCHIVED", + ) + ) + ad_account_id = account._require_linkedin_ad_account_id() + response = account._request_linkedin( + method="POST", + endpoint=( + f"{_ENDPOINT_AD_CAMPAIGN_GROUPS_LINKEDIN % ad_account_id}/" + f"{self.remote_ref.split(':')[-1]}" + ), + headers=account.media_id._get_linkedin_headers( + account.sudo().access_token, + x_restli_method="PARTIAL_UPDATE", + ), + json_data={ + "patch": { + "$set": { + "status": "ARCHIVED", + **self._linkedin_runschedule_values(), + } + } + }, + return_json=False, + ) + if response.status_code not in (200, 204): + raise UserError( + _( + "Error archiving campaign group in Linkedin: %(error)s", + error=self.env["social.account"]._linkedin_error_message(response), + ) + ) + self.with_context(skip_linkedin_needs_update=True).write( + {"stage_id": stage.id, "linkedin_needs_update": False} + ) + self.message_post(body=_("Campaign group archived on LinkedIn.")) + return True diff --git a/social_media_advertising_linkedin/models/social_media.py b/social_media_advertising_linkedin/models/social_media.py new file mode 100644 index 0000000000..d79e9a4da5 --- /dev/null +++ b/social_media_advertising_linkedin/models/social_media.py @@ -0,0 +1,20 @@ +# Copyright 2026 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models + +from ..social_advertising_linkedin_utils import _SCOPE_ADS_LINKEDIN + + +class SocialMedia(models.Model): + _inherit = "social.media" + + def _get_linkedin_scopes(self): + """Append the Advertising API scopes to the LinkedIn authorization. + + The tokens already issued keep the scopes they were granted with, so + every account authorized before installing this module has to be + re-authorized from the account wizard to be able to call the Ads API. + """ + scopes = super()._get_linkedin_scopes() + return scopes + [s for s in _SCOPE_ADS_LINKEDIN if s not in scopes] diff --git a/social_media_advertising_linkedin/models/social_post.py b/social_media_advertising_linkedin/models/social_post.py new file mode 100644 index 0000000000..7ca4195fcf --- /dev/null +++ b/social_media_advertising_linkedin/models/social_post.py @@ -0,0 +1,64 @@ +# Copyright 2026 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, api, models +from odoo.osv import expression + + +class SocialPost(models.Model): + """LinkedIn Ads constraints on the campaigns a post can promote.""" + + _inherit = "social.post" + + def _get_allow_social_campaign_domain(self): + """Only offer the LinkedIn campaigns that already exist on LinkedIn. + + A sponsored creative can only be attached to a campaign carrying a + remote reference, so campaigns not created on LinkedIn yet are kept + out of the selection. + + :rtype: list + """ + domain = super()._get_allow_social_campaign_domain() + return expression.AND( + [ + domain, + [ + "|", + ("media_id.media_type", "!=", "linkedin"), + ("remote_ref", "!=", False), + ], + ] + ) + + @api.depends( + "social_campaign_id", + "social_campaign_id.media_id", + "social_campaign_id.campaign_group_id", + ) + def _compute_message_info(self): + """Only declares the advertising dependencies of the computation.""" + return super()._compute_message_info() + + def _linkedin_message_info(self): + """Add the warning about sponsoring a multi-image post. + + :rtype: list + """ + messages = super()._linkedin_message_info() + if ( + len(self.image_ids) > 1 + and not self.video_ids + and self.social_campaign_id.media_id.media_type == "linkedin" + and self.social_campaign_id.campaign_group_id + ): + messages.append( + _( + "A post with several images is published as a multi-image " + "post, and LinkedIn does not sponsor that format, so this " + "post cannot be linked to the campaign %(campaign)s. Leave " + "a single image or remove the campaign.", + campaign=self.social_campaign_id.display_name, + ) + ) + return messages diff --git a/social_media_advertising_linkedin/models/social_post_account.py b/social_media_advertising_linkedin/models/social_post_account.py new file mode 100644 index 0000000000..380da6bab6 --- /dev/null +++ b/social_media_advertising_linkedin/models/social_post_account.py @@ -0,0 +1,218 @@ +# Copyright 2026 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging + +import psycopg2 + +from odoo import _, fields, models +from odoo.exceptions import UserError +from odoo.service.model import PG_CONCURRENCY_ERRORS_TO_RETRY + +_logger = logging.getLogger(__name__) + + +class SocialPostAccount(models.Model): + """Sponsored creative of a publication on a LinkedIn account.""" + + _inherit = "social.post.account" + + creative_urn = fields.Char() + + def _linkedin_advertising_accounts(self): + return self.account_id._get_linkedin_advertising_account() + + def _requires_campaign_post(self): + """Return whether publishing must create a LinkedIn sponsored creative. + + The campaign is read from ``social_campaign_id`` and not from the + parent post, so publications imported from LinkedIn — which have no + parent post — resolve it the same way. + + :rtype: bool + """ + self.ensure_one() + return bool( + self.media_id.media_type == "linkedin" + and self.social_campaign_id + and self.social_campaign_id.campaign_group_id + and self.social_campaign_id.media_id.media_type == "linkedin" + ) + + def _check_linkedin_campaign_format(self): + """Check that the post matches the ad format of its campaign. + + LinkedIn only accepts creatives of the format chosen when the campaign + was created, so publishing a video in a standard campaign would leave + the post online without its ad. A post carrying several images is + published as a multi-image post, which LinkedIn does not sponsor at + all. + + :raise UserError: When the post and the campaign formats differ. + """ + self.ensure_one() + campaign = self.social_campaign_id + has_video = bool(self.post_id.video_ids) + is_video_campaign = campaign.linkedin_format == "SINGLE_VIDEO" + if not has_video and len(self.post_id.image_ids) > 1: + raise UserError( + _( + "LinkedIn does not sponsor posts with several images: they " + "are published as a multi-image post, a format its API " + "cannot turn into an ad, so this post cannot be linked to " + "the campaign %(campaign)s. Publish it with a single image " + "or without a campaign. LinkedIn offers the Carousel ad " + "format for this case, which this module does not support " + "yet.", + campaign=campaign.display_name, + ) + ) + if has_video and not is_video_campaign: + raise UserError( + _( + "The post contains a video, so it needs a campaign of the " + "'Single video' format. The campaign %(campaign)s uses the " + "'Standard update' format and LinkedIn does not allow " + "changing it once the campaign is created.", + campaign=campaign.display_name, + ) + ) + if is_video_campaign and not has_video: + raise UserError( + _( + "The campaign %(campaign)s uses the 'Single video' format, " + "so it only accepts posts containing a video.", + campaign=campaign.display_name, + ) + ) + + def _action_campaign_post(self, post_id): + """Create the sponsored creative of the post on LinkedIn. + + :param post_id: URN of the post published on LinkedIn. + :return: URN of the creative created on LinkedIn. + """ + res = super()._action_campaign_post(post_id) + if self._requires_campaign_post(): + campaign_linkedin_urn = self.social_campaign_id.remote_ref + if not campaign_linkedin_urn: + raise UserError( + _( + "The campaign %(campaign)s has not been created on " + "LinkedIn yet. Use the 'Create in LinkedIn' button " + "on the campaign before posting.", + campaign=self.social_campaign_id.display_name, + ) + ) + if campaign_linkedin_urn and post_id: + ad_account_id = self.account_id._get_linkedin_ad_account_id() + if not ad_account_id: + raise UserError( + _( + "No LinkedIn advertising account is in use for " + "the account %(account)s. Open its " + "Advertising tab, fetch the advertising " + "accounts and choose one.", + account=self.account_id.display_name, + ) + ) + response = self.account_id._request_linkedin( + method="POST", + endpoint=f"/adAccounts/{ad_account_id}/creatives", + headers=self.account_id.media_id._get_linkedin_headers( + self.account_id.sudo().access_token + ), + json_data={ + "campaign": campaign_linkedin_urn, + "intendedStatus": "ACTIVE", + "content": {"reference": post_id}, + }, + return_json=False, + ) + if response.status_code == 201: + res = response.headers.get("x-restli-id") + else: + raise UserError( + _( + "Error creating campaign post in Linkedin: %(error)s", + error=self.account_id._linkedin_error_message(response), + ) + ) + else: + raise UserError( + _( + "The campaign could not be generated for the post, " + "please try again later." + ) + ) + return res + + def _check_linkedin_publishable(self): + """Validate the campaign of a sponsored post before publishing. + + The sponsored creative is created after the post is online and its + failures are only reported, so everything that can be checked upfront + must fail here, while nothing irreversible has happened yet. + """ + res = super()._check_linkedin_publishable() + if self._requires_campaign_post(): + if not self.social_campaign_id.remote_ref: + raise UserError( + _( + "The campaign %(campaign)s has not been created on " + "LinkedIn yet. Use the 'Create in LinkedIn' button " + "on the campaign before posting.", + campaign=self.social_campaign_id.display_name, + ) + ) + self._check_linkedin_campaign_format() + self._linkedin_advertising_accounts() + return res + + def _register_creative_failure(self, post_entity): + """Log and report a creative that could not be created. + + The post itself is already online, so the failure is surfaced on the + post chatter instead of being raised. Publications imported from + LinkedIn have no parent post, hence the guard. + + :param post_entity: URN of the post published on LinkedIn. + """ + _logger.exception( + "Error creating the LinkedIn campaign creative for post %s", + post_entity, + ) + if self.post_id: + self.post_id.message_post( + body=_( + "The post was published on LinkedIn but the campaign " + "creative could not be created. Check the Ads permissions " + "of the account." + ) + ) + + def _linkedin_published_values(self, post_entity): + """Store the URN of the sponsored creative of the post. + + This runs inside :meth:`_publish_guard` after ``remote_ref`` has been + written, so no error may escape: rolling back the savepoint here would + drop the reference of a post that already exists on LinkedIn, and + retrying would publish it a second time. The only exception is the + concurrency errors that :meth:`_publish_guard` re-raises on purpose: + they must keep propagating so Odoo retries the whole transaction. + + :param post_entity: URN of the post published on LinkedIn. + :rtype: dict + """ + values = super()._linkedin_published_values(post_entity) + if not self._requires_campaign_post(): + return values + try: + values["creative_urn"] = self._action_campaign_post(post_entity) + except psycopg2.OperationalError as error: + if error.pgcode in PG_CONCURRENCY_ERRORS_TO_RETRY: + raise + self._register_creative_failure(post_entity) + except Exception: # noqa: BLE001 - the post is already published + self._register_creative_failure(post_entity) + return values diff --git a/social_media_advertising_linkedin/pyproject.toml b/social_media_advertising_linkedin/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/social_media_advertising_linkedin/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/social_media_advertising_linkedin/readme/CONFIGURE.md b/social_media_advertising_linkedin/readme/CONFIGURE.md new file mode 100644 index 0000000000..347e34eeef --- /dev/null +++ b/social_media_advertising_linkedin/readme/CONFIGURE.md @@ -0,0 +1,55 @@ +The LinkedIn application. +--------------- + +- The campaign features (*Create in LinkedIn* and *Import campaigns*) require + an advertising account in the LinkedIn Campaign Manager and the following + products, which the *Social Media Linkedin* module does not request on its + own. Ask for them on the *Products* tab of your LinkedIn Developer App, + following the configuration steps of that module: + * Advertising API + * LinkedIn Ad Library + + The access levels of those products are described in the + [LinkedIn Marketing API documentation](https://learn.microsoft.com/en-us/linkedin/marketing/getting-started). +- The member who authorizes the account must also have a role on that + advertising account (`ACCOUNT_BILLING_ADMIN`, `ACCOUNT_MANAGER`, + `CAMPAIGN_MANAGER`, `CREATIVE_MANAGER` or `VIEWER`, the last one read + only), as listed in the + [account users documentation](https://learn.microsoft.com/en-us/linkedin/marketing/integrations/ads/account-structure/create-and-manage-account-users). + LinkedIn only answers the advertising accounts of the authorized + member, so without a role the import finds nothing. +- That requirement is unrelated to the formats LinkedIn accepts as an ad: a + post with several images cannot be sponsored with any product nor with any + advertising account, because LinkedIn does not turn a multi-image post into + an ad. See the ROADMAP section about it. + +Re-authorize the already associated accounts. +--------------- + +- Installing this module adds the `r_ads`, `rw_ads` and `r_ads_reporting` + OAuth scopes to the LinkedIn authorization. The tokens already issued keep + the scopes they were granted with — refreshing a token does not renegotiate + them — so every LinkedIn account that was authorized **before** installing + this module must be re-authorized from the account wizard (*Update + account* > *Update token*) before it can call the Ads API. +- These scopes are only requested while this module is installed, because + LinkedIn rejects the authorization of an application that asks for them + without the *Advertising API* product enabled. + +Test and production advertising accounts. +--------------- + +- The *Environment* of the account maps to the `test` flag + LinkedIn puts on an advertising account. **LinkedIn sets that flag when the + advertising account is created and it can never be changed afterwards**, so + a test advertising account stays a test one for good. +- A test advertising account can only be created through the API, never from + the Campaign Manager, and **each developer application may only have one**. + Only `BUSINESS` advertising accounts can be test ones: `ENTERPRISE` cannot. +- Inside a test advertising account the creatives are **never served and are + automatically rejected** in the review process, and `/adAnalytics` returns + no data at all. Empty statistics in *Test* are therefore expected, not a + failure of the module. +- The role requirement above is what makes the list of advertising accounts + return anything: LinkedIn only answers the advertising accounts the + authorized member holds a role on. diff --git a/social_media_advertising_linkedin/readme/CONTRIBUTORS.md b/social_media_advertising_linkedin/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..ab4e0fb41b --- /dev/null +++ b/social_media_advertising_linkedin/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Binhex] (https://www.binhex.cloud): + - Edilio Escalona Almira diff --git a/social_media_advertising_linkedin/readme/DESCRIPTION.md b/social_media_advertising_linkedin/readme/DESCRIPTION.md new file mode 100644 index 0000000000..8480231467 --- /dev/null +++ b/social_media_advertising_linkedin/readme/DESCRIPTION.md @@ -0,0 +1,11 @@ +This module plugs LinkedIn Ads into *Social Media Advertising*. + +Main features: + +- Campaign group and campaign management synchronized with LinkedIn Ads: + create them in LinkedIn from Odoo (in draft state) or import the existing + ones, and link posts to campaigns as sponsored creatives. +- The LinkedIn statuses of campaigns, campaign groups and ads (Draft, + Active, Paused, Archived...) are declared as stages of *Social Media + Advertising*, so the badges and the status bars show the real LinkedIn + vocabulary. diff --git a/social_media_advertising_linkedin/readme/ROADMAP.md b/social_media_advertising_linkedin/readme/ROADMAP.md new file mode 100644 index 0000000000..b387207688 --- /dev/null +++ b/social_media_advertising_linkedin/readme/ROADMAP.md @@ -0,0 +1,37 @@ +Campaign objectives +------------------- + +- LinkedIn defines seven campaign objectives: brand awareness, video views, + website visits, engagement, lead generation, website conversions and job + applicants, as listed in the + [campaigns documentation](https://learn.microsoft.com/en-us/linkedin/marketing/integrations/ads/account-structure/create-and-manage-campaigns). +- Only the first four are offered by this module. The other three need a + configuration that lives on LinkedIn and that the module does not create + nor check: lead generation needs a lead gen form, website conversions need + conversion tracking, and job applicants needs LinkedIn Talent Solutions and + is not compatible with the video format. Selecting them without that + configuration is refused by LinkedIn when the campaign is created. +- Campaigns imported from LinkedIn keep their real objective; the ones that + are not offered here are simply not shown in the field. + +Sponsored posts with several images +----------------------------------- + +- A post with more than one image is published as a multi-image post, and + LinkedIn does not sponsor that format: *"API partners can only create + non-sponsored multiImage posts"*. Linking such a post to a campaign is + refused before publishing, so the post is not left online without its ad, + and the post form warns about it as soon as the campaign is selected. + + https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/multiimage-post-api + +- This is a restriction of the content format, not of the access level of the + application nor of the advertising account: no LinkedIn plan sponsors a + multi-image post. +- The format LinkedIn does offer for several sponsored images is Carousel + ads (`content.carousel` with an `adContext`, on a campaign whose format is + set to carousel when it is created, see the + [creatives documentation](https://learn.microsoft.com/en-us/linkedin/marketing/integrations/ads/account-structure/create-and-manage-creatives)). + This module does not implement it, so + the case is not supported here even though the platform allows it. +- Publish the post with a single image if it has to be sponsored. diff --git a/social_media_advertising_linkedin/readme/USAGE.md b/social_media_advertising_linkedin/readme/USAGE.md new file mode 100644 index 0000000000..d5bb5adba7 --- /dev/null +++ b/social_media_advertising_linkedin/readme/USAGE.md @@ -0,0 +1,175 @@ +Choose the LinkedIn advertising account. +--------------- + +- Go to *Social Media* > Configuration > Accounts, open the LinkedIn account + and its *Advertising* tab, then click *Fetch advertising accounts*. +- Besides the generic columns, the list shows what LinkedIn reports: + - *LinkedIn Status*: `DRAFT`, `ACTIVE`, `CANCELED`, `PENDING_DELETION` or + `REMOVED`. + - *LinkedIn Serving Status*: `RUNNABLE` when the advertising account is + eligible for serving, otherwise the reasons why it is not, such as + `BILLING_HOLD`, `ACCOUNT_TOTAL_BUDGET_HOLD`, `ACCOUNT_END_DATE_HOLD`, + `RESTRICTED_HOLD`, `INTERNAL_HOLD` or `STOPPED`. This is the column to + look at when nothing is being served. + - *LinkedIn Type*: `BUSINESS` or `ENTERPRISE`. + - *LinkedIn Owner*: the organization or person the advertising account + advertises on behalf of, which tells apart two advertising accounts + sharing a name. +- *Campaign Manager URL* opens the advertising account on LinkedIn. +- *Create in LinkedIn*, *Import campaigns* and the sponsored creatives all + work against the advertising account marked *In Use*. + +Create a campaign in LinkedIn. +--------------- + +To link a post to a campaign, the campaign must already exist in LinkedIn +(only campaigns already synchronized with LinkedIn can be selected on the +post). Publishing a post does not create the campaign anymore. + +- Go to *Social Media* > Advertising > Campaigns and open the campaign +- Fill in the campaign group, account, budget and currency fields +- Choose the *LinkedIn Ad Format*: *Standard update* for posts with text or + images, *Single video* for posts with a video. LinkedIn fixes the format + when the campaign is created and only accepts posts of that format + afterwards, so a post with a video needs its own video campaign. The + fields of a campaign are the ones of the + [campaigns documentation](https://learn.microsoft.com/en-us/linkedin/marketing/integrations/ads/account-structure/create-and-manage-campaigns). +- The *LinkedIn Objective* is required by LinkedIn for the video format. + See the ROADMAP section about the objectives that are not offered here. +- The *LinkedIn Political Intent* is the declaration LinkedIn requires to + create any campaign since the EU political advertising regulation. It + defaults to *Not political advertising*, which states that none of your + ads qualify as political advertising under the law of the targeted + countries, including EU law for ads targeted to the EU. Change it to + *Political advertising* when they do. +- Click on the *Create in LinkedIn* button +- The campaign group and the campaign are created in LinkedIn in **DRAFT** + status, so no budget is spent until you activate them from the LinkedIn + Campaign Manager. +- If the campaign cannot be created, the error is shown in a notification + and logged in the chatter instead of being raised: the campaign group is + already created on LinkedIn, which does not allow deleting it, so its + reference is kept to avoid importing it later as a duplicate. Fix the + reported problem and press *Create in LinkedIn* again; the existing group + is reused. +- A campaign group can also be created on LinkedIn on its own: open the + group and click its *Create in LinkedIn* button (it requires a positive + total budget and a currency). If you forget to do it, the group is + created automatically when the first campaign of the group is published. +- When a post linked to a campaign is published, it is attached to the + campaign as a + [sponsored creative](https://learn.microsoft.com/en-us/linkedin/marketing/integrations/ads/account-structure/create-and-manage-creatives). + If the post is published but the creative + cannot be linked, the post is kept and the error is logged in the chatter. +- A post with more than one image cannot be sponsored: LinkedIn publishes it + as a multi-image post and does not accept that format as an ad, so linking + it to a campaign is refused before publishing. The post form warns about it + as soon as the campaign is selected, without waiting for the *Post* button. + +Import campaigns from LinkedIn. +--------------- + +- Go to *Social Media* > Configuration > Accounts +- Select the account +- Click on the *Import campaigns* button +- The campaign groups, campaigns and their sponsored creatives are imported + from LinkedIn Ads. Every creative is matched with the publication it + promotes by its remote reference. +- A publication brought from the wall with *Update* has no post in Odoo, so + its campaign is taken from the creative and its badge appears on the + dashboard. Publications published from Odoo keep the campaign of their + post and are never overwritten by the import. +- The Posts API does not return the campaign of a post, so only this import + can resolve it. The order does not matter, but the publications have to be + already in Odoo: if they are brought **after** importing the campaigns, + press *Import campaigns* again. +- Campaign groups created on LinkedIn often carry no total budget, which is + optional there, as described in the + [campaign groups documentation](https://learn.microsoft.com/en-us/linkedin/marketing/integrations/ads/account-structure/create-and-manage-campaign-groups). Those groups set no limit in Odoo either: the rule keeping + the daily budgets of the campaigns within the total budget of their group + only applies when the group has one, and it is never checked on the values + brought by the import. The rule is enforced from both sides: raising the + daily budget of a campaign, moving it to another group, and lowering the + total budget of a group are all refused when the sum goes over it. +- The real LinkedIn status of each campaign and campaign group (Draft, + Active, Paused, Archived...) is stored in its *Stage*, shown as a status + bar on the form and refreshed on every import. Campaigns flagged as test + campaigns on LinkedIn are marked as well. +- Campaigns and campaign groups deleted on LinkedIn (*Pending deletion*, + *Removed*) are hidden in the LinkedIn Campaign Manager, but the API + keeps returning them because of their performance data. They are + therefore kept in Odoo as history, and a note is logged in their chatter + when the deletion is detected. Keep in mind that deleting a campaign + group on LinkedIn also deletes all its campaigns and ads. + +Update a campaign in LinkedIn. +--------------- + +- When you modify in Odoo the editable fields of a campaign (name, unit + cost, daily budget, campaign group) or of a campaign group (name, total + budget) that already exists on LinkedIn, the record shows a *Pending + changes* ribbon, a warning banner explaining that the local changes have + not been pushed yet, and an *Update in LinkedIn* button. A record that + does not exist on LinkedIn yet carries the *Not synced* ribbon instead. The tracked fields also + log their changes in the chatter. + + ![CAMPAIGN_NOT_SYNCED](../static/img/readme/CAMPAIGN_NOT_SYNCED.png) + +- Click *Update in LinkedIn* to push the local values to LinkedIn. To move + the campaign to another campaign group, the target group must already + exist on LinkedIn. The political advertising declaration is pushed the + same way, since LinkedIn allows updating it when the targeting changes. +- Synchronization priority is hybrid: the LinkedIn-only fields (stage, + test flag) are always refreshed by *Import campaigns*; for the editable + fields, your local pending changes are preserved by the import — the + LinkedIn values are logged in the chatter so you can decide whether to + keep your changes (push them with *Update in LinkedIn*) or re-type the + LinkedIn ones. +- When the LinkedIn stage is *Archived*, *Canceled*, *Pending deletion* + or *Removed*, the campaign or campaign group cannot be modified in Odoo + (LinkedIn does not allow editing them either): the editable fields + become read-only, the *Update in LinkedIn* button is hidden and a red + banner explains the situation. The *Media* of a campaign that exists on + LinkedIn is read-only as well, so the lock cannot be escaped by taking the + campaign out of LinkedIn. The import keeps refreshing their stage, + so an archived record becomes editable again once it is unarchived on + LinkedIn. + +Archive a campaign or a campaign group in LinkedIn. +--------------- + +- LinkedIn does **not** allow deleting a campaign or a campaign group + through its API, whose statuses are the ones of the + [campaigns documentation](https://learn.microsoft.com/en-us/linkedin/marketing/integrations/ads/account-structure/create-and-manage-campaigns). + Archiving is the only way to end them, and it is what the + *Archive in LinkedIn* button does. +- Go to *Social Media* > Advertising, open the campaign or the campaign + group and click *Archive in LinkedIn*. The button is only shown when the + record already exists on LinkedIn and is not already archived, canceled or + deleted. + + ![CAMPAIGN_ARCHIVE](../static/img/readme/CAMPAIGN_ARCHIVE.png) + +- LinkedIn refuses to archive a campaign while its campaign group is still + in **Draft**: activate the group in the Campaign Manager first, or archive + the group instead, which archives its campaigns. Odoo checks it before + calling LinkedIn and explains it. +- A campaign or a campaign group still in Draft keeps the start date given + when it was created, and LinkedIn rejects any later change because that + date already belongs to the past. Odoo therefore sends a new run schedule + when it updates or archives a record in Draft, which moves its start date + to the moment of the change. +- The campaign stops running on LinkedIn and disappears from the Campaign + Manager active lists, but it is kept in Odoo with its performance data. + Its stage becomes *Archived*, which makes the record read-only in Odoo, + because LinkedIn no longer accepts changes on it. +- **It cannot be reactivated from Odoo.** To unarchive it, use the LinkedIn + Campaign Manager and then run *Import campaigns* to refresh the stage. +- Archiving a campaign group archives its campaigns on LinkedIn as well, + except the ones still in Draft, which LinkedIn leaves untouched. Odoo does + not know about any of it until you run *Import campaigns*, so run it + afterwards to refresh them, and archive the draft campaigns one by one if + you want them archived too. +- Deleting the campaign in Odoo does not do anything on LinkedIn: the + campaign keeps running there and the next *Import campaigns* brings it + back. Use *Archive in LinkedIn* to actually end it. diff --git a/social_media_advertising_linkedin/social_advertising_linkedin_utils.py b/social_media_advertising_linkedin/social_advertising_linkedin_utils.py new file mode 100644 index 0000000000..31138583e6 --- /dev/null +++ b/social_media_advertising_linkedin/social_advertising_linkedin_utils.py @@ -0,0 +1,20 @@ +# Copyright 2026 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +_URL_CAMPAIGN_MANAGER_LINKEDIN = "https://www.linkedin.com/campaignmanager/accounts/" + +_FIELDS_CAMPAIGN_LINKEDIN = "id,name,test,account,format,objectiveType,runSchedule" + +_ENDPOINT_AD_ACCOUNTS_LINKEDIN = "/adAccounts" +_ENDPOINT_AD_ACCOUNT_USERS_LINKEDIN = "/adAccountUsers" +_ENDPOINT_AD_CAMPAIGN_GROUPS_LINKEDIN = "/adAccounts/%s/adCampaignGroups" +_ENDPOINT_AD_CAMPAIGNS_LINKEDIN = "/adAccounts/%s/adCampaigns" +_ENDPOINT_AD_ANALYTICS_LINKEDIN = "/adAnalytics" + +_PAGE_SIZE_LINKEDIN = 100 +_FIELDS_STATISTIC_LINKEDIN = ( + "actionClicks,adUnitClicks,clicks,costInUsd," + "externalWebsiteConversions,impressions,pivotValues" +) + +_SCOPE_ADS_LINKEDIN = ["r_ads", "rw_ads", "r_ads_reporting"] diff --git a/social_media_advertising_linkedin/static/description/icon.png b/social_media_advertising_linkedin/static/description/icon.png new file mode 100644 index 0000000000..9cd4ce2988 Binary files /dev/null and b/social_media_advertising_linkedin/static/description/icon.png differ diff --git a/social_media_advertising_linkedin/static/description/index.html b/social_media_advertising_linkedin/static/description/index.html new file mode 100644 index 0000000000..65dc5b5c7e --- /dev/null +++ b/social_media_advertising_linkedin/static/description/index.html @@ -0,0 +1,779 @@ + + + + + +Social Media Advertising LinkedIn + + + +
+

Social Media Advertising LinkedIn

+ + +

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

+

This module plugs LinkedIn Ads into Social Media Advertising.

+

Main features:

+
    +
  • Campaign group and campaign management synchronized with LinkedIn Ads: +create them in LinkedIn from Odoo (in draft state) or import the +existing ones, and link posts to campaigns as sponsored creatives.
  • +
  • The LinkedIn statuses of campaigns, campaign groups and ads (Draft, +Active, Paused, Archived…) are declared as stages of Social Media +Advertising, so the badges and the status bars show the real LinkedIn +vocabulary.
  • +
+

Table of contents

+ +
+

Configuration

+
+

The LinkedIn application.

+
    +
  • The campaign features (Create in LinkedIn and Import campaigns) +require an advertising account in the LinkedIn Campaign Manager and +the following products, which the Social Media Linkedin module does +not request on its own. Ask for them on the Products tab of your +LinkedIn Developer App, following the configuration steps of that +module:

    +
      +
    • Advertising API
    • +
    • LinkedIn Ad Library
    • +
    +

    The access levels of those products are described in the LinkedIn +Marketing API +documentation.

    +
  • +
  • The member who authorizes the account must also have a role on that +advertising account (ACCOUNT_BILLING_ADMIN, ACCOUNT_MANAGER, +CAMPAIGN_MANAGER, CREATIVE_MANAGER or VIEWER, the last one +read only), as listed in the account users +documentation. +LinkedIn only answers the advertising accounts of the authorized +member, so without a role the import finds nothing.

    +
  • +
  • That requirement is unrelated to the formats LinkedIn accepts as an +ad: a post with several images cannot be sponsored with any product +nor with any advertising account, because LinkedIn does not turn a +multi-image post into an ad. See the ROADMAP section about it.

    +
  • +
+
+
+

Re-authorize the already associated accounts.

+
    +
  • Installing this module adds the r_ads, rw_ads and +r_ads_reporting OAuth scopes to the LinkedIn authorization. The +tokens already issued keep the scopes they were granted with — +refreshing a token does not renegotiate them — so every LinkedIn +account that was authorized before installing this module must be +re-authorized from the account wizard (Update account > Update +token) before it can call the Ads API.
  • +
  • These scopes are only requested while this module is installed, +because LinkedIn rejects the authorization of an application that asks +for them without the Advertising API product enabled.
  • +
+
+
+

Test and production advertising accounts.

+
    +
  • The Environment of the account maps to the test flag LinkedIn +puts on an advertising account. LinkedIn sets that flag when the +advertising account is created and it can never be changed +afterwards, so a test advertising account stays a test one for good.
  • +
  • A test advertising account can only be created through the API, never +from the Campaign Manager, and each developer application may only +have one. Only BUSINESS advertising accounts can be test ones: +ENTERPRISE cannot.
  • +
  • Inside a test advertising account the creatives are never served and +are automatically rejected in the review process, and +/adAnalytics returns no data at all. Empty statistics in Test +are therefore expected, not a failure of the module.
  • +
  • The role requirement above is what makes the list of advertising +accounts return anything: LinkedIn only answers the advertising +accounts the authorized member holds a role on.
  • +
+
+
+
+

Usage

+
+

Choose the LinkedIn advertising account.

+
    +
  • Go to Social Media > Configuration > Accounts, open the LinkedIn +account and its Advertising tab, then click Fetch advertising +accounts.
  • +
  • Besides the generic columns, the list shows what LinkedIn reports:
      +
    • LinkedIn Status: DRAFT, ACTIVE, CANCELED, +PENDING_DELETION or REMOVED.
    • +
    • LinkedIn Serving Status: RUNNABLE when the advertising account +is eligible for serving, otherwise the reasons why it is not, such +as BILLING_HOLD, ACCOUNT_TOTAL_BUDGET_HOLD, +ACCOUNT_END_DATE_HOLD, RESTRICTED_HOLD, INTERNAL_HOLD or +STOPPED. This is the column to look at when nothing is being +served.
    • +
    • LinkedIn Type: BUSINESS or ENTERPRISE.
    • +
    • LinkedIn Owner: the organization or person the advertising account +advertises on behalf of, which tells apart two advertising accounts +sharing a name.
    • +
    +
  • +
  • Campaign Manager URL opens the advertising account on LinkedIn.
  • +
  • Create in LinkedIn, Import campaigns and the sponsored creatives +all work against the advertising account marked In Use.
  • +
+
+
+

Create a campaign in LinkedIn.

+

To link a post to a campaign, the campaign must already exist in +LinkedIn (only campaigns already synchronized with LinkedIn can be +selected on the post). Publishing a post does not create the campaign +anymore.

+
    +
  • Go to Social Media > Advertising > Campaigns and open the campaign
  • +
  • Fill in the campaign group, account, budget and currency fields
  • +
  • Choose the LinkedIn Ad Format: Standard update for posts with text +or images, Single video for posts with a video. LinkedIn fixes the +format when the campaign is created and only accepts posts of that +format afterwards, so a post with a video needs its own video +campaign. The fields of a campaign are the ones of the campaigns +documentation.
  • +
  • The LinkedIn Objective is required by LinkedIn for the video format. +See the ROADMAP section about the objectives that are not offered +here.
  • +
  • The LinkedIn Political Intent is the declaration LinkedIn requires +to create any campaign since the EU political advertising regulation. +It defaults to Not political advertising, which states that none of +your ads qualify as political advertising under the law of the +targeted countries, including EU law for ads targeted to the EU. +Change it to Political advertising when they do.
  • +
  • Click on the Create in LinkedIn button
  • +
  • The campaign group and the campaign are created in LinkedIn in +DRAFT status, so no budget is spent until you activate them from +the LinkedIn Campaign Manager.
  • +
  • If the campaign cannot be created, the error is shown in a +notification and logged in the chatter instead of being raised: the +campaign group is already created on LinkedIn, which does not allow +deleting it, so its reference is kept to avoid importing it later as a +duplicate. Fix the reported problem and press Create in LinkedIn +again; the existing group is reused.
  • +
  • A campaign group can also be created on LinkedIn on its own: open the +group and click its Create in LinkedIn button (it requires a +positive total budget and a currency). If you forget to do it, the +group is created automatically when the first campaign of the group is +published.
  • +
  • When a post linked to a campaign is published, it is attached to the +campaign as a sponsored +creative. +If the post is published but the creative cannot be linked, the post +is kept and the error is logged in the chatter.
  • +
  • A post with more than one image cannot be sponsored: LinkedIn +publishes it as a multi-image post and does not accept that format as +an ad, so linking it to a campaign is refused before publishing. The +post form warns about it as soon as the campaign is selected, without +waiting for the Post button.
  • +
+
+
+

Import campaigns from LinkedIn.

+
    +
  • Go to Social Media > Configuration > Accounts
  • +
  • Select the account
  • +
  • Click on the Import campaigns button
  • +
  • The campaign groups, campaigns and their sponsored creatives are +imported from LinkedIn Ads. Every creative is matched with the +publication it promotes by its remote reference.
  • +
  • A publication brought from the wall with Update has no post in Odoo, +so its campaign is taken from the creative and its badge appears on +the dashboard. Publications published from Odoo keep the campaign of +their post and are never overwritten by the import.
  • +
  • The Posts API does not return the campaign of a post, so only this +import can resolve it. The order does not matter, but the publications +have to be already in Odoo: if they are brought after importing +the campaigns, press Import campaigns again.
  • +
  • Campaign groups created on LinkedIn often carry no total budget, which +is optional there, as described in the campaign groups +documentation. +Those groups set no limit in Odoo either: the rule keeping the daily +budgets of the campaigns within the total budget of their group only +applies when the group has one, and it is never checked on the values +brought by the import. The rule is enforced from both sides: raising +the daily budget of a campaign, moving it to another group, and +lowering the total budget of a group are all refused when the sum goes +over it.
  • +
  • The real LinkedIn status of each campaign and campaign group (Draft, +Active, Paused, Archived…) is stored in its Stage, shown as a +status bar on the form and refreshed on every import. Campaigns +flagged as test campaigns on LinkedIn are marked as well.
  • +
  • Campaigns and campaign groups deleted on LinkedIn (Pending deletion, +Removed) are hidden in the LinkedIn Campaign Manager, but the API +keeps returning them because of their performance data. They are +therefore kept in Odoo as history, and a note is logged in their +chatter when the deletion is detected. Keep in mind that deleting a +campaign group on LinkedIn also deletes all its campaigns and ads.
  • +
+
+
+

Update a campaign in LinkedIn.

+
    +
  • When you modify in Odoo the editable fields of a campaign (name, unit +cost, daily budget, campaign group) or of a campaign group (name, +total budget) that already exists on LinkedIn, the record shows a +Pending changes ribbon, a warning banner explaining that the local +changes have not been pushed yet, and an Update in LinkedIn button. +A record that does not exist on LinkedIn yet carries the Not synced +ribbon instead. The tracked fields also log their changes in the +chatter.

    +

    CAMPAIGN_NOT_SYNCED

    +
  • +
  • Click Update in LinkedIn to push the local values to LinkedIn. To +move the campaign to another campaign group, the target group must +already exist on LinkedIn. The political advertising declaration is +pushed the same way, since LinkedIn allows updating it when the +targeting changes.

    +
  • +
  • Synchronization priority is hybrid: the LinkedIn-only fields (stage, +test flag) are always refreshed by Import campaigns; for the +editable fields, your local pending changes are preserved by the +import — the LinkedIn values are logged in the chatter so you can +decide whether to keep your changes (push them with Update in +LinkedIn) or re-type the LinkedIn ones.

    +
  • +
  • When the LinkedIn stage is Archived, Canceled, Pending deletion +or Removed, the campaign or campaign group cannot be modified in +Odoo (LinkedIn does not allow editing them either): the editable +fields become read-only, the Update in LinkedIn button is hidden and +a red banner explains the situation. The Media of a campaign that +exists on LinkedIn is read-only as well, so the lock cannot be escaped +by taking the campaign out of LinkedIn. The import keeps refreshing +their stage, so an archived record becomes editable again once it is +unarchived on LinkedIn.

    +
  • +
+
+
+

Archive a campaign or a campaign group in LinkedIn.

+
    +
  • LinkedIn does not allow deleting a campaign or a campaign group +through its API, whose statuses are the ones of the campaigns +documentation. +Archiving is the only way to end them, and it is what the Archive in +LinkedIn button does.

    +
  • +
  • Go to Social Media > Advertising, open the campaign or the campaign +group and click Archive in LinkedIn. The button is only shown when +the record already exists on LinkedIn and is not already archived, +canceled or deleted.

    +

    CAMPAIGN_ARCHIVE

    +
  • +
  • LinkedIn refuses to archive a campaign while its campaign group is +still in Draft: activate the group in the Campaign Manager first, +or archive the group instead, which archives its campaigns. Odoo +checks it before calling LinkedIn and explains it.

    +
  • +
  • A campaign or a campaign group still in Draft keeps the start date +given when it was created, and LinkedIn rejects any later change +because that date already belongs to the past. Odoo therefore sends a +new run schedule when it updates or archives a record in Draft, which +moves its start date to the moment of the change.

    +
  • +
  • The campaign stops running on LinkedIn and disappears from the +Campaign Manager active lists, but it is kept in Odoo with its +performance data. Its stage becomes Archived, which makes the record +read-only in Odoo, because LinkedIn no longer accepts changes on it.

    +
  • +
  • It cannot be reactivated from Odoo. To unarchive it, use the +LinkedIn Campaign Manager and then run Import campaigns to refresh +the stage.

    +
  • +
  • Archiving a campaign group archives its campaigns on LinkedIn as well, +except the ones still in Draft, which LinkedIn leaves untouched. Odoo +does not know about any of it until you run Import campaigns, so run +it afterwards to refresh them, and archive the draft campaigns one by +one if you want them archived too.

    +
  • +
  • Deleting the campaign in Odoo does not do anything on LinkedIn: the +campaign keeps running there and the next Import campaigns brings it +back. Use Archive in LinkedIn to actually end it.

    +
  • +
+
+
+
+

Known issues / Roadmap

+
+

Campaign objectives

+
    +
  • LinkedIn defines seven campaign objectives: brand awareness, video +views, website visits, engagement, lead generation, website +conversions and job applicants, as listed in the campaigns +documentation.
  • +
  • Only the first four are offered by this module. The other three need a +configuration that lives on LinkedIn and that the module does not +create nor check: lead generation needs a lead gen form, website +conversions need conversion tracking, and job applicants needs +LinkedIn Talent Solutions and is not compatible with the video format. +Selecting them without that configuration is refused by LinkedIn when +the campaign is created.
  • +
  • Campaigns imported from LinkedIn keep their real objective; the ones +that are not offered here are simply not shown in the field.
  • +
+
+ +
+
+

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_advertising_linkedin/static/img/readme/CAMPAIGN_ARCHIVE.png b/social_media_advertising_linkedin/static/img/readme/CAMPAIGN_ARCHIVE.png new file mode 100644 index 0000000000..0dd217e029 Binary files /dev/null and b/social_media_advertising_linkedin/static/img/readme/CAMPAIGN_ARCHIVE.png differ diff --git a/social_media_advertising_linkedin/static/img/readme/CAMPAIGN_NOT_SYNCED.png b/social_media_advertising_linkedin/static/img/readme/CAMPAIGN_NOT_SYNCED.png new file mode 100644 index 0000000000..fd72d37601 Binary files /dev/null and b/social_media_advertising_linkedin/static/img/readme/CAMPAIGN_NOT_SYNCED.png differ diff --git a/social_media_advertising_linkedin/tests/__init__.py b/social_media_advertising_linkedin/tests/__init__.py new file mode 100644 index 0000000000..4e47b081d8 --- /dev/null +++ b/social_media_advertising_linkedin/tests/__init__.py @@ -0,0 +1,8 @@ +# Copyright 2026 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import test_common_advertising_linkedin +from . import test_social_account_advertising_linkedin +from . import test_social_campaign_linkedin +from . import test_social_campaign_group_linkedin +from . import test_social_post_advertising_linkedin diff --git a/social_media_advertising_linkedin/tests/test_common_advertising_linkedin.py b/social_media_advertising_linkedin/tests/test_common_advertising_linkedin.py new file mode 100644 index 0000000000..9cb9076f9f --- /dev/null +++ b/social_media_advertising_linkedin/tests/test_common_advertising_linkedin.py @@ -0,0 +1,98 @@ +# Copyright 2026 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.fields import Command + +from odoo.addons.social_media_linkedin.tests.test_common_linkedin import ( + TestSocialCommonLinkedin, +) + +PATCH_ADVERTISING_LINKEDIN = "odoo.addons.social_media_advertising_linkedin.models.{}" +PATCH_ADVERTISING_ACCOUNT_LINKEDIN = PATCH_ADVERTISING_LINKEDIN.format( + "social_account.SocialAccount.{}" +) +PATCH_ADVERTISING_CAMPAIGN_LINKEDIN = PATCH_ADVERTISING_LINKEDIN.format( + "social_campaign.SocialCampaign.{}" +) +PATCH_ADVERTISING_CAMPAIGN_GROUP_LINKEDIN = PATCH_ADVERTISING_LINKEDIN.format( + "social_campaign_group.SocialCampaignGroup.{}" +) +PATCH_ADVERTISING_POST_ACCOUNT_LINKEDIN = PATCH_ADVERTISING_LINKEDIN.format( + "social_post_account.SocialPostAccount.{}" +) + + +class TestSocialCommonAdvertisingLinkedin(TestSocialCommonLinkedin): + """Advertising fixtures shared by the LinkedIn connector tests. + + The campaigns are linked to the ``social.media`` record shipped as data + by ``social_media_linkedin`` because the LinkedIn stages of + ``social_media_advertising_linkedin`` belong to it, and the domain of + ``stage_id`` requires the campaign and the stage to share the media. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.SocialCampaign = cls.env["social.campaign"] + cls.SocialCampaignGroup = cls.env["social.campaign.group"] + cls.SocialStage = cls.env["social.stage"] + cls.media_linkedin_data_id = cls.env.ref( + "social_media_linkedin.social_media_linkedin" + ) + cls.AdvertisingAccountLinkedin = cls.env["social.advertising.account"].create( + { + "account_id": cls.SocialAccountLinkedin.id, + "name": "LinkedIn Ads", + "remote_ref": "urn:li:sponsoredAccount:999", + "environment": "test", + "is_current": True, + } + ) + + cls.SocialCampaignGroupLinkedin = cls.SocialCampaignGroup.create( + { + "name": "Campaign Group 1", + "remote_ref": "urn:li:sponsoredCampaignGroup:456", + "total_budget": 10000, + "currency_id": cls.env.ref("base.USD").id, + } + ) + + cls.SocialCampaignLinkedin = cls.SocialCampaign.create( + { + "name": "Campaign 1", + "campaign_group_id": cls.SocialCampaignGroupLinkedin.id, + "remote_ref": "urn:li:sponsoredCampaign:001", + "media_id": cls.media_linkedin_data_id.id, + "account_ids": [Command.link(cls.SocialAccountLinkedin.id)], + } + ) + + cls.SocialCampaignLinkedin2 = cls.SocialCampaign.create( + { + "name": "Campaign 2", + "campaign_group_id": cls.SocialCampaignGroupLinkedin.id, + "remote_ref": "urn:li:sponsoredCampaign:002", + } + ) + + cls.SocialPostCampaignLinkedin = cls.SocialPost.create( + { + "message": "Test Message for Campaign", + "account_ids": [Command.set(cls.SocialAccountLinkedin.ids)], + "social_campaign_id": cls.SocialCampaignLinkedin.id, + } + ) + cls.SocialPostAccountCampaignLinkedin = cls.SocialPostAccount.create( + { + "message": "Test Message for Campaign", + "account_id": cls.SocialAccountLinkedin.id, + "media_id": cls.media_linkedin_id.id, + "post_id": cls.SocialPostCampaignLinkedin.id, + } + ) + + def get_stage_linkedin(self, applies_to, code): + """Shortcut to the LinkedIn stage of a scope and code.""" + return self.SocialStage._get_stage("linkedin", applies_to, code) diff --git a/social_media_advertising_linkedin/tests/test_social_account_advertising_linkedin.py b/social_media_advertising_linkedin/tests/test_social_account_advertising_linkedin.py new file mode 100644 index 0000000000..edbb7044ff --- /dev/null +++ b/social_media_advertising_linkedin/tests/test_social_account_advertising_linkedin.py @@ -0,0 +1,1096 @@ +# Copyright 2026 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from unittest.mock import MagicMock, patch + +from odoo.exceptions import UserError +from odoo.fields import Command +from odoo.tools import mute_logger + +from odoo.addons.social_media_base.social_utils import _generate_timestamps +from odoo.addons.social_media_base.tests.test_social_common import PATCH_ACCOUNT + +from .test_common_advertising_linkedin import ( + PATCH_ADVERTISING_ACCOUNT_LINKEDIN, + TestSocialCommonAdvertisingLinkedin, +) + +PATCH_ACCOUNT_LINKEDIN = ( + "odoo.addons.social_media_linkedin.models.social_account.SocialAccount.{}" +) + + +class TestSocialAccountAdvertisingLinkedin(TestSocialCommonAdvertisingLinkedin): + def test_get_campaigns(self): + mock_response = self.generate_magic_mock( + **{ + "status_code": 200, + "json_return_value": { + "elements": [ + {"id": 123, "name": "Campaign A"}, + {"id": 456, "name": "Campaign B"}, + ] + }, + } + ) + + patch_request_linkedin = self.generate_patch( + **{ + "type_object": True, + "model_patch": self.SocialAccountLinkedin, + "method_patch": "_request_linkedin", + "return_value": mock_response, + } + ) + + with patch_request_linkedin as mock_request_linkedin: + result = self.SocialAccountLinkedin._get_campaigns( + start_date=self.start_datetime, + end_date=self.end_datetime, + ) + self.assertEqual(len(result), 2) + self.assertEqual(result[0]["id"], 123) + self.assertEqual(result[1]["id"], 456) + mock_request_linkedin.assert_called_once() + self.assertEqual( + mock_request_linkedin.call_args.kwargs["endpoint"], + "/adAccounts/999/adCampaigns", + ) + params_values = mock_request_linkedin.call_args.kwargs["params_values"] + expected = ( + "true" if self.SocialAccountLinkedin.environment == "test" else "false" + ) + self.assertIn(f"test:{expected}", params_values["search"]) + + with patch_request_linkedin: + result = self.SocialAccountLinkedin._get_campaigns( + start_date=self.start_datetime, + end_date=self.end_datetime, + campaign_ids=["urn:li:sponsoredCampaign:123"], + ) + self.assertEqual( + [campaign["id"] for campaign in result], + [123], + msg="The finder has no campaign criteria, so they are " + "filtered here.", + ) + + patch_request_linkedin_failed = self.generate_patch( + **{ + "type_object": True, + "model_patch": self.SocialAccountLinkedin, + "method_patch": "_request_linkedin", + "return_value": self.generate_magic_mock( + **{ + "status_code": 403, + } + ), + } + ) + with patch_request_linkedin_failed as mock_request_linkedin_failed: + with self.assertRaises(UserError): + self.SocialAccountLinkedin._get_campaigns( + start_date=self.start_datetime, + end_date=self.end_datetime, + campaign_ids=["urn:li:sponsoredCampaign:420"], + ) + mock_request_linkedin_failed.assert_called_once() + + def test_get_advertising_account_is_the_one_in_use(self): + account = self.SocialAccountLinkedin + self.assertEqual( + account._get_linkedin_advertising_account(), + "urn:li:sponsoredAccount:999", + ) + + def test_get_advertising_account_without_one_in_use(self): + """Nothing is guessed: without a chosen one there is no scope.""" + account = self.SocialAccountLinkedin + self.AdvertisingAccountLinkedin.write({"is_current": False}) + self.assertFalse(account._get_linkedin_advertising_account()) + with self.assertRaises(UserError) as error: + account._require_linkedin_ad_account_id() + self.assertIn("No LinkedIn advertising account is in use", str(error.exception)) + + def test_environment_change_drops_the_account_in_use(self): + """A production account never keeps the test advertising account.""" + account = self.SocialAccountLinkedin + account.write({"environment": "production"}) + self.assertFalse(self.AdvertisingAccountLinkedin.is_current) + self.assertFalse(account.advertising_account_urn) + self.assertFalse(account._get_linkedin_advertising_account()) + + def test_import_campaigns_without_an_advertising_account_in_use(self): + account = self.SocialAccountLinkedin + self.AdvertisingAccountLinkedin.write({"is_current": False}) + res = account.action_import_campaigns() + self.assertFalse(res["success"]) + self.assertIn("No LinkedIn advertising account is in use", res["message"]) + self.assertEqual(res["campaigns"], 0) + + def test_fetch_advertising_accounts_maps_the_payload(self): + account = self.SocialAccountLinkedin + users_response = self.generate_magic_mock( + **{ + "status_code": 200, + "json_return_value": { + "paging": {"total": 1}, + "elements": [{"account": "urn:li:sponsoredAccount:2"}], + }, + } + ) + production_account = self.generate_magic_mock( + **{ + "status_code": 200, + "json_return_value": { + "id": 2, + "test": False, + "name": "Dunder Mifflin", + "currency": "USD", + "status": "ACTIVE", + "type": "BUSINESS", + "reference": "urn:li:organization:2414183", + "servingStatuses": ["BILLING_HOLD"], + }, + } + ) + patch_request_linkedin = self.generate_patch( + **{ + "type_object": True, + "model_patch": account, + "method_patch": "_request_linkedin", + "side_effect": [users_response, production_account], + } + ) + with patch_request_linkedin: + account._sync_advertising_accounts() + advertising_account = account.advertising_account_ids.filtered( + lambda record: record.remote_ref == "urn:li:sponsoredAccount:2" + ) + self.assertEqual(advertising_account.name, "Dunder Mifflin") + self.assertEqual(advertising_account.environment, "production") + self.assertEqual(advertising_account.currency_id, self.env.ref("base.USD")) + self.assertEqual(advertising_account.linkedin_status, "ACTIVE") + self.assertEqual(advertising_account.linkedin_type, "BUSINESS") + self.assertEqual(advertising_account.linkedin_serving_status, "BILLING_HOLD") + self.assertEqual( + advertising_account.linkedin_reference, "urn:li:organization:2414183" + ) + self.assertFalse( + advertising_account.is_current, + msg="Fetching must not choose an advertising account by itself.", + ) + + def test_get_campaigns_filters_by_run_schedule(self): + """A campaign scheduled outside the requested range is dropped.""" + start_time, end_time = _generate_timestamps( + self.start_datetime, self.end_datetime + ) + mock_response = self.generate_magic_mock( + **{ + "status_code": 200, + "json_return_value": { + "elements": [ + {"id": 1, "runSchedule": {"start": start_time}}, + {"id": 2, "runSchedule": {"start": end_time + 1}}, + { + "id": 3, + "runSchedule": { + "start": start_time - 2, + "end": start_time - 1, + }, + }, + {"id": 4}, + ] + }, + } + ) + patch_request_linkedin = self.generate_patch( + **{ + "type_object": True, + "model_patch": self.SocialAccountLinkedin, + "method_patch": "_request_linkedin", + "return_value": mock_response, + } + ) + with patch_request_linkedin: + result = self.SocialAccountLinkedin._get_campaigns( + start_date=self.start_datetime, end_date=self.end_datetime + ) + self.assertEqual( + [campaign["id"] for campaign in result], + [1, 4], + msg="A campaign without run schedule is never dropped.", + ) + + def test_fetch_ad_entities_follows_the_cursor(self): + """The search finders paginate with a token, not with an index.""" + first_page = self.generate_magic_mock( + **{ + "status_code": 200, + "json_return_value": { + "elements": [{"id": 1}], + "metadata": {"nextPageToken": "token-2"}, + }, + } + ) + second_page = self.generate_magic_mock( + **{ + "status_code": 200, + "json_return_value": {"elements": [{"id": 2}], "metadata": {}}, + } + ) + patch_request_linkedin = self.generate_patch( + **{ + "type_object": True, + "model_patch": self.SocialAccountLinkedin, + "method_patch": "_request_linkedin", + "side_effect": [first_page, second_page], + } + ) + with patch_request_linkedin as mock_request_linkedin: + elements = self.SocialAccountLinkedin._fetch_linkedin_ad_entities( + "/adAccounts/999/adCampaigns" + ) + self.assertEqual([element["id"] for element in elements], [1, 2]) + self.assertEqual(mock_request_linkedin.call_count, 2) + self.assertEqual( + mock_request_linkedin.call_args.kwargs["params_values"]["pageToken"], + "token-2", + ) + + def test_get_statistics(self): + mock_response = self.generate_magic_mock( + **{ + "status_code": 200, + "json_return_value": { + "elements": [ + { + "campaign": "123", + "statistics": {"clickCount": 100, "impressionCount": 500}, + }, + { + "campaign": "456", + "statistics": {"clickCount": 200, "impressionCount": 600}, + }, + ] + }, + } + ) + patch_request_linkedin = self.generate_patch( + **{ + "type_object": True, + "model_patch": self.SocialAccountLinkedin, + "method_patch": "_request_linkedin", + "return_value": mock_response, + } + ) + with patch_request_linkedin as mock_request_linkedin: + result = self.SocialAccountLinkedin._get_statistics( + ads_ids=["123", "456"], + start_date=self.start_datetime, + end_date=self.end_datetime, + ) + self.assertEqual(len(result), 2) + self.assertEqual(result[0]["campaign"], "123") + self.assertEqual(result[1]["campaign"], "456") + mock_request_linkedin.assert_called_once() + + patch_request_linkedin_failed = self.generate_patch( + **{ + "type_object": True, + "model_patch": self.SocialAccountLinkedin, + "method_patch": "_request_linkedin", + "return_value": self.generate_magic_mock( + **{ + "status_code": 403, + } + ), + } + ) + with patch_request_linkedin_failed as mock_request_linkedin_failed: + with self.assertRaises(UserError): + self.SocialAccountLinkedin._get_statistics( + ads_ids=["423", "756"], + start_date=self.start_datetime, + end_date=self.end_datetime, + ) + mock_request_linkedin_failed.assert_called_once() + + def test_get_statistics_ads(self): + ads_ids = [123, 456] + expected_result = [{"mock": "data"}] + patch_get_statistics = self.generate_patch( + **{ + "type_object": True, + "model_patch": self.SocialAccount, + "method_patch": "_get_statistics", + "return_value": expected_result, + } + ) + + with patch_get_statistics as mock_get_statistics: + result = self.SocialAccountLinkedin._get_statistics_ads( + ads_ids, self.start_datetime, self.end_datetime + ) + self.assertEqual(result, expected_result) + mock_get_statistics.assert_called_once() + + def test_load_ads(self): + patch_request_linkedin = self.generate_patch( + **{ + "type_object": True, + "model_patch": self.SocialAccountLinkedin, + "method_patch": "_request_linkedin", + "side_effect": [ + self.generate_magic_mock( + **{ + "status_code": 200, + "json_return_value": { + "elements": [ + { + "id": "urn:li:sponsoredCreative:1", + "content": {"reference": "ref1"}, + "campaign": "urn:li:sponsoredCampaign:123", + "createdAt": 1735689600000, + "intendedStatus": "DRAFT", + "servingHoldReasons": ["UNDER_REVIEW"], + "isTest": True, + } + ] + }, + } + ), + self.generate_magic_mock( + **{ + "status_code": 200, + "json_return_value": { + "results": { + "ref1": { + "id": "ref1", + "commentary": "Test post", + } + } + }, + } + ), + ], + } + ) + patch_get_campaigns = self.generate_patch( + **{ + "model_patch": PATCH_ADVERTISING_ACCOUNT_LINKEDIN.format( + "_get_campaigns" + ), + "return_value": [ + { + "id": 123, + "account": "urn:li:sponsoredAccount:999", + } + ], + } + ) + patch_get_statistics_ads = self.generate_patch( + **{ + "model_patch": PATCH_ADVERTISING_ACCOUNT_LINKEDIN.format( + "_get_statistics_ads" + ), + "return_value": [ + { + "pivotValues": ["urn:li:sponsoredCreative:1"], + "clicks": 10, + } + ], + } + ) + patch_get_default_filter_date = self.generate_patch( + **{ + "model_patch": PATCH_ACCOUNT.format("_get_default_filter_date"), + "method_patch": "_get_default_filter_date", + "side_effect": ( + lambda s, e, time_date=False: ( + self.start_datetime, + self.end_datetime, + ) + if not time_date + else (self.start_datetime, self.end_datetime) + ), + } + ) + patch_ad_account = self.generate_patch( + **{ + "model_patch": PATCH_ADVERTISING_ACCOUNT_LINKEDIN.format( + "_get_linkedin_ad_account_id" + ), + "return_value": "999", + } + ) + with ( + patch_request_linkedin as mock_request_linkedin, + patch_get_campaigns as mock_get_campaigns, + patch_get_statistics_ads as mock_get_statistics_ads, + patch_get_default_filter_date as mock_get_default_filter_date, + patch_ad_account, + ): + result = self.SocialAccountLinkedin._load_ads( + start_date=self.start_datetime, end_date=self.end_datetime + ) + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["id"], "urn:li:sponsoredCreative:1") + self.assertEqual(result[0]["post"]["name"], "Test post") + self.assertEqual(result[0]["campaign"]["id"], 123) + self.assertEqual( + result[0]["advertising_account"], + "LinkedIn Ads", + msg="The card shows the advertising account the ads belong to.", + ) + self.assertEqual(result[0]["statistic"]["clicks"], 10) + self.assertIn("url", result[0]) + self.assertEqual(result[0]["status"], "Draft") + self.assertEqual(result[0]["status_level"], "info") + self.assertEqual(result[0]["status_detail"], "UNDER_REVIEW") + self.assertEqual(mock_request_linkedin.call_count, 2) + mock_get_statistics_ads.assert_called_once() + mock_get_campaigns.assert_called_once() + mock_get_default_filter_date.assert_called_once() + + patch_request_linkedin_failed = self.generate_patch( + **{ + "type_object": True, + "model_patch": self.SocialAccountLinkedin, + "method_patch": "_request_linkedin", + "return_value": self.generate_magic_mock(**{"status_code": 403}), + } + ) + with patch_request_linkedin_failed as mock_request_linkedin_failed: + with self.assertRaises(UserError): + self.SocialAccountLinkedin._load_ads( + start_date=self.start_datetime, end_date=self.end_datetime + ) + mock_request_linkedin_failed.assert_called_once() + + def test_load_ads_without_matching_campaign(self): + patch_request_linkedin = self.generate_patch( + **{ + "type_object": True, + "model_patch": self.SocialAccountLinkedin, + "method_patch": "_request_linkedin", + "return_value": self.generate_magic_mock( + **{ + "status_code": 200, + "json_return_value": { + "elements": [ + { + "id": "urn:li:sponsoredCreative:1", + "campaign": "urn:li:sponsoredCampaign:123", + "createdAt": 1735689600000, + "servingHoldReasons": ["STOPPED"], + "isTest": True, + } + ] + }, + } + ), + } + ) + patch_get_campaigns = patch( + PATCH_ADVERTISING_ACCOUNT_LINKEDIN.format("_get_campaigns"), + autospec=True, + return_value=[], + ) + patch_get_statistics_ads = patch( + PATCH_ADVERTISING_ACCOUNT_LINKEDIN.format("_get_statistics_ads"), + autospec=True, + return_value=[], + ) + patch_get_default_filter_date = self.generate_patch( + **{ + "model_patch": PATCH_ACCOUNT.format("_get_default_filter_date"), + "method_patch": "_get_default_filter_date", + "return_value": (self.start_datetime, self.end_datetime), + } + ) + patch_ad_account = self.generate_patch( + **{ + "model_patch": PATCH_ADVERTISING_ACCOUNT_LINKEDIN.format( + "_get_linkedin_ad_account_id" + ), + "return_value": "999", + } + ) + with ( + patch_request_linkedin as mock_request_linkedin, + patch_get_campaigns as mock_get_campaigns, + patch_get_statistics_ads as mock_get_statistics_ads, + patch_get_default_filter_date as mock_get_default_filter_date, + patch_ad_account, + ): + result = self.SocialAccountLinkedin._load_ads( + start_date=self.start_datetime, end_date=self.end_datetime + ) + mock_request_linkedin.assert_called_once() + mock_get_campaigns.assert_called_once() + mock_get_statistics_ads.assert_called_once() + mock_get_default_filter_date.assert_called_once() + self.assertEqual(len(result), 1) + self.assertEqual( + result[0]["campaign"], + {}, + msg="An ad whose campaign is not returned by LinkedIn is kept.", + ) + self.assertEqual( + result[0]["status_level"], + "secondary", + msg="A status that is not mapped falls back to the neutral badge.", + ) + self.assertEqual( + result[0]["advertising_account"], + "LinkedIn Ads", + msg="The advertising account does not depend on the campaign.", + ) + + @patch(PATCH_ACCOUNT_LINKEDIN.format("_request_linkedin")) + @patch( + PATCH_ADVERTISING_ACCOUNT_LINKEDIN.format("_get_linkedin_advertising_account") + ) + def test_action_import_campaigns(self, mock_advertising, mock_request_linkedin): + advertising = "urn:li:sponsoredAccount:999" + mock_advertising.return_value = advertising + groups_response = MagicMock(status_code=200) + groups_response.json.return_value = { + "elements": [ + { + "id": 45, + "name": "Imported Group", + "account": advertising, + "totalBudget": {"amount": "100", "currencyCode": "USD"}, + }, + ], + "metadata": {}, + } + campaigns_response = MagicMock(status_code=200) + campaigns_response.json.return_value = { + "elements": [ + { + "id": 67, + "name": "Imported Campaign", + "account": advertising, + "campaignGroup": "urn:li:sponsoredCampaignGroup:45", + "format": "SINGLE_VIDEO", + "unitCost": {"amount": "1", "currencyCode": "USD"}, + "dailyBudget": {"amount": "10", "currencyCode": "USD"}, + }, + ], + "paging": {"total": 1}, + } + creatives_response = MagicMock(status_code=200) + creatives_response.json.return_value = { + "elements": [ + { + "id": "urn:li:sponsoredCreative:888", + "content": {"reference": "urn:li:share:5555"}, + "campaign": "urn:li:sponsoredCampaign:67", + "isTest": True, + }, + ], + "metadata": {}, + } + self.SocialPostAccountLinkedin.write({"remote_ref": "urn:li:share:5555"}) + mock_request_linkedin.side_effect = [ + groups_response, + campaigns_response, + creatives_response, + ] + res = self.SocialAccountLinkedin.action_import_campaigns() + self.assertTrue(res["success"]) + self.assertEqual(res["groups"], 1) + self.assertEqual(res["campaigns"], 1) + self.assertEqual(res["ads"], 1) + group = self.SocialCampaignGroup.search( + [("remote_ref", "=", "urn:li:sponsoredCampaignGroup:45")] + ) + self.assertEqual(group.name, "Imported Group") + self.assertEqual(group.total_budget, 100) + self.assertEqual( + mock_request_linkedin.call_args_list[0].kwargs["endpoint"], + "/adAccounts/999/adCampaignGroups", + ) + self.assertEqual( + mock_request_linkedin.call_args_list[1].kwargs["endpoint"], + "/adAccounts/999/adCampaigns", + ) + campaign = self.SocialCampaign.search( + [("remote_ref", "=", "urn:li:sponsoredCampaign:67")] + ) + self.assertEqual(campaign.campaign_group_id, group) + self.assertIn(self.SocialAccountLinkedin, campaign.account_ids) + self.assertEqual(campaign.daily_budget, 10) + self.assertEqual(campaign.linkedin_format, "SINGLE_VIDEO") + self.assertEqual( + self.SocialPostAccountLinkedin.creative_urn, + "urn:li:sponsoredCreative:888", + ) + self.assertEqual(group.campaign_count, 1) + action = group.action_view_campaigns() + self.assertEqual(action["domain"], [("campaign_group_id", "=", group.id)]) + mock_request_linkedin.side_effect = [ + groups_response, + campaigns_response, + creatives_response, + ] + res = self.SocialAccountLinkedin.action_import_campaigns() + self.assertTrue(res["success"]) + self.assertEqual(res["groups"], 0) + self.assertEqual(res["campaigns"], 0) + self.assertEqual(res["ads"], 0) + self.assertFalse(self.SocialPostAccountLinkedin.social_campaign_id) + + def _import_creatives_response(self, reference, campaign_urn): + """Return the three responses of an import with a single creative.""" + advertising = "urn:li:sponsoredAccount:999" + groups_response = MagicMock(status_code=200) + groups_response.json.return_value = { + "elements": [ + { + "id": 45, + "name": "Imported Group", + "account": advertising, + "totalBudget": {"amount": "100", "currencyCode": "USD"}, + } + ], + "paging": {"total": 1}, + } + campaigns_response = MagicMock(status_code=200) + campaigns_response.json.return_value = { + "elements": [ + { + "id": 67, + "name": "Imported Campaign", + "account": advertising, + "campaignGroup": "urn:li:sponsoredCampaignGroup:45", + "format": "STANDARD_UPDATE", + "unitCost": {"amount": "1", "currencyCode": "USD"}, + "dailyBudget": {"amount": "10", "currencyCode": "USD"}, + } + ], + "paging": {"total": 1}, + } + creatives_response = MagicMock(status_code=200) + creatives_response.json.return_value = { + "elements": [ + { + "id": "urn:li:sponsoredCreative:888", + "content": {"reference": reference}, + "campaign": campaign_urn, + "isTest": True, + } + ], + "metadata": {}, + } + return [groups_response, campaigns_response, creatives_response] + + @patch(PATCH_ACCOUNT_LINKEDIN.format("_request_linkedin")) + @patch( + PATCH_ADVERTISING_ACCOUNT_LINKEDIN.format("_get_linkedin_advertising_account") + ) + def test_import_campaigns_links_the_campaign_of_an_imported_post( + self, mock_advertising, mock_request_linkedin + ): + """A publication brought from the wall gets its campaign here. + + It has no parent post, so the campaign can only come from the + creative that promotes it. + """ + mock_advertising.return_value = "urn:li:sponsoredAccount:999" + imported = self.SocialPostAccount.create( + { + "account_id": self.SocialAccountLinkedin.id, + "message": "Imported from the wall", + "remote_ref": "urn:li:share:9999", + "state": "posted", + } + ) + self.assertFalse(imported.post_id) + responses = self._import_creatives_response( + "urn:li:share:9999", "urn:li:sponsoredCampaign:67" + ) + mock_request_linkedin.side_effect = responses + res = self.SocialAccountLinkedin.action_import_campaigns() + self.assertEqual(res["ads"], 1) + self.assertEqual( + imported.social_campaign_id.remote_ref, "urn:li:sponsoredCampaign:67" + ) + self.assertEqual(imported.creative_urn, "urn:li:sponsoredCreative:888") + mock_request_linkedin.side_effect = self._import_creatives_response( + "urn:li:share:9999", "urn:li:sponsoredCampaign:67" + ) + res = self.SocialAccountLinkedin.action_import_campaigns() + self.assertEqual(res["ads"], 0) + self.assertEqual( + imported.social_campaign_id.remote_ref, "urn:li:sponsoredCampaign:67" + ) + + @patch(PATCH_ACCOUNT_LINKEDIN.format("_request_linkedin")) + @patch( + PATCH_ADVERTISING_ACCOUNT_LINKEDIN.format("_get_linkedin_advertising_account") + ) + def test_import_campaigns_ignores_an_unknown_campaign( + self, mock_advertising, mock_request_linkedin + ): + mock_advertising.return_value = "urn:li:sponsoredAccount:999" + imported = self.SocialPostAccount.create( + { + "account_id": self.SocialAccountLinkedin.id, + "message": "Imported from the wall", + "remote_ref": "urn:li:share:8888", + "state": "posted", + } + ) + mock_request_linkedin.side_effect = self._import_creatives_response( + "urn:li:share:8888", "urn:li:sponsoredCampaign:404" + ) + res = self.SocialAccountLinkedin.action_import_campaigns() + self.assertTrue(res["success"]) + self.assertFalse(imported.social_campaign_id) + self.assertEqual(imported.creative_urn, "urn:li:sponsoredCreative:888") + + @patch(PATCH_ACCOUNT_LINKEDIN.format("_request_linkedin")) + @patch( + PATCH_ADVERTISING_ACCOUNT_LINKEDIN.format("_get_linkedin_advertising_account") + ) + def test_campaign_pending_changes_hybrid_import( + self, mock_advertising, mock_request_linkedin + ): + advertising = "urn:li:sponsoredAccount:999" + mock_advertising.return_value = advertising + currency_usd = self.env.ref("base.USD") + group = self.SocialCampaignGroup.create( + { + "name": "Synced Group", + "remote_ref": "urn:li:sponsoredCampaignGroup:70", + "total_budget": 100, + "currency_id": currency_usd.id, + } + ) + campaign = self.SocialCampaign.create( + { + "name": "Synced Campaign", + "campaign_group_id": group.id, + "media_id": self.media_linkedin_data_id.id, + "account_ids": [Command.link(self.SocialAccountLinkedin.id)], + "unit_cost": 1, + "daily_budget": 10, + "remote_ref": "urn:li:sponsoredCampaign:71", + } + ) + self.assertFalse(campaign.linkedin_needs_update) + self.assertFalse(group.linkedin_needs_update) + campaign.write({"unit_cost": 99}) + group.write({"total_budget": 500}) + self.assertTrue(campaign.linkedin_needs_update) + self.assertTrue(group.linkedin_needs_update) + groups_response = MagicMock(status_code=200) + groups_response.json.return_value = { + "elements": [ + { + "id": 70, + "name": "Renamed Group In LinkedIn", + "account": advertising, + "status": "ACTIVE", + "totalBudget": {"amount": "300", "currencyCode": "USD"}, + }, + ], + "paging": {"total": 1}, + } + campaigns_response = MagicMock(status_code=200) + campaigns_response.json.return_value = { + "elements": [ + { + "id": 71, + "name": "Renamed Campaign In LinkedIn", + "account": advertising, + "campaignGroup": "urn:li:sponsoredCampaignGroup:70", + "status": "PAUSED", + "test": True, + "unitCost": {"amount": "5", "currencyCode": "USD"}, + "dailyBudget": {"amount": "50", "currencyCode": "USD"}, + }, + ], + "paging": {"total": 1}, + } + creatives_response = MagicMock(status_code=200) + creatives_response.json.return_value = {"elements": [], "paging": {"total": 0}} + mock_request_linkedin.side_effect = [ + groups_response, + campaigns_response, + creatives_response, + ] + campaign_messages = len(campaign.message_ids) + group_messages = len(group.message_ids) + self.SocialAccountLinkedin.action_import_campaigns() + self.assertEqual(campaign.unit_cost, 99) + self.assertEqual(campaign.name, "Synced Campaign") + self.assertEqual(campaign.stage_id.code, "PAUSED") + self.assertTrue(campaign.linkedin_is_test) + self.assertTrue(campaign.linkedin_needs_update) + self.assertEqual(group.total_budget, 500) + self.assertEqual(group.name, "Synced Group") + self.assertEqual(group.stage_id.code, "ACTIVE") + self.assertTrue(group.linkedin_needs_update) + self.assertEqual(len(campaign.message_ids), campaign_messages + 1) + self.assertEqual(len(group.message_ids), group_messages + 1) + mock_request_linkedin.side_effect = None + mock_request_linkedin.return_value = MagicMock(status_code=204) + campaign.action_update_linkedin() + payload = mock_request_linkedin.call_args.kwargs["json_data"] + self.assertEqual(payload["patch"]["$set"]["unitCost"]["amount"], "99.0") + self.assertEqual( + payload["patch"]["$set"]["campaignGroup"], + "urn:li:sponsoredCampaignGroup:70", + ) + self.assertFalse(campaign.linkedin_needs_update) + group.action_update_linkedin() + payload = mock_request_linkedin.call_args.kwargs["json_data"] + self.assertEqual(payload["patch"]["$set"]["totalBudget"]["amount"], "500.0") + self.assertFalse(group.linkedin_needs_update) + + def test_deleted_on_linkedin_history_message(self): + currency_usd = self.env.ref("base.USD") + group = self.SocialCampaignGroup.create( + { + "name": "History Group", + "remote_ref": "urn:li:sponsoredCampaignGroup:90", + "total_budget": 100, + "currency_id": currency_usd.id, + "stage_id": self.get_stage_linkedin("group", "ACTIVE").id, + } + ) + campaign = self.SocialCampaign.create( + { + "name": "History Campaign", + "campaign_group_id": group.id, + "media_id": self.media_linkedin_data_id.id, + "account_ids": [Command.link(self.SocialAccountLinkedin.id)], + "unit_cost": 1, + "daily_budget": 10, + "remote_ref": "urn:li:sponsoredCampaign:91", + "stage_id": self.get_stage_linkedin("campaign", "ACTIVE").id, + } + ) + group_messages = len(group.message_ids) + campaign_messages = len(campaign.message_ids) + elements_group = [ + { + "id": 90, + "name": "History Group", + "status": "PENDING_DELETION", + "totalBudget": {"amount": "100", "currencyCode": "USD"}, + } + ] + elements_campaign = [ + { + "id": 91, + "name": "History Campaign", + "status": "REMOVED", + "campaignGroup": "urn:li:sponsoredCampaignGroup:90", + "unitCost": {"amount": "1", "currencyCode": "USD"}, + "dailyBudget": {"amount": "10", "currencyCode": "USD"}, + } + ] + self.SocialAccountLinkedin._upsert_linkedin_campaigns( + elements_group, elements_campaign + ) + self.assertEqual(group.stage_id.code, "PENDING_DELETION") + self.assertEqual(campaign.stage_id.code, "REMOVED") + self.assertEqual(len(group.message_ids), group_messages + 1) + self.assertEqual(len(campaign.message_ids), campaign_messages + 1) + self.SocialAccountLinkedin._upsert_linkedin_campaigns( + elements_group, elements_campaign + ) + self.assertEqual(len(group.message_ids), group_messages + 1) + self.assertEqual(len(campaign.message_ids), campaign_messages + 1) + + def test_upsert_linkedin_campaigns_updates_the_archived_records(self): + """An archived record still owns its URN: never create it again.""" + currency_usd = self.env.ref("base.USD") + group = self.SocialCampaignGroup.create( + { + "name": "Archived Group", + "remote_ref": "urn:li:sponsoredCampaignGroup:95", + "total_budget": 100, + "currency_id": currency_usd.id, + } + ) + campaign = self.SocialCampaign.create( + { + "name": "Archived Campaign", + "campaign_group_id": group.id, + "media_id": self.media_linkedin_data_id.id, + "account_ids": [Command.link(self.SocialAccountLinkedin.id)], + "remote_ref": "urn:li:sponsoredCampaign:96", + } + ) + group.write({"active": False}) + campaign.write({"active": False}) + counts = self.SocialAccountLinkedin._upsert_linkedin_campaigns( + [ + { + "id": 95, + "name": "Archived Group Renamed", + "status": "ACTIVE", + "totalBudget": {"amount": "150", "currencyCode": "USD"}, + } + ], + [ + { + "id": 96, + "name": "Archived Campaign Renamed", + "status": "ACTIVE", + "campaignGroup": "urn:li:sponsoredCampaignGroup:95", + "unitCost": {"amount": "2", "currencyCode": "USD"}, + "dailyBudget": {"amount": "20", "currencyCode": "USD"}, + } + ], + ) + self.assertEqual(counts, {"groups": 0, "campaigns": 0}) + self.assertEqual(group.name, "Archived Group Renamed") + self.assertEqual(group.total_budget, 150) + self.assertEqual(campaign.name, "Archived Campaign Renamed") + self.assertEqual(campaign.daily_budget, 20) + self.assertFalse(group.active) + self.assertFalse(campaign.active) + + @patch(PATCH_ADVERTISING_ACCOUNT_LINKEDIN.format("_load_ads")) + @patch(PATCH_ADVERTISING_ACCOUNT_LINKEDIN.format("search")) + def test_load_ads_accounts(self, mock_search, mock_load_ads): + ads_linkedin = [ + { + "media_type": "linkedin", + "statistic": {"clicks": 10}, + } + ] + mock_load_ads.return_value = ads_linkedin + mock_search.return_value = self.SocialAccountLinkedin + res = self.SocialAccount._load_ads_accounts() + self.assertEqual(mock_search.call_count, 1) + self.assertEqual(res["ads"], ads_linkedin) + + @patch(PATCH_ADVERTISING_ACCOUNT_LINKEDIN.format("_load_ads")) + @patch(PATCH_ADVERTISING_ACCOUNT_LINKEDIN.format("search")) + def test_load_ads_accounts_without_accounts(self, mock_search, mock_load_ads): + """With no LinkedIn account the payload keeps ``ads`` as a list.""" + mock_search.return_value = self.SocialAccount.browse() + res = self.SocialAccount._load_ads_accounts() + self.assertEqual(res["ads"], []) + mock_load_ads.assert_not_called() + + @mute_logger("odoo.addons.social_media_advertising_linkedin.models.social_account") + @patch(PATCH_ADVERTISING_ACCOUNT_LINKEDIN.format("_load_ads")) + @patch(PATCH_ADVERTISING_ACCOUNT_LINKEDIN.format("search")) + def test_load_ads_accounts_skips_the_failing_account( + self, mock_search, mock_load_ads + ): + """A revoked token must not break the whole ads dashboard.""" + mock_load_ads.side_effect = UserError("Token revoked") + mock_search.return_value = self.SocialAccountLinkedin + res = self.SocialAccount._load_ads_accounts() + self.assertEqual(res["ads"], []) + + def test_advertising_media_types_contains_linkedin(self): + self.assertIn("linkedin", self.SocialAccount._advertising_media_types()) + self.assertTrue(self.SocialAccountLinkedin.can_sync_advertising_accounts) + + def test_fetch_advertising_accounts_dedupes_the_roles(self): + """``adAccountUsers`` answers one row per role of the member.""" + users_response = self.generate_magic_mock( + **{ + "status_code": 200, + "json_return_value": { + "paging": {"total": 2}, + "elements": [ + {"account": "urn:li:sponsoredAccount:1"}, + {"account": "urn:li:sponsoredAccount:1"}, + ], + }, + } + ) + advertising_account = self.generate_magic_mock( + **{ + "status_code": 200, + "json_return_value": {"id": 1, "test": True, "name": "One"}, + } + ) + patch_request_linkedin = self.generate_patch( + **{ + "type_object": True, + "model_patch": self.SocialAccountLinkedin, + "method_patch": "_request_linkedin", + "side_effect": [users_response, advertising_account], + } + ) + with patch_request_linkedin as mock_request_linkedin: + values = self.SocialAccountLinkedin._fetch_advertising_accounts() + self.assertEqual(len(values), 1) + self.assertEqual(mock_request_linkedin.call_count, 2) + + def test_prepare_advertising_account_without_a_known_currency(self): + values = self.SocialAccountLinkedin._prepare_linkedin_advertising_account( + "urn:li:sponsoredAccount:1", {"id": 1, "currency": "XXX"} + ) + self.assertFalse(values["currency_id"]) + self.assertEqual(values["name"], "1") + self.assertEqual(values["environment"], "production") + + def test_web_url_points_to_the_campaign_manager(self): + self.assertEqual( + self.AdvertisingAccountLinkedin.web_url, + "https://www.linkedin.com/campaignmanager/accounts/999/", + ) + + def test_action_sync_advertising_accounts_notify(self): + users_response = self.generate_magic_mock( + **{ + "status_code": 200, + "json_return_value": { + "paging": {"total": 1}, + "elements": [{"account": "urn:li:sponsoredAccount:999"}], + }, + } + ) + advertising_account = self.generate_magic_mock( + **{ + "status_code": 200, + "json_return_value": {"id": 999, "test": True, "name": "Ads"}, + } + ) + patch_request_linkedin = self.generate_patch( + **{ + "type_object": True, + "model_patch": self.SocialAccountLinkedin, + "method_patch": "_request_linkedin", + "side_effect": [users_response, advertising_account], + } + ) + with patch_request_linkedin: + res = self.SocialAccountLinkedin.action_sync_advertising_accounts_notify() + self.assertEqual(res["params"]["type"], "success") + self.assertEqual(self.AdvertisingAccountLinkedin.name, "Ads") + + def test_action_sync_advertising_accounts_notify_error(self): + failed_response = self.generate_magic_mock( + **{"status_code": 403, "json_return_value": {"message": "Unauthorized"}} + ) + patch_request_linkedin = self.generate_patch( + **{ + "type_object": True, + "model_patch": self.SocialAccountLinkedin, + "method_patch": "_request_linkedin", + "return_value": failed_response, + } + ) + with patch_request_linkedin: + res = self.SocialAccountLinkedin.action_sync_advertising_accounts_notify() + self.assertEqual(res["params"]["type"], "danger") + self.assertIn( + "Error get advertising account in Linkedin", res["params"]["message"] + ) diff --git a/social_media_advertising_linkedin/tests/test_social_campaign_group_linkedin.py b/social_media_advertising_linkedin/tests/test_social_campaign_group_linkedin.py new file mode 100644 index 0000000000..765fa2c906 --- /dev/null +++ b/social_media_advertising_linkedin/tests/test_social_campaign_group_linkedin.py @@ -0,0 +1,145 @@ +# Copyright 2026 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from unittest.mock import MagicMock, patch + +from odoo.exceptions import UserError +from odoo.fields import Command + +from .test_common_advertising_linkedin import ( + PATCH_ADVERTISING_ACCOUNT_LINKEDIN, + PATCH_ADVERTISING_CAMPAIGN_GROUP_LINKEDIN, + TestSocialCommonAdvertisingLinkedin, +) + +PATCH_ACCOUNT_LINKEDIN = ( + "odoo.addons.social_media_linkedin.models.social_account.SocialAccount.{}" +) + + +class TestSocialCampaignGroupLinkedin(TestSocialCommonAdvertisingLinkedin): + def test_get_linkedin_account(self): + """The account is taken from the campaigns before the fallback.""" + self.assertEqual( + self.SocialCampaignGroupLinkedin._get_linkedin_account(), + self.SocialAccountLinkedin, + ) + + def test_get_linkedin_account_without_campaigns(self): + """Without campaigns the fallback only works with a single account.""" + group = self.SocialCampaignGroup.create({"name": "Standalone Group"}) + accounts = self.SocialAccount.search([("media_type", "=", "linkedin")]) + self.assertGreater(len(accounts), 1) + with self.assertRaises( + UserError, msg="Guessing the advertiser is never acceptable." + ): + group._get_linkedin_account() + (accounts - self.SocialAccountLinkedin).write({"active": False}) + self.assertEqual(group._get_linkedin_account(), self.SocialAccountLinkedin) + + def test_get_linkedin_account_with_several_campaign_accounts(self): + self.SocialCampaign.create( + { + "name": "Campaign of another advertiser", + "campaign_group_id": self.SocialCampaignGroupLinkedin.id, + "media_id": self.media_linkedin_data_id.id, + "account_ids": [Command.link(self.SocialAccountLinkedinData.id)], + } + ) + with self.assertRaises(UserError): + self.SocialCampaignGroupLinkedin._get_linkedin_account() + + @patch(PATCH_ACCOUNT_LINKEDIN.format("_request_linkedin")) + @patch( + PATCH_ADVERTISING_ACCOUNT_LINKEDIN.format("_get_linkedin_advertising_account") + ) + def test_group_publish_linkedin(self, mock_advertising, mock_request_linkedin): + mock_advertising.return_value = "urn:li:sponsoredAccount:999" + group = self.SocialCampaignGroup.create( + { + "name": "Standalone Group", + "total_budget": 100, + "currency_id": self.env.ref("base.USD").id, + } + ) + self.SocialCampaign.create( + { + "name": "Campaign of the standalone group", + "campaign_group_id": group.id, + "media_id": self.media_linkedin_data_id.id, + "account_ids": [Command.link(self.SocialAccountLinkedin.id)], + } + ) + mock_request_linkedin.return_value = MagicMock( + status_code=201, + headers={"Location": "/adAccounts/999/adCampaignGroups/555"}, + ) + group.action_publish_linkedin() + self.assertEqual(group.remote_ref, "urn:li:sponsoredCampaignGroup:555") + self.assertEqual(group.stage_id.code, "DRAFT") + self.assertFalse(group.linkedin_needs_update) + payload = mock_request_linkedin.call_args.kwargs["json_data"] + self.assertEqual(payload["status"], "DRAFT") + self.assertEqual(payload["totalBudget"]["amount"], "100.0") + with self.assertRaises(UserError): + group.action_publish_linkedin() + empty_group = self.SocialCampaignGroup.create({"name": "Empty Group"}) + with self.assertRaises(UserError): + empty_group.action_publish_linkedin() + + def test_group_archive_linkedin(self): + group = self.SocialCampaignGroupLinkedin + messages = len(group.message_ids) + with self._mock_linkedin( + MagicMock(status_code=204), self.SocialAccountLinkedin + ) as mock_request: + group.action_archive_linkedin() + self.assertEqual(group.stage_id.code, "ARCHIVED") + self.assertTrue(group.linkedin_locked) + self.assertFalse(group.linkedin_needs_update) + self.assertEqual(len(group.message_ids), messages + 1) + self.assertEqual( + mock_request.call_args.kwargs["json_data"], + {"patch": {"$set": {"status": "ARCHIVED"}}}, + ) + self.assertEqual( + mock_request.call_args.kwargs["endpoint"], + "/adAccounts/999/adCampaignGroups/456", + ) + with self.assertRaises( + UserError, msg="An archived campaign group is read only on LinkedIn." + ): + group.action_archive_linkedin() + + def test_group_archive_linkedin_errors(self): + group = self.SocialCampaignGroup.create( + { + "name": "Group Without Urn", + "total_budget": 100, + "currency_id": self.env.ref("base.USD").id, + } + ) + self.SocialCampaign.create( + { + "name": "Campaign of the group", + "campaign_group_id": group.id, + "media_id": self.media_linkedin_data_id.id, + "account_ids": [Command.link(self.SocialAccountLinkedin.id)], + } + ) + with self.assertRaises(UserError): + group.action_archive_linkedin() + group.write({"remote_ref": "urn:li:sponsoredCampaignGroup:457"}) + with patch( + PATCH_ADVERTISING_CAMPAIGN_GROUP_LINKEDIN.format("_get_linkedin_account"), + autospec=True, + return_value=self.env["social.account"], + ): + with self.assertRaises(UserError): + group.action_archive_linkedin() + error_response = MagicMock(status_code=400) + error_response.json.return_value = {"message": "Cannot archive"} + with self._mock_linkedin(error_response, self.SocialAccountLinkedin): + with self.assertRaises(UserError): + group.action_archive_linkedin() + self.assertNotEqual(group.stage_id.code, "ARCHIVED") diff --git a/social_media_advertising_linkedin/tests/test_social_campaign_linkedin.py b/social_media_advertising_linkedin/tests/test_social_campaign_linkedin.py new file mode 100644 index 0000000000..ec287fe750 --- /dev/null +++ b/social_media_advertising_linkedin/tests/test_social_campaign_linkedin.py @@ -0,0 +1,482 @@ +# Copyright 2026 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from unittest.mock import MagicMock, patch + +from odoo.exceptions import UserError, ValidationError +from odoo.fields import Command + +from odoo.addons.social_media_base.tests.test_social_common import ( + PATCH_SOCIAL_BASE_UTILS, +) + +from .test_common_advertising_linkedin import ( + PATCH_ADVERTISING_ACCOUNT_LINKEDIN, + TestSocialCommonAdvertisingLinkedin, +) + +PATCH_ACCOUNT_LINKEDIN = ( + "odoo.addons.social_media_linkedin.models.social_account.SocialAccount.{}" +) +ADVERTISING_ACCOUNT_URN = "urn:li:sponsoredAccount:999" + + +class TestSocialCampaignLinkedin(TestSocialCommonAdvertisingLinkedin): + def test_compute_media_id(self): + campaign = self.SocialCampaign.create( + { + "name": "Test Campaign", + "account_ids": [Command.link(self.SocialAccountLinkedin.id)], + "campaign_group_id": self.SocialCampaignGroupLinkedin.id, + } + ) + self.assertIn(self.SocialAccountLinkedin.media_id, campaign.allow_media_ids) + + def test_check_linkedin_single_account(self): + """A LinkedIn campaign refuses a second LinkedIn account.""" + second_account = self.SocialAccountLinkedin.copy( + {"name": "Second LinkedIn account", "remote_ref": "urn:li:organization:2"} + ) + with self.assertRaises(ValidationError): + self.SocialCampaignLinkedin.account_ids = [Command.link(second_account.id)] + + def test_validate_publish_linkedin_video_objective(self): + """LinkedIn requires an objective to create a video campaign.""" + campaign = self.SocialCampaignLinkedin + campaign.write( + { + "linkedin_format": "SINGLE_VIDEO", + "linkedin_objective": False, + "unit_cost": 2, + "daily_budget": 20, + } + ) + with self.assertRaises(UserError) as context: + campaign._validate_publish_linkedin() + self.assertIn("requires an objective", str(context.exception)) + + campaign.linkedin_objective = "VIDEO_VIEW" + campaign._validate_publish_linkedin() + + def test_linkedin_create_campaign_sends_format(self): + """The ad format travels to LinkedIn, which fixes it on creation.""" + campaign = self.SocialCampaignLinkedin + campaign.linkedin_format = "SINGLE_VIDEO" + campaign.linkedin_objective = "VIDEO_VIEW" + response = MagicMock(status_code=201) + response.headers = {"Location": "/adAccounts/999/adCampaigns/321"} + patch_request_linkedin = self.get_patch_exceptions_linkedin(response) + with patch_request_linkedin as mock_request_linkedin: + res = campaign._linkedin_create_campaign( + self.SocialAccountLinkedin, + "urn:li:sponsoredAccount:999", + "urn:li:sponsoredCampaignGroup:456", + ) + self.assertEqual(res, "urn:li:sponsoredCampaign:321") + self.assertEqual(campaign.stage_id.code, "DRAFT") + json_data = mock_request_linkedin.call_args.kwargs["json_data"] + self.assertEqual(json_data["format"], "SINGLE_VIDEO") + self.assertEqual(json_data["objectiveType"], "VIDEO_VIEW") + + def test_check_daily_budget(self): + with self.assertRaises(ValidationError): + self.SocialCampaignLinkedin.daily_budget = 5000 + self.SocialCampaignLinkedin2.daily_budget = 5001 + + def test_check_daily_budget_without_a_total_budget(self): + """A group without total budget sets no limit, as on LinkedIn.""" + self.SocialCampaignGroupLinkedin.with_context( + skip_linkedin_needs_update=True + ).total_budget = 0 + self.SocialCampaignLinkedin.daily_budget = 5000 + self.SocialCampaignLinkedin2.daily_budget = 5001 + self.assertEqual(self.SocialCampaignLinkedin2.daily_budget, 5001) + + def test_check_daily_budget_skipped_on_import(self): + """The import is never stopped by the local budget rule.""" + self.SocialCampaignLinkedin.with_context(skip_linkedin_budget_check=True).write( + {"daily_budget": 5000} + ) + self.SocialCampaignLinkedin2.with_context( + skip_linkedin_budget_check=True + ).write({"daily_budget": 5001}) + self.assertEqual(self.SocialCampaignLinkedin2.daily_budget, 5001) + + def test_check_daily_budget_lowering_the_total_budget(self): + """The rule is watched from the group side too.""" + self.SocialCampaignLinkedin.daily_budget = 6000 + with self.assertRaises(ValidationError): + self.SocialCampaignGroupLinkedin.with_context( + skip_linkedin_needs_update=True + ).total_budget = 5000 + + def test_check_daily_budget_moving_the_campaign_to_another_group(self): + small_group = self.SocialCampaignGroup.create( + { + "name": "Small Group", + "total_budget": 50, + "currency_id": self.env.ref("base.USD").id, + } + ) + self.SocialCampaignLinkedin.daily_budget = 100 + with self.assertRaises(ValidationError): + self.SocialCampaignLinkedin.with_context( + skip_linkedin_needs_update=True + ).campaign_group_id = small_group + + def test_linkedin_lock_covers_the_media(self): + """Taking the campaign out of LinkedIn used to escape the lock.""" + campaign = self.SocialCampaignLinkedin + campaign.with_context( + skip_linkedin_needs_update=True + ).stage_id = self.get_stage_linkedin("campaign", "ARCHIVED") + self.assertTrue(campaign.linkedin_locked) + with self.assertRaises(UserError): + campaign.media_id = False + + def test_action_publish_linkedin_validation(self): + group = self.SocialCampaignGroup.create({"name": "Test Group"}) + campaign = self.SocialCampaign.create( + { + "name": "Test Campaign", + "campaign_group_id": group.id, + "media_id": self.media_linkedin_data_id.id, + "account_ids": [Command.link(self.SocialAccountLinkedin.id)], + } + ) + with self.assertRaises(UserError) as context: + campaign.action_publish_linkedin() + self.assertIn("total budget must be positive", str(context.exception)) + self.assertIn("unit cost must be positive", str(context.exception)) + self.assertIn("daily budget must be positive", str(context.exception)) + + @patch(PATCH_ACCOUNT_LINKEDIN.format("_request_linkedin")) + @patch( + PATCH_ADVERTISING_ACCOUNT_LINKEDIN.format("_get_linkedin_advertising_account") + ) + def test_action_publish_linkedin( + self, mock_advertising_account, mock_request_linkedin + ): + currency = self.env.ref("base.USD") + group = self.SocialCampaignGroup.create( + {"name": "Test Group", "total_budget": 100, "currency_id": currency.id} + ) + campaign = self.SocialCampaign.create( + { + "name": "Test Campaign", + "campaign_group_id": group.id, + "media_id": self.media_linkedin_data_id.id, + "account_ids": [Command.link(self.SocialAccountLinkedin.id)], + "unit_cost": 1, + "daily_budget": 10, + } + ) + mock_advertising_account.return_value = "urn:li:sponsoredAccount:999" + mock_request_linkedin.side_effect = [ + MagicMock( + status_code=201, + headers={"Location": "/adAccounts/999/adCampaignGroups/45"}, + ), + MagicMock( + status_code=201, headers={"Location": "/adAccounts/999/adCampaigns/67"} + ), + ] + campaign.action_publish_linkedin() + self.assertEqual( + campaign.campaign_group_id.remote_ref, + "urn:li:sponsoredCampaignGroup:45", + ) + self.assertEqual(campaign.remote_ref, "urn:li:sponsoredCampaign:67") + self.assertEqual(campaign.stage_id.code, "DRAFT") + self.assertEqual(campaign.campaign_group_id.stage_id.code, "DRAFT") + for call in mock_request_linkedin.call_args_list: + self.assertEqual(call.kwargs["json_data"]["status"], "DRAFT") + + def test_campaign_locked_linkedin_statuses(self): + currency_usd = self.env.ref("base.USD") + group = self.SocialCampaignGroup.create( + { + "name": "Locked Group", + "remote_ref": "urn:li:sponsoredCampaignGroup:80", + "total_budget": 100, + "currency_id": currency_usd.id, + } + ) + campaign = self.SocialCampaign.create( + { + "name": "Locked Campaign", + "campaign_group_id": group.id, + "media_id": self.media_linkedin_data_id.id, + "account_ids": [Command.link(self.SocialAccountLinkedin.id)], + "unit_cost": 1, + "daily_budget": 10, + "remote_ref": "urn:li:sponsoredCampaign:81", + } + ) + campaign.stage_id = self.get_stage_linkedin("campaign", "CANCELED") + group.stage_id = self.get_stage_linkedin("group", "PENDING_DELETION") + with self.assertRaises(UserError): + campaign.write({"unit_cost": 5}) + with self.assertRaises(UserError): + campaign.action_update_linkedin() + with self.assertRaises(UserError): + group.write({"total_budget": 200}) + with self.assertRaises(UserError): + group.action_update_linkedin() + campaign.with_context(skip_linkedin_needs_update=True).write({"unit_cost": 5}) + group.with_context(skip_linkedin_needs_update=True).write({"total_budget": 200}) + self.assertEqual(campaign.unit_cost, 5) + self.assertEqual(group.total_budget, 200) + campaign.stage_id = self.get_stage_linkedin("campaign", "PAUSED") + campaign.write({"unit_cost": 7}) + self.assertTrue(campaign.linkedin_needs_update) + + def test_campaign_archive_linkedin_draft_group(self): + """LinkedIn refuses to archive a campaign of a draft group.""" + campaign = self.SocialCampaignLinkedin + campaign.campaign_group_id.stage_id = self.get_stage_linkedin("group", "DRAFT") + with self.get_patch_exceptions_linkedin() as mock_request: + with self.assertRaises(UserError) as error: + campaign.action_archive_linkedin() + self.assertIn("still in draft status", str(error.exception)) + mock_request.assert_not_called() + + def test_campaign_archive_linkedin_refreshes_the_schedule(self): + """A draft campaign travels with a new schedule LinkedIn accepts. + + Its stored start date is the one given on creation, which LinkedIn + rejects as soon as it belongs to the past. + """ + campaign = self.SocialCampaignLinkedin + campaign.stage_id = self.get_stage_linkedin("campaign", "DRAFT") + with self._mock_linkedin( + MagicMock(status_code=204), self.SocialAccountLinkedin + ) as mock_request: + campaign.action_archive_linkedin() + values = mock_request.call_args.kwargs["json_data"]["patch"]["$set"] + self.assertEqual(values["status"], "ARCHIVED") + self.assertGreater(values["runSchedule"]["start"], 0) + self.assertGreater(values["runSchedule"]["end"], values["runSchedule"]["start"]) + self.assertEqual(campaign.stage_id.code, "ARCHIVED") + + def test_campaign_archive_linkedin(self): + campaign = self.SocialCampaignLinkedin + messages = len(campaign.message_ids) + with self._mock_linkedin( + MagicMock(status_code=204), self.SocialAccountLinkedin + ) as mock_request: + campaign.action_archive_linkedin() + self.assertEqual(campaign.stage_id.code, "ARCHIVED") + self.assertTrue(campaign.linkedin_locked) + self.assertFalse(campaign.linkedin_needs_update) + self.assertEqual(len(campaign.message_ids), messages + 1) + self.assertEqual( + mock_request.call_args.kwargs["json_data"], + {"patch": {"$set": {"status": "ARCHIVED"}}}, + ) + self.assertEqual( + mock_request.call_args.kwargs["endpoint"], "/adAccounts/999/adCampaigns/001" + ) + with self.assertRaises( + UserError, msg="An archived campaign is read only on LinkedIn." + ): + campaign.action_archive_linkedin() + + def test_campaign_archive_linkedin_errors(self): + campaign = self.SocialCampaign.create( + { + "name": "Campaign Without Urn", + "campaign_group_id": self.SocialCampaignGroupLinkedin.id, + "media_id": self.media_linkedin_data_id.id, + "account_ids": [Command.link(self.SocialAccountLinkedin.id)], + } + ) + with self.assertRaises(UserError): + campaign.action_archive_linkedin() + campaign.write({"remote_ref": "urn:li:sponsoredCampaign:003"}) + campaign.account_ids = [Command.clear()] + with self.assertRaises(UserError): + campaign.action_archive_linkedin() + campaign.account_ids = [Command.link(self.SocialAccountLinkedin.id)] + error_response = MagicMock(status_code=400) + error_response.json.return_value = {"message": "Cannot archive"} + with self._mock_linkedin(error_response, self.SocialAccountLinkedin): + with self.assertRaises(UserError): + campaign.action_archive_linkedin() + self.assertNotEqual(campaign.stage_id.code, "ARCHIVED") + + def test_archived_stage_locks_writes(self): + """A campaign archived on LinkedIn refuses local sync changes.""" + campaign = self.SocialCampaignLinkedin + campaign.stage_id = self.get_stage_linkedin("campaign", "ARCHIVED") + self.assertTrue(campaign.linkedin_locked) + with self.assertRaises(UserError): + campaign.write({"name": "Renamed"}) + + def test_publish_campaign_group_without_advertising_account(self): + """Without an advertising account nothing is sent to LinkedIn.""" + with self.get_patch_exceptions_linkedin() as mock_request: + self.assertFalse( + self.SocialCampaignLinkedin._linkedin_publish_campaign_group( + self.SocialAccountLinkedin, False + ) + ) + mock_request.assert_not_called() + + def test_publish_campaign_group_already_created(self): + """An existing group is only verified, never created again.""" + with self.get_patch_exceptions_linkedin( + MagicMock(status_code=200) + ) as mock_request: + urn = self.SocialCampaignLinkedin._linkedin_publish_campaign_group( + self.SocialAccountLinkedin, ADVERTISING_ACCOUNT_URN + ) + self.assertEqual(urn, self.SocialCampaignGroupLinkedin.remote_ref) + mock_request.assert_called_once() + + def test_publish_campaign_group_creates_the_missing_one(self): + """A URN unknown to LinkedIn is replaced by a newly created group.""" + patch_request = self.get_patch_exceptions_linkedin( + side_effect=[ + MagicMock(status_code=404), + MagicMock( + status_code=201, + headers={"Location": "/adAccounts/999/adCampaignGroups/456"}, + ), + ] + ) + with patch( + PATCH_SOCIAL_BASE_UTILS.format("_generate_timestamps"), + autospec=True, + return_value=(111111, 222222), + ), patch_request as mock_request: + urn = self.SocialCampaignLinkedin._linkedin_publish_campaign_group( + self.SocialAccountLinkedin, ADVERTISING_ACCOUNT_URN + ) + self.assertEqual(urn, "urn:li:sponsoredCampaignGroup:456") + self.assertEqual( + self.SocialCampaignGroupLinkedin.remote_ref, + "urn:li:sponsoredCampaignGroup:456", + ) + self.assertEqual(mock_request.call_count, 2) + + def test_publish_campaign_group_creation_error(self): + """The creation error of the group is reported to the user.""" + patch_request = self.get_patch_exceptions_linkedin( + side_effect=[ + MagicMock(status_code=404), + MagicMock(status_code=400, headers={"error": "Invalid request"}), + ] + ) + with patch_request as mock_request: + with self.assertRaises(UserError) as error: + self.SocialCampaignLinkedin._linkedin_publish_campaign_group( + self.SocialAccountLinkedin, ADVERTISING_ACCOUNT_URN + ) + self.assertIn("Error creating group campaign in Linkedin", str(error.exception)) + self.assertEqual(mock_request.call_count, 2) + + def test_validate_publish_linkedin_political_intent(self): + """LinkedIn requires the political advertising declaration.""" + campaign = self.SocialCampaignLinkedin + campaign.write( + { + "linkedin_political_intent": False, + "unit_cost": 2, + "daily_budget": 20, + } + ) + with self.assertRaises(UserError) as context: + campaign._validate_publish_linkedin() + self.assertIn("political advertising", str(context.exception)) + + campaign.linkedin_political_intent = "NOT_POLITICAL" + campaign._validate_publish_linkedin() + + def test_linkedin_create_campaign_sends_political_intent(self): + """The declaration travels to LinkedIn, which rejects it otherwise.""" + campaign = self.SocialCampaignLinkedin + self.assertEqual(campaign.linkedin_political_intent, "NOT_POLITICAL") + response = MagicMock(status_code=201) + response.headers = {"Location": "/adAccounts/999/adCampaigns/321"} + with self.get_patch_exceptions_linkedin(response) as mock_request_linkedin: + campaign._linkedin_create_campaign( + self.SocialAccountLinkedin, + ADVERTISING_ACCOUNT_URN, + "urn:li:sponsoredCampaignGroup:456", + ) + json_data = mock_request_linkedin.call_args.kwargs["json_data"] + self.assertEqual(json_data["politicalIntent"], "NOT_POLITICAL") + + def test_action_update_linkedin_sends_political_intent(self): + """A local change of the declaration is pushed to LinkedIn.""" + campaign = self.SocialCampaignLinkedin + campaign.linkedin_political_intent = "POLITICAL" + self.assertTrue(campaign.linkedin_needs_update) + with self._mock_linkedin( + MagicMock(status_code=204), self.SocialAccountLinkedin + ) as mock_request: + campaign.action_update_linkedin() + values = mock_request.call_args.kwargs["json_data"]["patch"]["$set"] + self.assertEqual(values["politicalIntent"], "POLITICAL") + self.assertFalse(campaign.linkedin_needs_update) + + @patch(PATCH_ACCOUNT_LINKEDIN.format("_request_linkedin")) + @patch( + PATCH_ADVERTISING_ACCOUNT_LINKEDIN.format("_get_linkedin_advertising_account") + ) + def test_action_publish_linkedin_keeps_the_group_reference( + self, mock_advertising_account, mock_request_linkedin + ): + """A campaign failure must not lose the group created on LinkedIn. + + LinkedIn cannot delete a campaign group, so its reference has to + survive the failure or the next import would duplicate it. + """ + currency = self.env.ref("base.USD") + group = self.SocialCampaignGroup.create( + {"name": "Test Group", "total_budget": 100, "currency_id": currency.id} + ) + campaign = self.SocialCampaign.create( + { + "name": "Test Campaign", + "campaign_group_id": group.id, + "media_id": self.media_linkedin_data_id.id, + "account_ids": [Command.link(self.SocialAccountLinkedin.id)], + "unit_cost": 1, + "daily_budget": 10, + } + ) + mock_advertising_account.return_value = ADVERTISING_ACCOUNT_URN + error_response = MagicMock(status_code=400) + error_response.json.return_value = {"message": "Invalid campaign"} + mock_request_linkedin.side_effect = [ + MagicMock( + status_code=201, + headers={"Location": "/adAccounts/999/adCampaignGroups/45"}, + ), + error_response, + ] + messages = len(campaign.message_ids) + action = campaign.action_publish_linkedin() + self.assertEqual(action["params"]["type"], "danger") + self.assertIn( + "Error creating campaign in Linkedin", action["params"]["message"] + ) + self.assertEqual(len(campaign.message_ids), messages + 1) + self.assertEqual(group.remote_ref, "urn:li:sponsoredCampaignGroup:45") + self.assertEqual(group.stage_id.code, "DRAFT") + self.assertFalse(campaign.remote_ref) + + def test_publish_campaign_group_verification_error(self): + """An unexpected answer while verifying the group is reported too.""" + patch_request = self.get_patch_exceptions_linkedin( + side_effect=[MagicMock(status_code=400)] + ) + with patch_request as mock_request: + with self.assertRaises(UserError) as error: + self.SocialCampaignLinkedin._linkedin_publish_campaign_group( + self.SocialAccountLinkedin, ADVERTISING_ACCOUNT_URN + ) + self.assertIn("Error creating group campaign in Linkedin", str(error.exception)) + mock_request.assert_called_once() diff --git a/social_media_advertising_linkedin/tests/test_social_post_advertising_linkedin.py b/social_media_advertising_linkedin/tests/test_social_post_advertising_linkedin.py new file mode 100644 index 0000000000..033db7a1f7 --- /dev/null +++ b/social_media_advertising_linkedin/tests/test_social_post_advertising_linkedin.py @@ -0,0 +1,319 @@ +# Copyright 2026 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +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_linkedin.tests.test_common_linkedin import ( + PATCH_ACCOUNT_LINKEDIN, +) + +from .test_common_advertising_linkedin import ( + PATCH_ADVERTISING_ACCOUNT_LINKEDIN, + TestSocialCommonAdvertisingLinkedin, +) + +LOGGER_POST_ACCOUNT_ADVERTISING_LINKEDIN = ( + "odoo.addons.social_media_advertising_linkedin.models.social_post_account" +) +LOGGER_POST_ACCOUNT_BASE = "odoo.addons.social_media_base.models.social_post_account" + + +class TestSocialPostAdvertisingLinkedin(TestSocialCommonAdvertisingLinkedin): + def test_linkedin_advertising_accounts_success(self): + """The publication works against the advertising account in use.""" + self.assertEqual( + self.SocialPostAccountLinkedin._linkedin_advertising_accounts(), + "urn:li:sponsoredAccount:999", + ) + + def test_linkedin_advertising_accounts_without_one_in_use(self): + """Nothing is resolved on the fly, so publishing an ad is refused.""" + self.AdvertisingAccountLinkedin.write({"is_current": False}) + self.assertFalse( + self.SocialPostAccountLinkedin._linkedin_advertising_accounts() + ) + with self.assertRaises(UserError) as error: + self.SocialAccountLinkedin._require_linkedin_ad_account_id() + self.assertIn("No LinkedIn advertising account is in use", str(error.exception)) + + def test_check_linkedin_campaign_format(self): + """The post and its campaign must share the LinkedIn ad format.""" + post_account = self.SocialPostAccountCampaignLinkedin + campaign = post_account.social_campaign_id + campaign.linkedin_format = "STANDARD_UPDATE" + post_account._check_linkedin_campaign_format() + + post_account.post_id.video_ids = [ + Command.set([self.create_attachment("test_video.mp4").id]) + ] + with self.assertRaises(UserError) as context: + post_account._check_linkedin_campaign_format() + self.assertIn("'Single video' format", str(context.exception)) + + campaign.linkedin_format = "SINGLE_VIDEO" + post_account._check_linkedin_campaign_format() + post_account.post_id.video_ids = [Command.clear()] + with self.assertRaises(UserError) as context: + post_account._check_linkedin_campaign_format() + self.assertIn("only accepts posts containing a video", str(context.exception)) + + def test_check_linkedin_campaign_format_multi_image(self): + """LinkedIn does not sponsor a post carrying several images.""" + post_account = self.SocialPostAccountCampaignLinkedin + post_account.social_campaign_id.linkedin_format = "STANDARD_UPDATE" + post_account.post_id.image_ids = [ + Command.set( + [ + self.create_attachment("image_1.png").id, + self.create_attachment("image_2.png").id, + ] + ) + ] + with self.assertRaises(UserError) as context: + post_account._check_linkedin_campaign_format() + self.assertIn("several images", str(context.exception)) + + post_account.post_id.image_ids = [ + Command.set([self.create_attachment("image_1.png").id]) + ] + post_account._check_linkedin_campaign_format() + + @patch(PATCH_ADVERTISING_ACCOUNT_LINKEDIN.format("_get_linkedin_ad_account_id")) + @patch(PATCH_ACCOUNT_LINKEDIN.format("_request_linkedin")) + def test_action_campaign_post(self, mock_request_linkedin, mock_get_ad_account_id): + creative_urn = "urn:li:sponsoredCreative:123456" + mock_get_ad_account_id.return_value = "999" + campaign = self.SocialPostCampaignLinkedin.social_campaign_id + campaign.remote_ref = "urn:li:sponsoredCampaign:001" + mock_request_linkedin.side_effect = [ + MagicMock(status_code=201, headers={"x-restli-id": creative_urn}), + ] + res = self.SocialPostAccountCampaignLinkedin._action_campaign_post( + self.SocialPostAccountCampaignLinkedin.id + ) + self.assertEqual(res, creative_urn) + self.assertEqual( + mock_request_linkedin.call_args.kwargs["endpoint"], + "/adAccounts/999/creatives", + ) + + mock_request_linkedin.side_effect = [ + MagicMock(status_code=404, headers={"x-restli-id": creative_urn}), + ] + with self.assertRaises(UserError): + self.SocialPostAccountCampaignLinkedin._action_campaign_post( + self.SocialPostAccountCampaignLinkedin.id + ) + + campaign.remote_ref = False + with self.assertRaises(UserError) as context: + self.SocialPostAccountCampaignLinkedin._action_campaign_post( + self.SocialPostAccountCampaignLinkedin.id + ) + self.assertIn("has not been created on LinkedIn", str(context.exception)) + self.assertEqual(mock_request_linkedin.call_count, 2) + + def test_compute_message_info_multi_image_campaign(self): + """The multi-image limitation is warned when the campaign is chosen.""" + warning = "does not sponsor that format" + post = self.SocialPost.create( + { + "message": self.test_message, + "account_ids": [Command.set(self.SocialAccountLinkedin.ids)], + "image_ids": [ + Command.set( + [ + self.create_attachment("one.jpg").id, + self.create_attachment("two.jpg").id, + ] + ) + ], + } + ) + self.assertFalse(post.message_info) + + post.social_campaign_id = self.SocialCampaignLinkedin + self.assertIn(warning, post.message_info) + self.assertIn(self.SocialCampaignLinkedin.display_name, post.message_info) + + post.image_ids = [Command.set([self.create_attachment("one.jpg").id])] + self.assertFalse(post.message_info) + + def test_compute_message_info_multi_image_without_linkedin_campaign(self): + """A campaign of another media does not create a sponsored post.""" + post = self.SocialPost.create( + { + "message": self.test_message, + "account_ids": [Command.set(self.SocialAccountLinkedin.ids)], + "social_campaign_id": self.SocialCampaignLinkedin2.id, + "image_ids": [ + Command.set( + [ + self.create_attachment("one.jpg").id, + self.create_attachment("two.jpg").id, + ] + ) + ], + } + ) + self.assertFalse(post.message_info) + + def _set_linkedin_campaign(self, remote_ref=False): + group = self.SocialCampaignGroup.create({"name": "Test Group"}) + campaign = self.SocialCampaign.create( + { + "name": "Test Campaign", + "campaign_group_id": group.id, + "media_id": self.media_linkedin_data_id.id, + "account_ids": [Command.link(self.SocialAccountLinkedin.id)], + "remote_ref": remote_ref, + } + ) + self.SocialPostLinkedin.write({"social_campaign_id": campaign.id}) + return campaign + + @mute_logger(LOGGER_POST_ACCOUNT_BASE) + def test_action_post_campaign_precheck_blocks_publish(self): + """A failing precheck must not reach LinkedIn. + + The guard turns the error into a failed line instead of propagating + it, so the other accounts of the post keep their own result. + """ + self.SocialPostAccountLinkedin.write({"state": "ready", "remote_ref": False}) + self._set_linkedin_campaign() + with patch.object( + type(self.SocialPostLinkedin), + "filter_by_media_types", + autospec=True, + return_value=self.SocialPostAccountLinkedin, + ), patch.object( + type(self.SocialPostAccountLinkedin), + "_linkedin_advertising_accounts", + autospec=True, + side_effect=UserError("Ads access denied"), + ), 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( + "has not been created on LinkedIn yet", + self.SocialPostAccountLinkedin.failed_description, + ) + + @mute_logger(LOGGER_POST_ACCOUNT_BASE) + def test_action_post_multi_image_campaign_is_not_published(self): + """The multi-image guard must run before reaching LinkedIn.""" + self.SocialPostAccountLinkedin.write({"state": "ready", "remote_ref": False}) + self._set_linkedin_campaign(remote_ref="urn:li:sponsoredCampaign:100") + self.SocialPostLinkedin.write( + { + "image_ids": [ + Command.set( + [ + self.create_attachment("one.jpg").id, + self.create_attachment("two.jpg").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( + "several images", self.SocialPostAccountLinkedin.failed_description + ) + + @mute_logger(LOGGER_POST_ACCOUNT_ADVERTISING_LINKEDIN) + def test_action_post_campaign_failure_keeps_posted(self): + self.SocialPostAccountLinkedin.write({"state": "ready", "remote_ref": False}) + self._set_linkedin_campaign(remote_ref="urn:li:sponsoredCampaign:100") + post_account_urn = "urn:li:share: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), + "_linkedin_advertising_accounts", + autospec=True, + return_value="urn:li:sponsoredAccount:123", + ), 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=[], + ), patch.object( + type(self.SocialPostAccountLinkedin), + "_action_campaign_post", + autospec=True, + side_effect=UserError("Creative error"), + ): + self.SocialPostAccountLinkedin._action_post(self.SocialPostLinkedin) + self.assertEqual(self.SocialPostAccountLinkedin.state, "posted") + self.assertEqual( + self.SocialPostAccountLinkedin.remote_ref, + post_account_urn, + ) + self.assertFalse(self.SocialPostAccountLinkedin.creative_urn) + + def test_republish_keeps_the_imported_creative(self): + """A non-sponsored publication keeps a creative linked by the import.""" + self.SocialPostAccountLinkedin.creative_urn = "urn:li:sponsoredCreative:7" + self.assertFalse(self.SocialPostAccountLinkedin._requires_campaign_post()) + values = self.SocialPostAccountLinkedin._linkedin_published_values( + "urn:li:share:1" + ) + self.assertNotIn("creative_urn", values) + self.assertEqual( + self.SocialPostAccountLinkedin.creative_urn, "urn:li:sponsoredCreative:7" + ) + + def test_allow_social_campaign_ids_filters_unpublished(self): + """Only the LinkedIn campaigns already created on LinkedIn are offered. + + The publication is reset because a published post no longer changes + its campaign. + """ + self.SocialPostAccountLinkedin.write({"state": "ready", "remote_ref": False}) + campaign = self._set_linkedin_campaign() + self.SocialPostLinkedin.invalidate_recordset() + self.assertNotIn(campaign, self.SocialPostLinkedin.allow_social_campaign_ids) + campaign.remote_ref = "urn:li:sponsoredCampaign:100" + self.SocialPostLinkedin.invalidate_recordset() + self.assertIn(campaign, self.SocialPostLinkedin.allow_social_campaign_ids) diff --git a/social_media_advertising_linkedin/views/social_account_views.xml b/social_media_advertising_linkedin/views/social_account_views.xml new file mode 100644 index 0000000000..f8520ee174 --- /dev/null +++ b/social_media_advertising_linkedin/views/social_account_views.xml @@ -0,0 +1,29 @@ + + + + + + social.account.view.form.inherit.advertising.linkedin + social.account + + + +