diff --git a/requirements.txt b/requirements.txt index 154935992a..5e2c59b6fa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,7 @@ # generated from manifests external_dependencies cairosvg +linkedin-api-client lottie python-telegram-bot requests_toolbelt +tweepy diff --git a/social_media_base/README.rst b/social_media_base/README.rst new file mode 100644 index 0000000000..a694ffa0d7 --- /dev/null +++ b/social_media_base/README.rst @@ -0,0 +1,109 @@ +================= +Social Media Base +================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:9ef0613fac59ec66dcecfb14f4bff101e89925782f08418a3cf3b64bdf731cd0 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fsocial-lightgray.png?logo=github + :target: https://github.com/OCA/social/tree/18.0/social_media_base + :alt: OCA/social +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/social-18-0/social-18-0-social_media_base + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/social&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module provides the fundamental foundation for social media +management. It facilitates the integration of user accounts, posts, +reactions (likes), comments, and graph-based analysis. Designed to be +flexible and scalable, it allows developers and businesses to integrate +and customize social features according to their needs. + +Main features: + +- Integration of multiple user accounts. +- Basic methods that can be extended and adapted to suit the social + network. +- Basic business structure. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Generate group campaign. +------------------------ + +- Go to *Social Marketing* > Campaign group > New +- Fill in the required fields |CREATE_GROUP_CAMPAIGN| +- Save + +Generate campaign. +------------------ + +- Go to *Social Marketing* > Campaign > New +- Fill in the required fields |CREATE_CAMPAIGN| +- Save + +.. |CREATE_GROUP_CAMPAIGN| image:: https://raw.githubusercontent.com/social_media_base/static/img/readme/CREATE_GROUP_CAMPAIGN.png +.. |CREATE_CAMPAIGN| image:: https://raw.githubusercontent.com/social_media_base/static/img/readme/CREATE_CAMPAIGN.png + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Binhex + +Contributors +------------ + +- [Binhex Cloud] (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. + +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_base/__init__.py b/social_media_base/__init__.py new file mode 100644 index 0000000000..32f2ffeb49 --- /dev/null +++ b/social_media_base/__init__.py @@ -0,0 +1,6 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import controllers +from . import models +from . import wizards diff --git a/social_media_base/__manifest__.py b/social_media_base/__manifest__.py new file mode 100644 index 0000000000..ba2108bd1d --- /dev/null +++ b/social_media_base/__manifest__.py @@ -0,0 +1,50 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Social Media Base", + "summary": """Basic module for social media management.""", + "version": "18.0.1.0.0", + "license": "AGPL-3", + "author": "Binhex ,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/social", + "depends": ["base", "web", "mail", "utm"], + "data": [ + "security/ir.model.access.csv", + "security/social_account_security.xml", + "security/social_post_security.xml", + "security/social_post_account_security.xml", + "data/ir_cron_data.xml", + "views/social_media_views.xml", + "views/social_account_views.xml", + "views/social_post_views.xml", + "views/social_post_account_views.xml", + "views/social_comment_views.xml", + "views/social_lead_views.xml", + "views/utm_group_campaign_views.xml", + "views/utm_campaign_views.xml", + "views/social_action_client_views.xml", + "wizards/wizard_social_account.xml", + "views/social_media_base_menus.xml", + ], + "assets": { + "web.assets_backend": [ + ("include", "web.chartjs_lib"), + # GENERAL + "social_media_base/static/src/xml/**/*.xml", + "social_media_base/static/src/scss/**/*.scss", + # MIXINS + "social_media_base/static/src/js/app/**/*.js", + # SERVICES + "social_media_base/static/src/js/services/**/*.js", + # COMPONENTS + "social_media_base/static/src/components/**/*.xml", + "social_media_base/static/src/components/**/*.js", + "social_media_base/static/src/components/**/*.scss", + # VIEWS + "social_media_base/static/src/js/views/**/*.xml", + "social_media_base/static/src/js/views/**/*.scss", + "social_media_base/static/src/js/views/**/*.js", + ], + }, + "exclude": ["social"], +} diff --git a/social_media_base/controllers/__init__.py b/social_media_base/controllers/__init__.py new file mode 100644 index 0000000000..b9af37a568 --- /dev/null +++ b/social_media_base/controllers/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import thread diff --git a/social_media_base/controllers/thread.py b/social_media_base/controllers/thread.py new file mode 100644 index 0000000000..20c2db01a4 --- /dev/null +++ b/social_media_base/controllers/thread.py @@ -0,0 +1,41 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.http import request, route + +from odoo.addons.mail.controllers.thread import ThreadController +from odoo.addons.mail.models.discuss.mail_guest import add_guest_to_context + + +class ThreadControllerSocial(ThreadController): + def _prepare_result(self): + return { + "author": { + "id": request.env.user.partner_id.id, + "name": request.env.user.partner_id.name, + "is_company": request.env.user.partner_id.is_company, + "user": { + "id": request.env.uid, + "isInternalUser": request.env.user._is_internal(), + }, + "type": "partner", + } + } + + @route("/mail/message/post", methods=["POST"], type="json", auth="public") + @add_guest_to_context + def mail_message_post(self, thread_model, thread_id, post_data, context=None): + if thread_model == "social.post.account" and thread_id: + post_id = request.env[thread_model].browse(thread_id) + if post_id: + comment = post_id.create_comment(post_data, context) + request.env["bus.bus"]._sendone( + request.env.user.partner_id, "comments", comment + ) + return self._prepare_result() + else: + return None + else: + return super().mail_message_post( + thread_model, thread_id, post_data, context + ) diff --git a/social_media_base/data/ir_cron_data.xml b/social_media_base/data/ir_cron_data.xml new file mode 100644 index 0000000000..f5d880f7ef --- /dev/null +++ b/social_media_base/data/ir_cron_data.xml @@ -0,0 +1,24 @@ + + + + Social Post: Send post schedule + + code + model._run_send_post() + 5 + minutes + + 1 + + + + Social: Checking social media updates + + code + model._run_check_media_updates() + 30 + minutes + + 1 + + diff --git a/social_media_base/models/__init__.py b/social_media_base/models/__init__.py new file mode 100644 index 0000000000..6e42d138aa --- /dev/null +++ b/social_media_base/models/__init__.py @@ -0,0 +1,14 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import res_config_settings +from . import social_media_base_mixin +from . import social_post_mixin +from . import social_account +from . import social_media +from . import social_post +from . import social_post_account +from . import social_comment +from . import social_lead_form +from . import utm_group_campaign +from . import utm_campaign diff --git a/social_media_base/models/res_config_settings.py b/social_media_base/models/res_config_settings.py new file mode 100644 index 0000000000..7343283a00 --- /dev/null +++ b/social_media_base/models/res_config_settings.py @@ -0,0 +1,15 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + # Base settings model for Social Media Integration + # Platform-specific credentials are defined in their respective modules: + # - Facebook: social_media_facebook + # - LinkedIn: social_media_linkedin + # - X (Twitter): social_media_x + pass diff --git a/social_media_base/models/social_account.py b/social_media_base/models/social_account.py new file mode 100644 index 0000000000..85b41a2ccb --- /dev/null +++ b/social_media_base/models/social_account.py @@ -0,0 +1,221 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import base64 +import json + +from odoo import api, fields, models +from odoo.tools import file_open + + +class SocialAccount(models.Model): + _name = "social.account" + _inherit = ["avatar.mixin", "social.media.base.mixin"] + _description = "Social Account" + + """ + This model defines the accounts associated with the different social networks. + """ + + name = fields.Char() + active = fields.Boolean(default=True) + username = fields.Char() + media_id = fields.Many2one("social.media", ondelete="restrict") + media_type = fields.Selection(related="media_id.media_type") + company_id = fields.Many2one( + "res.company", "Company", default=lambda self: self.env.company + ) + advertising_account_id = fields.Char() + last_update_account = fields.Datetime() + post_account_ids = fields.One2many("social.post.account", "account_id") + + @api.model + def _default_image(self): + return base64.b64encode(file_open("base/static/img/avatar.png", "rb").read()) + + image_1920 = fields.Image(default=_default_image) + # Use media platform icon for kanban group headers + # Override avatar_128 to show platform logo instead of account image + avatar_128 = fields.Image( + compute="_compute_avatar_128", + store=False, + help="Platform logo dynamically retrieved from social.media.image", + ) + avatar_256 = fields.Image( + compute="_compute_avatar_256", + store=False, + help="Platform logo in 256x256 for better quality", + ) + + @api.depends("media_id", "media_id.image") + def _compute_avatar_128(self): + """Use platform logo as avatar for group headers""" + for account in self: + if account.media_id and account.media_id.image: + # Dynamically get platform logo from social.media + account.avatar_128 = account.media_id.image + else: + # Fallback to account image if no media linked + account.avatar_128 = account.image_128 + + @api.depends("media_id", "media_id.image") + def _compute_avatar_256(self): + """Use platform logo for higher resolution displays""" + for account in self: + if account.media_id and account.media_id.image: + # Dynamically get platform logo from social.media + account.avatar_256 = account.media_id.image + else: + # Fallback to account image + account.avatar_256 = account.image_256 + + # STATISTICS + comment_count = fields.Integer(default=0) + like_count = fields.Integer(default=0) + click_count = fields.Integer(default=0) + share_count = fields.Integer(default=0) + interactions_count = fields.Integer( + compute="_compute_interactions_count", + store=True, + default=0, + help=""" + Indicates the interactions with the + publication (clicks, likes, comments,shares). + """, + ) + impression_count = fields.Integer( + default=0, + help=""" + Total number of views, which may include + multiple views by the same user. + """, + ) + engagement = fields.Float(default=0) + + account_url = fields.Char(compute="_compute_account_url", store=True) + environment = fields.Selection( + [("test", "Test"), ("production", "Production")], default="test" + ) + need_update = fields.Boolean(default=False) + show_post_calendar = fields.Boolean( + default=False, help="Defines whether to display upcoming posts in the calendar." + ) + + # SECURITY + access_token = fields.Char() + refresh_access_token = fields.Char() + expire_access_token_date = fields.Date() + is_property_account = fields.Boolean( + default=False, compute="_compute_is_property_account" + ) + + @api.depends_context("uid") + def _compute_is_property_account(self): + for account in self: + account.is_property_account = self.env.user == account.create_uid + + def update_account(self): + return { + "res_model": "wizard.social.account", + "views": [[False, "form"]], + "target": "new", + "type": "ir.actions.act_window", + "context": { + "default_account_id": self.id, + "default_media_id": self.media_id.id, + "social_update_account": True, + }, + } + + def delete_account(self): + SocialPostAccount = self.env["social.post.account"] + SocialPost = self.env["social.post"] + UtmCampaign = self.env["utm.campaign"] + for account in self: + SocialPostAccount.search([("account_id", "=", account.id)]).write( + { + "active": False, + } + ) + UtmCampaign.search([("account_id", "=", account.id)]).write( + { + "active": False, + } + ) + post_ids = SocialPost.search([("account_ids", "in", account.id)]) + for post in post_ids: + if len(post.account_ids) == 1: + post.write( + { + "active": False, + } + ) + account.write( + { + "active": False, + } + ) + + def _compute_display_name(self): + """Display clean account name - platform icon shown via avatar widget""" + for account in self: + # Show just the account name - the platform logo/icon will be displayed + # via the many2one_avatar widget which uses avatar_128/avatar_256 fields + # that are computed to show media_id.image dynamically + account.display_name = account.name or "Unnamed Account" + + def _fields_account_url(self): + return [] + + @api.depends(lambda self: [val[0] for val in self._fields_account_url()]) + def _compute_account_url(self): + for account in self: + for val_url in account._fields_account_url(): + if len(val_url) < 2: + continue + if account.media_id.media_type: + account.account_url = ( + val_url[1] if account.media_id.media_type in val_url[0] else "" + ) + else: + continue + + @api.depends("click_count", "like_count", "share_count", "comment_count") + def _compute_interactions_count(self): + for account in self: + account.interactions_count = ( + account.click_count + + account.like_count + + account.share_count + + account.comment_count + ) + + def _get_chart_account_statistics(self, start_date, end_date, granularity): + return [] + + def get_chart_account_statistics( + self, start_date=None, end_date=None, granularity="WEEK" + ): + return self._get_chart_account_statistics(start_date, end_date, granularity) + + def _update_posts_statistics(self, post_id, domain): + return [] + + def update_posts_statistics(self, post_id=None, domain=None): + """ + Update posts and statistics + """ + statistics = self._update_posts_statistics(post_id, domain) + return json.dumps(statistics) + + def validate_access_token(self): + pass + + def _load_ads_accounts(self): + return {} + + def load_ads_accounts(self): + return self._load_ads_accounts() + + def _run_check_media_updates(self): + return False diff --git a/social_media_base/models/social_comment.py b/social_media_base/models/social_comment.py new file mode 100644 index 0000000000..8907580bce --- /dev/null +++ b/social_media_base/models/social_comment.py @@ -0,0 +1,136 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class SocialComment(models.Model): + """Comment Moderation System - Generic across all social platforms""" + + _name = "social.comment" + _inherit = ["mail.thread", "mail.activity.mixin"] + _description = "Social Media Comment" + _order = "created_time desc" + _rec_name = "message" + + # Core fields + post_id = fields.Many2one( + "social.post", + string="Post", + required=True, + ondelete="cascade", + index=True, + ) + comment_id = fields.Char( + string="Comment ID", + required=True, + index=True, + help="External comment ID from the social platform", + ) + parent_id = fields.Many2one( + "social.comment", + string="Parent Comment", + ondelete="cascade", + help="For nested replies", + ) + reply_ids = fields.One2many( + "social.comment", + "parent_id", + string="Replies", + ) + + # Content + message = fields.Text(required=True) + author_name = fields.Char() + author_id = fields.Char(string="Author ID", help="External author ID") + author_avatar = fields.Char(string="Author Avatar URL") + attachment_ids = fields.Many2many( + "ir.attachment", + "social_comment_attachment_rel", + "comment_id", + "attachment_id", + string="Attachments", + help="Files attached to this comment", + ) + + # Timestamps + created_time = fields.Datetime(string="Created At", required=True) + last_sync_at = fields.Datetime(string="Last Synced At") + + # Status + is_replied = fields.Boolean(string="Replied", default=False) + is_hidden = fields.Boolean(string="Hidden", default=False) + reply_count = fields.Integer(compute="_compute_reply_count", store=True) + + # Moderation + sentiment = fields.Selection( + [ + ("positive", "Positive"), + ("neutral", "Neutral"), + ("negative", "Negative"), + ], + help="Auto-detected or manual sentiment", + ) + requires_action = fields.Boolean(default=False) + assigned_to = fields.Many2one("res.users") + notes = fields.Text(string="Internal Notes") + + # mail.activity.mixin - Override to add groups for cleaner prefetch + activity_ids = fields.One2many(groups="base.group_user") + activity_state = fields.Selection(groups="base.group_user") + activity_user_id = fields.Many2one(groups="base.group_user") + activity_type_id = fields.Many2one(groups="base.group_user") + activity_type_icon = fields.Char(groups="base.group_user") + activity_date_deadline = fields.Date(groups="base.group_user") + my_activity_date_deadline = fields.Date(groups="base.group_user") + activity_summary = fields.Char(groups="base.group_user") + activity_exception_decoration = fields.Selection(groups="base.group_user") + activity_exception_icon = fields.Char(groups="base.group_user") + + _sql_constraints = [ + ( + "comment_id_unique", + "unique(comment_id)", + "This comment has already been synced!", + ), + ] + + @api.depends("reply_ids") + def _compute_reply_count(self): + for record in self: + record.reply_count = len(record.reply_ids) + + def action_reply(self): + """Open wizard to reply to comment""" + self.ensure_one() + return { + "name": "Reply to Comment", + "type": "ir.actions.act_window", + "res_model": "wizard.comment.reply", + "view_mode": "form", + "target": "new", + "context": { + "default_comment_id": self.id, + "default_post_id": self.post_id.id, + }, + } + + def action_hide(self): + """Hide comment on social platform - platform-specific implementation""" + self.ensure_one() + # Delegate to platform-specific account implementation + for account in self.post_id.account_ids: + if hasattr(account, "_hide_comment"): + success = account._hide_comment(self.comment_id) + if success: + self.is_hidden = True + return True + return True + + def action_mark_replied(self): + """Mark comment as replied""" + self.write({"is_replied": True}) + + def action_assign_to_me(self): + """Assign comment to current user""" + self.write({"assigned_to": self.env.user.id}) diff --git a/social_media_base/models/social_lead_form.py b/social_media_base/models/social_lead_form.py new file mode 100644 index 0000000000..11d3653444 --- /dev/null +++ b/social_media_base/models/social_lead_form.py @@ -0,0 +1,281 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class SocialLeadForm(models.Model): + """Generic Social Media Lead Form - works across platforms""" + + _name = "social.lead.form" + _description = "Social Media Lead Form" + _order = "created_time desc" + + name = fields.Char(string="Form Name", required=True) + account_id = fields.Many2one( + "social.account", + string="Account", + required=True, + ondelete="cascade", + ) + platform = fields.Selection( + related="account_id.media_type", + store=True, + string="Platform", + help="Social media platform (facebook, linkedin, etc.)", + ) + post_id = fields.Many2one( + "social.post", + string="Associated Ad", + help="The ad/post using this lead form", + ondelete="set null", + ) + + # Form metadata - generic across platforms + status = fields.Selection( + [("active", "Active"), ("archived", "Archived"), ("deleted", "Deleted")], + default="active", + ) + created_time = fields.Datetime() + questions = fields.Text( + string="Form Questions (JSON)", + help="JSON array of form questions and field types", + ) + privacy_policy_url = fields.Char(string="Privacy Policy URL") + locale = fields.Char(default="en_US") + + # Sync status + last_sync_at = fields.Datetime(string="Last Sync") + leads_count = fields.Integer( + string="Total Leads", + compute="_compute_leads_count", + store=False, + ) + + # Field mapping configuration + field_mapping_ids = fields.One2many( + "social.lead.field.mapping", + "lead_form_id", + string="Field Mappings", + ) + + # Webhook configuration + webhook_enabled = fields.Boolean( + default=False, + help="Enable real-time webhook notifications for new leads", + ) + + @api.depends("platform") + def _compute_leads_count(self): + """Count total leads received for this form""" + for record in self: + record.leads_count = self.env["social.lead"].search_count( + [("lead_form_id", "=", record.id)] + ) + + def action_sync_leads(self): + """Manually sync leads - platform-specific implementations override this""" + self.ensure_one() + # Base implementation - platforms override this method + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": "Sync Not Implemented", + "message": f"Lead sync not implemented for {self.platform}", + "type": "warning", + }, + } + + def action_view_leads(self): + """Open list view of leads for this form""" + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": "Leads", + "res_model": "social.lead", + "view_mode": "list,form", + "domain": [("lead_form_id", "=", self.id)], + "context": {"default_lead_form_id": self.id}, + } + + +class SocialLead(models.Model): + """Generic Social Media Lead - works across platforms""" + + _name = "social.lead" + _description = "Social Media Lead" + _order = "created_time desc" + + lead_form_id = fields.Many2one( + "social.lead.form", + string="Lead Form", + required=True, + ondelete="cascade", + ) + platform = fields.Selection( + related="lead_form_id.platform", + store=True, + string="Platform", + ) + created_time = fields.Datetime(required=True) + + # Extracted common fields (generic across platforms) + name = fields.Char() + email = fields.Char() + phone = fields.Char() + + # Raw data from platform (JSON format) + field_data_json = fields.Text( + help="Raw field data from social platform as JSON", + ) + + # Link to CRM + crm_lead_id = fields.Many2one( + "crm.lead", + string="CRM Lead", + help="Linked Odoo CRM Lead/Opportunity", + ondelete="set null", + ) + + status = fields.Selection( + [ + ("new", "New"), + ("processed", "Processed"), + ("converted", "Converted to CRM"), + ("error", "Error"), + ], + default="new", + ) + + error_message = fields.Text() + + def action_create_crm_lead(self): + """ + Convert social lead to CRM lead using field + mappings - platforms may override + """ + self.ensure_one() + + if self.crm_lead_id: + return { + "type": "ir.actions.act_window", + "res_model": "crm.lead", + "res_id": self.crm_lead_id.id, + "view_mode": "form", + "target": "current", + } + + # Default CRM lead creation + import json + + field_data = json.loads(self.field_data_json or "[]") + field_dict = {} + for field in field_data: + field_name = field.get("name") + values = field.get("values", []) + if values: + field_dict[field_name] = values[0] if len(values) == 1 else values + + # Apply field mappings + crm_values = { + "name": self.name or f"{self.platform.title()} Lead", + "email_from": self.email, + "phone": self.phone, + "description": ( + f"Source: {self.platform.title()} Lead Form\n" + f"Form: {self.lead_form_id.name}" + ), + } + + # Apply custom field mappings + for mapping in self.lead_form_id.field_mapping_ids: + field_value = field_dict.get(mapping.platform_field_name) + if field_value: + crm_values[mapping.crm_field_name] = field_value + + try: + # Create CRM lead + crm_lead = self.env["crm.lead"].create(crm_values) + + # Update social lead + self.write( + { + "crm_lead_id": crm_lead.id, + "status": "converted", + } + ) + + return { + "type": "ir.actions.act_window", + "res_model": "crm.lead", + "res_id": crm_lead.id, + "view_mode": "form", + "target": "current", + } + + except Exception as e: + self.write( + { + "status": "error", + "error_message": str(e), + } + ) + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": "Error", + "message": f"Failed to create CRM lead: {str(e)}", + "type": "danger", + "sticky": True, + }, + } + + +class SocialLeadFieldMapping(models.Model): + """Field mapping configuration for social leads to CRM""" + + _name = "social.lead.field.mapping" + _description = "Social Lead Field Mapping" + + lead_form_id = fields.Many2one( + "social.lead.form", + string="Lead Form", + required=True, + ondelete="cascade", + ) + platform_field_name = fields.Char( + string="Platform Field", + required=True, + help="Field name from platform (e.g., 'email', 'full_name', 'company_name')", + ) + crm_field_name = fields.Selection( + [ + ("name", "Lead Name"), + ("contact_name", "Contact Name"), + ("email_from", "Email"), + ("phone", "Phone"), + ("mobile", "Mobile"), + ("function", "Job Position"), + ("website", "Website"), + ("street", "Street"), + ("street2", "Street 2"), + ("city", "City"), + ("zip", "Zip"), + ("country_id", "Country"), + ("state_id", "State"), + ("description", "Notes"), + ], + string="CRM Field", + required=True, + help="Target field in Odoo CRM lead", + ) + + _sql_constraints = [ + ( + "unique_platform_field_per_form", + "unique(lead_form_id, platform_field_name)", + "Each platform field can only be mapped once per form!", + ) + ] diff --git a/social_media_base/models/social_media.py b/social_media_base/models/social_media.py new file mode 100644 index 0000000000..ac566b5973 --- /dev/null +++ b/social_media_base/models/social_media.py @@ -0,0 +1,25 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class SocialMedia(models.Model): + _name = "social.media" + _inherit = "social.media.base.mixin" + _description = "Social Media" + + """ + This model defines social networks. + """ + + name = fields.Char() + description = fields.Text() + media_type = fields.Selection( + [], + readonly=True, + ) + image = fields.Binary() + + def open_action_account(self): + pass diff --git a/social_media_base/models/social_media_base_mixin.py b/social_media_base/models/social_media_base_mixin.py new file mode 100644 index 0000000000..0597c549bf --- /dev/null +++ b/social_media_base/models/social_media_base_mixin.py @@ -0,0 +1,57 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from markupsafe import Markup + +from odoo import _, models + + +class SocialMediaBaseMixin(models.AbstractModel): + _name = "social.media.base.mixin" + _description = "Social Media Base Mixin" + + def _get_account_by_media(self): + if self: + return self.env["social.account"].search_count([("media_id", "=", self.id)]) + return None + + def _notify_user_client( + self, + target=None, + notif_type=None, + notif_message=None, + media=False, + social_name=False, + account_name=False, + ): + message_type = ( + notif_type.split("_")[-1] if len(notif_type.split("_")) > 0 else "danger" + ) + message = "" + if media: + social_media_name = ( + media.upper() + " " + social_name if social_name else media.upper() + ) + message = Markup( + _( + "Social Media %(social_media)s " + "[%(account)s]

%(message)s" + ) + % { + "social_media": social_media_name, + "account": social_media_name if not account_name else account_name, + "message": f"{'ERROR:' if message_type == 'danger' else ''}" + f" {notif_message}", + } + ) + + # Notifying the user + if message: + self.env["bus.bus"]._sendone( + target or self.env.user.partner_id, + notif_type, + { + "message_type": message_type, + "message": message, + }, + ) diff --git a/social_media_base/models/social_post.py b/social_media_base/models/social_post.py new file mode 100644 index 0000000000..e385fc4233 --- /dev/null +++ b/social_media_base/models/social_post.py @@ -0,0 +1,282 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging +from datetime import datetime, timedelta + +from odoo import Command, _, api, fields, models +from odoo.exceptions import ValidationError + +_logger = logging.getLogger(__name__) + + +class SocialPost(models.Model): + _name = "social.post" + _inherit = ["mail.thread", "mail.activity.mixin", "social.post.mixin"] + _description = "Social Post" + + account_ids = fields.Many2many( + "social.account", required=True, ondelete="restrict", string="Accounts" + ) + active = fields.Boolean(default=True) + content_type = fields.Selection( + [ + ("post", "Post"), + ("reel", "Video"), + ("story", "Story"), + ("ad", "Ad"), + ], + default="post", + required=True, + tracking=True, + help=( + "Type of content: Post, Video, Story, Ad. " + "Platforms may support different types." + ), + ) + message = fields.Text(required=True, tracking=True) + campaign_id = fields.Many2one("utm.campaign") + send_post = fields.Selection( + [("now", "Now"), ("schedule", "Schedule")], + required=True, + default="now", + tracking=True, + ) + send_post_date = fields.Datetime( + string="Schedule date", + # compute="_compute_send_post_date", + store=True, + ) + published_date = fields.Datetime(tracking=True) + state = fields.Selection( + [ + ("draft", "Draft"), + ("planned", "Planned"), + ("publishing", "Publishing"), + ("published", "Published"), + ("cancelled", "Cancelled"), + ], + default="draft", + tracking=True, + ) + post_account_ids = fields.One2many("social.post.account", "post_id") + + count_post_likes = fields.Integer( + compute="_compute_post_statistics", default=0, string="Likes" + ) + count_post_comments = fields.Integer( + compute="_compute_post_statistics", default=0, string="Comments" + ) + count_post_clicks = fields.Integer( + compute="_compute_post_statistics", default=0, string="Clicks" + ) + count_post_shares = fields.Integer( + compute="_compute_post_statistics", default=0, string="Shares" + ) + count_post_impression = fields.Integer( + compute="_compute_post_statistics", default=0, string="Impression" + ) + count_post_engagement = fields.Float( + compute="_compute_post_statistics", default=0, string="Engagement" + ) + count_post_interactions = fields.Float( + compute="_compute_post_statistics", default=0, string="Interactions" + ) + + image_ids = fields.Many2many( + "ir.attachment", + column1="post_id", + column2="image_id", + ondelete="restrict", + relation="social_network_post_image_rel", + string="Images", + help=( + "Attach multiple images (up to 10). " + "Cannot mix images and videos in the same post." + ), + ) + + video_ids = fields.Many2many( + "ir.attachment", + relation="social_network_post_video_rel", + column1="post_id", + column2="video_id", + ondelete="restrict", + string="Videos", + help="Attach a single video. Cannot mix images and videos in the same post. " + "Note: Only the first video will be used for Facebook posts.", + ) + + post_preview = fields.Html(compute="_compute_post_preview", store=True) + + # mail.activity.mixin - Override to add groups for cleaner prefetch + activity_ids = fields.One2many(groups="base.group_user") + activity_state = fields.Selection(groups="base.group_user") + activity_user_id = fields.Many2one(groups="base.group_user") + activity_type_id = fields.Many2one(groups="base.group_user") + activity_type_icon = fields.Char(groups="base.group_user") + activity_date_deadline = fields.Date(groups="base.group_user") + my_activity_date_deadline = fields.Date(groups="base.group_user") + activity_summary = fields.Char(groups="base.group_user") + activity_exception_decoration = fields.Selection(groups="base.group_user") + activity_exception_icon = fields.Char(groups="base.group_user") + + @api.constrains("image_ids", "video_ids") + def _check_media_exclusivity(self): + """Ensure that a post cannot have both images and videos""" + for post in self: + if post.image_ids and post.video_ids: + raise ValidationError( + _( + "You cannot attach both images and videos to the same post. " + "Please choose either images or a video." + ) + ) + + @api.depends("account_ids.media_id") + def _compute_display_name(self): + for acc in self: + acc.display_name = "Post on {}".format( + ", ".join(acc.account_ids.mapped("media_id.name")) + ) + + @api.depends("send_post") + def _compute_send_post_date(self): + for post in self: + if post.send_post == "schedule": + post.send_post_date = datetime.now() + timedelta(hours=1) + post.state = "planned" + + @api.depends( + "post_account_ids.like_count", + "post_account_ids.comment_count", + "post_account_ids.click_count", + "post_account_ids.share_count", + "post_account_ids.engagement", + "post_account_ids.impression_count", + ) + def _compute_post_statistics(self): + for post in self: + post.count_post_clicks = sum(post.mapped("post_account_ids.click_count")) + post.count_post_shares = sum(post.mapped("post_account_ids.share_count")) + post.count_post_likes = sum(post.mapped("post_account_ids.like_count")) + post.count_post_engagement = sum(post.mapped("post_account_ids.engagement")) + post.count_post_impression = sum(post.mapped("post_account_ids.engagement")) + post.count_post_comments = sum( + post.mapped("post_account_ids.comment_count") + ) + post.count_post_interactions = ( + post.count_post_clicks + + post.count_post_likes + + post.count_post_comments + + post.count_post_shares + ) + + def _render_values_preview(self): + """ + Add extra values dictionary for preview view, if necessary. + """ + return {} + + def _render_template_preview(self): + render_template = "" + IrQweb = self.env["ir.qweb"] + for account in self.account_ids: + values = { + "media_id": account.media_id, + "author": account.name, + "message": self.message, + "image_ids": self.image_ids[0:2], + } + try: + render_template += """\n\n""" + IrQweb._render( + f"social_media_{account.media_id.media_type}.social_network_post_preview", + values | self._render_values_preview(), + ) + except ValueError: + render_template += """\n\n""" + IrQweb._render( + "social_media_base.social_network_post_preview", + values | self._render_values_preview(), + ) + return render_template if render_template else _("No preview available") + + @api.depends("account_ids", "message", "image_ids", "video_ids") + def _compute_post_preview(self): + """ + This method is responsible for obtaining the templates + to preview the posts when they are created. + + Template ID format: + * social_media_{media_type}.social_network_post_preview + Example: + * social_media_linkedin.social_network_post_preview + + As many templates as there are media according to the accounts selected in + the post will be rendered. + + If the template does not exist, a default one is rendered. + """ + for post in self: + post.post_preview = post._render_template_preview() + + def _default_account_ids(self): + return [] + + @api.model + def default_get(self, fields): + res = super().default_get(fields) + account_ids = self._default_account_ids() + if account_ids: + res["account_ids"] = [(6, 0, account_ids)] + return res + + def action_create_post_account(self): + self._action_create_post_account() + + def _prepare_post_account_values(self): + posts_account = [] + for account in self.account_ids: + fb_video_url = None + if self.video_ids: + first_video = self.video_ids[0] + # Generate the URL for the video attachment + fb_video_url = f"/web/image/{first_video._name}/{first_video.id}/datas" + + posts_account.append( + Command.create( + { + "post_id": self.id, + "account_id": account.id, + "state": "ready", + "message": self.message, + "fb_video_url": fb_video_url, + } + ) + ) + return posts_account + + def _action_create_post_account(self): + for post in self: + post.write( + { + "state": "publishing", + "published_date": fields.Datetime.now(), + "post_account_ids": post._prepare_post_account_values(), + } + ) + post.post_account_ids[0]._action_post() + post.write( + { + "state": "published", + } + ) + + def _run_send_post(self): + post_accounts = self.env["social.post"].search( + [ + ("state", "=", "planned"), + ("send_post", "=", "schedule"), + ("send_post_date", "<=", fields.Datetime.now()), + ] + ) + post_accounts._action_create_post_account() diff --git a/social_media_base/models/social_post_account.py b/social_media_base/models/social_post_account.py new file mode 100644 index 0000000000..d206ac08a9 --- /dev/null +++ b/social_media_base/models/social_post_account.py @@ -0,0 +1,123 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, fields, models + + +class SocialPostAccount(models.Model): + _name = "social.post.account" + _inherit = ["mail.thread", "social.post.mixin"] + _description = "Social Post Account" + _rec_name = "message" + + post_id = fields.Many2one("social.post", ondelete="cascade") + active = fields.Boolean(default=True) + account_id = fields.Many2one("social.account", ondelete="restrict", required=True) + media_id = fields.Many2one( + "social.media", related="account_id.media_id", required=True + ) + media_type = fields.Selection(related="media_id.media_type") + content_type = fields.Selection( + related="post_id.content_type", + store=True, + readonly=False, + help="Type of content - inherited from post but can be overridden per account", + ) + + state = fields.Selection( + [ + ("ready", "Ready"), + ("posting", "Posting"), + ("posted", "Posted"), + ("failed", "Failed"), + ], + default="ready", + ) + published_date = fields.Datetime() + published = fields.Boolean(default=True) + message = fields.Text(required=True) + + comment_count = fields.Integer() + like_count = fields.Integer() + click_count = fields.Integer() + share_count = fields.Integer() + view_count = fields.Integer(string="Views", help="Number of views/impressions") + impression_count = fields.Float() + engagement = fields.Float() + + video_ids = fields.Many2many( + "ir.attachment", + relation="social_post_account_video_rel", + column1="post_id", + column2="video_id", + ) + + image_ids = fields.Many2many( + "ir.attachment", + relation="social_post_account_image_rel", + column1="post_id", + column2="image_id", + ) + + fb_video_url = fields.Char( + string="Facebook Video URL", + help=( + "URL to display video in template. " + "Generated from video_ids or synced from Facebook." + ), + ) + + failed_description = fields.Html() + post_account_url = fields.Char() + author = fields.Char(related="account_id.name", store=True) + actor_urn = fields.Char() + + def action_like_post(self, author_urn=None): + return {"success": True, "message": ""} + + def action_like_comment(self, author_urn=None): + return {"success": True, "message": ""} + + def _action_post(self): + """ + Post on social network + """ + pass + + def _action_campaign_post(self, post_id): + pass + + def _delete_post_account(self): + pass + + def delete_post_account(self): + self._delete_post_account() + account_id = self.account_id + post_id = self.post_id + self.unlink() + if not post_id.post_account_ids: + post_id.unlink() + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Post deleted [%(account)s]") % {"account": account_id.name}, + "type": "success", + "message": _("The post was successfully deleted."), + "next": {"type": "ir.actions.client", "tag": "reload"}, + }, + } + + def filter_by_media_types(self, media_types): + return self.env["social.post.account"].search( + [ + ("media_type", "in", media_types), + ("state", "in", ("ready", "failed")), + ] + ) + + def get_comments(self): + return {"success": False, "data": []} + + def create_comment(self, post_data, context=None): + pass diff --git a/social_media_base/models/social_post_mixin.py b/social_media_base/models/social_post_mixin.py new file mode 100644 index 0000000000..23e6e7d485 --- /dev/null +++ b/social_media_base/models/social_post_mixin.py @@ -0,0 +1,28 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import json + +from odoo import api, fields, models + + +class SocialPostMixin(models.AbstractModel): + _name = "social.post.mixin" + _description = "Social Network Post Mixin" + + image_urls = fields.Char(compute="_compute_image_urls", store=True) + video_urls = fields.Char(compute="_compute_video_urls", store=True) + + @api.depends(lambda self: ["image_ids"]) + def _compute_image_urls(self): + for post in self: + post.image_urls = json.dumps( + [f"/web/image/{image_id}" for image_id in post.image_ids.ids] + ) + + @api.depends(lambda self: ["video_ids"]) + def _compute_video_urls(self): + for post in self: + post.video_urls = json.dumps( + [f"/web/content/{video_id}" for video_id in post.video_ids.ids] + ) diff --git a/social_media_base/models/utm_campaign.py b/social_media_base/models/utm_campaign.py new file mode 100644 index 0000000000..c1caabf5c4 --- /dev/null +++ b/social_media_base/models/utm_campaign.py @@ -0,0 +1,37 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import Command, api, fields, models + + +class UtmCampaign(models.Model): + _inherit = "utm.campaign" + + campaign_group_id = fields.Many2one("utm.group.campaign", string="Campaign group") + allow_media_ids = fields.Many2many( + "social.media", string="Available Media", compute="_compute_media_id" + ) + media_id = fields.Many2one( + "social.media", string="Social Media", domain="[('id','in',allow_media_ids)]" + ) + account_id = fields.Many2one( + "social.account", + string="Account", + domain="[('media_id', '=', media_id)]", + help="Optional: Select a specific account for platform-specific campaigns", + ) + + @api.depends("media_id", "account_id") + def _compute_media_id(self): + SocialMedia = self.env["social.media"] + for campaign in self: + campaign.allow_media_ids = [ + Command.set( + SocialMedia.search( + [("media_type", "in", campaign._available_campaign())] + ).ids + ) + ] + + def _available_campaign(self): + return [] diff --git a/social_media_base/models/utm_group_campaign.py b/social_media_base/models/utm_group_campaign.py new file mode 100644 index 0000000000..3b5e5ac639 --- /dev/null +++ b/social_media_base/models/utm_group_campaign.py @@ -0,0 +1,11 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class UtmGroupCampaign(models.Model): + _name = "utm.group.campaign" + _description = "UTM Group Campaign" + + name = fields.Char() diff --git a/social_media_base/pyproject.toml b/social_media_base/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/social_media_base/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/social_media_base/readme/CONTRIBUTORS.md b/social_media_base/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..0bfe91dd47 --- /dev/null +++ b/social_media_base/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Binhex Cloud] (https://www.binhex.cloud): + - Edilio Escalona Almira diff --git a/social_media_base/readme/DESCRIPTION.md b/social_media_base/readme/DESCRIPTION.md new file mode 100644 index 0000000000..9c7420d438 --- /dev/null +++ b/social_media_base/readme/DESCRIPTION.md @@ -0,0 +1,11 @@ +This module provides the fundamental foundation for social media management. +It facilitates the integration of user accounts, posts, reactions (likes), +comments, and graph-based analysis. Designed to be flexible and scalable, +it allows developers and businesses to integrate and customize social features +according to their needs. + +Main features: + +- Integration of multiple user accounts. +- Basic methods that can be extended and adapted to suit the social network. +- Basic business structure. diff --git a/social_media_base/readme/USAGE.md b/social_media_base/readme/USAGE.md new file mode 100644 index 0000000000..bebf9dec64 --- /dev/null +++ b/social_media_base/readme/USAGE.md @@ -0,0 +1,15 @@ +Generate group campaign. +--------------- + +- Go to *Social Marketing* > Campaign group > New +- Fill in the required fields + ![CREATE_GROUP_CAMPAIGN](/social_media_base/static/img/readme/CREATE_GROUP_CAMPAIGN.png) +- Save + +Generate campaign. +--------------- + +- Go to *Social Marketing* > Campaign > New +- Fill in the required fields + ![CREATE_CAMPAIGN](/social_media_base/static/img/readme/CREATE_CAMPAIGN.png) +- Save diff --git a/social_media_base/security/ir.model.access.csv b/social_media_base/security/ir.model.access.csv new file mode 100644 index 0000000000..8e621aa02c --- /dev/null +++ b/social_media_base/security/ir.model.access.csv @@ -0,0 +1,11 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_social_media_admin,social_media_base_social_media,model_social_media,base.group_user,1,1,1,1 +access_social_account_admin,social_media_base_social_account,model_social_account,base.group_user,1,1,1,1 +access_social_network_post_admin,social_media_base_social_post,model_social_post,base.group_user,1,1,1,1 +access_social_post_account_admin,social_media_base_social_account,model_social_post_account,base.group_user,1,1,1,1 +access_social_comment_user,social_comment_user,model_social_comment,base.group_user,1,1,1,1 +access_social_lead_form_user,social_lead_form_user,model_social_lead_form,base.group_user,1,1,1,1 +access_social_lead_user,social_lead_user,model_social_lead,base.group_user,1,1,1,1 +access_social_lead_field_mapping_user,social_lead_field_mapping_user,model_social_lead_field_mapping,base.group_user,1,1,1,1 +access_utm_group_campaign_admin,social_media_base_utm_group_campaign,model_utm_group_campaign,base.group_user,1,1,1,1 +access_wizard_social_account_admin,social_media_base_wizard_social_account,model_wizard_social_account,base.group_user,1,1,1,1 diff --git a/social_media_base/security/social_account_security.xml b/social_media_base/security/social_account_security.xml new file mode 100644 index 0000000000..47ef8091fe --- /dev/null +++ b/social_media_base/security/social_account_security.xml @@ -0,0 +1,12 @@ + + + + + Social media accounts, the one who created it is the logged-in user. + + [('create_uid', '=', user.id)] + + diff --git a/social_media_base/security/social_post_account_security.xml b/social_media_base/security/social_post_account_security.xml new file mode 100644 index 0000000000..bd444e2f7e --- /dev/null +++ b/social_media_base/security/social_post_account_security.xml @@ -0,0 +1,12 @@ + + + + + Social media post accounts, the one who created it is the logged-in user. + + [('account_id.create_uid', '=', user.id)] + + diff --git a/social_media_base/security/social_post_security.xml b/social_media_base/security/social_post_security.xml new file mode 100644 index 0000000000..66f937dd19 --- /dev/null +++ b/social_media_base/security/social_post_security.xml @@ -0,0 +1,12 @@ + + + + + Social media post, the one who created it is the logged-in user. + + [('create_uid', '=', user.id)] + + diff --git a/social_media_base/social_utils.py b/social_media_base/social_utils.py new file mode 100644 index 0000000000..642220ce49 --- /dev/null +++ b/social_media_base/social_utils.py @@ -0,0 +1,192 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import re +from datetime import date, datetime, timedelta +from urllib.parse import quote + +import pytz +from werkzeug.urls import url_encode, url_quote + +from odoo.exceptions import ValidationError +from odoo.tools import DEFAULT_SERVER_DATE_FORMAT +from odoo.tools.date_utils import add + + +def convert_to_days(seconds=None, miliseconds=None): + """ + Converts the given duration in seconds or miliseconds into days. + + :param int seconds: duration in seconds + :param int miliseconds: duration in miliseconds + :return: duration in days + :rtype: int + """ + if seconds: + return seconds / 60 / 60 / 24 + elif miliseconds: + return miliseconds / 1000 / 60 / 60 / 24 + return 0 + + +def convert_to_date( + date_add=None, + seconds=None, + miliseconds=None, + expire_date=True, + time_zone=None, + format_date=None, +): + if time_zone and isinstance(time_zone, str): + time_zone = pytz.timezone(time_zone) + if expire_date: + if not date_add: + date_add = date.today() + return_date = add( + date_add + timedelta(days=convert_to_days(seconds, miliseconds)) + ) + else: + return_date = datetime.fromtimestamp(miliseconds / 1000, tz=time_zone) + if format_date: + return_date = return_date.strftime(format_date) + return return_date + + +def convert_date_in_time(miliseconds, timezone=None): + timezone = timezone if timezone else pytz.utc + if isinstance(timezone, str): + timezone = pytz.timezone(timezone) + val_date = convert_to_date( + miliseconds=miliseconds, expire_date=False, time_zone=timezone + ) + current_date = datetime.now(timezone) + diff_date = current_date - val_date + seconds = diff_date.total_seconds() + minutes = seconds / 60 # Convert seconds to minutes + hours = minutes / 60 # Convert minutes to hours + days = hours / 24 # Convert hours to days + months = days / 30 # Convert days to months (months 30 days) + years = months / 12 # Convert months to years + + if seconds < 60: + date_in_time = f"{int(seconds)} seconds" + elif minutes < 60: + date_in_time = f"{int(minutes)} minutes" + elif hours < 24: + date_in_time = f"{int(hours)} hours" + elif days < 30: + date_in_time = f"{int(days)} days" + elif months < 12: + date_in_time = f"{int(months)} months" + else: + years_exacts = int(years) + months_exacts = int(months % 12) + date_in_time = f"{years_exacts} years y {months_exacts} months" + return date_in_time + + +def replace_repetitions(text, character_replace, character_new, repetitions): + positions = [m.start() for m in re.finditer(re.escape(character_replace), text)] + # Convert string to list to modify it + text_result = list(text) + # Replace only the indicated repetitions + count = 0 + for repetition in repetitions: + if int(repetition) - 1 < len(positions): # Check if the occurrence exists + if count == 0: + start = positions[int(repetition) - 1] + count += 1 + else: + start = positions[int(repetition) - 1] - ( + (len(character_replace) - 1) * count + ) + count += 1 + fin = start + len(character_replace) + text_result[start:fin] = character_new + return "".join(text_result) # We convert the list back to string + + +def social_url_encode( + param_field, params_values, params_values_char_ignore, format_quote=False +): + values = {param_field: params_values[param_field]} + if isinstance(params_values[param_field], list): + values = ( + "List(" + + ",".join( + quote(param_value, safe=",") + for param_value in params_values[param_field] + ) + + ")" + ) + url_format = f"{param_field}={url_quote(values, safe='()%,')}".replace("+", "") + elif format_quote: + url_format = ( + f"{param_field}={url_quote(params_values[param_field], safe='()%,')}" + ) + else: + url_format = url_encode(values) + if params_values_char_ignore and params_values_char_ignore.get(param_field, False): + for params_values_char in params_values_char_ignore[param_field]: + for key, character in params_values_char.items(): + if quote(character) in url_format and key == "all": + url_format = url_format.replace(quote(character), character) + else: + url_format = replace_repetitions( + url_format, quote(character), character, key.split(",") + ) + return url_format + + +def _generate_timestamps(date_start=None, date_end=None): + if isinstance(date_start, str): + date_start = datetime.strptime(date_start, DEFAULT_SERVER_DATE_FORMAT) + if isinstance(date_end, str): + date_end = datetime.strptime(date_end, DEFAULT_SERVER_DATE_FORMAT) + + if date_start: + date_start_time = date_start.timestamp() * 1000 + else: + date_start_time = datetime.now().timestamp() * 1000 + + if date_end: + date_end_time = date_start_time + date_end.timestamp() * 1000 + else: + date_end_time = date_start_time + (30 * 86400000) + return int(date_start_time), int(date_end_time) + + +def get_weeks(start_date, end_date, freq="W-MON"): + if isinstance(start_date, str): + start_date = datetime.fromisoformat(start_date) + if isinstance(end_date, str): + end_date = datetime.fromisoformat(end_date) + + result = [] + + if freq == "D": + current = start_date + while current <= end_date: + result.append(current.strftime("%d/%m/%Y")) + current += timedelta(days=1) + + elif freq == "ME": # Month End + current = start_date.replace(day=1) + while current <= end_date: + next_month = (current.replace(day=28) + timedelta(days=4)).replace(day=1) + last_day = next_month - timedelta(days=1) + if last_day <= end_date: + result.append(last_day.strftime("%m/%Y")) + current = next_month + + elif freq == "W-MON": + # Align start_date to the next Monday + days_ahead = (0 - start_date.weekday()) % 7 + current = start_date + timedelta(days=days_ahead) + while current <= end_date: + result.append(current.strftime("%W/%Y")) + current += timedelta(weeks=1) + + else: + raise ValidationError(f"Unsupported frequency: {freq}") + + return result diff --git a/social_media_base/static/description/icon.png b/social_media_base/static/description/icon.png new file mode 100644 index 0000000000..f10b3a7b31 Binary files /dev/null and b/social_media_base/static/description/icon.png differ diff --git a/social_media_base/static/description/icon.svg b/social_media_base/static/description/icon.svg new file mode 100644 index 0000000000..c77511d417 --- /dev/null +++ b/social_media_base/static/description/icon.svg @@ -0,0 +1,83 @@ + + diff --git a/social_media_base/static/description/index.html b/social_media_base/static/description/index.html new file mode 100644 index 0000000000..a9da0a137b --- /dev/null +++ b/social_media_base/static/description/index.html @@ -0,0 +1,461 @@ + + + + + +Social Media Base + + + +
+

Social Media Base

+ + +

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

+

This module provides the fundamental foundation for social media +management. It facilitates the integration of user accounts, posts, +reactions (likes), comments, and graph-based analysis. Designed to be +flexible and scalable, it allows developers and businesses to integrate +and customize social features according to their needs.

+

Main features:

+
    +
  • Integration of multiple user accounts.
  • +
  • Basic methods that can be extended and adapted to suit the social +network.
  • +
  • Basic business structure.
  • +
+

Table of contents

+ +
+

Usage

+
+

Generate group campaign.

+
    +
  • Go to Social Marketing > Campaign group > New
  • +
  • Fill in the required fields CREATE_GROUP_CAMPAIGN
  • +
  • Save
  • +
+
+
+

Generate campaign.

+
    +
  • Go to Social Marketing > Campaign > New
  • +
  • Fill in the required fields CREATE_CAMPAIGN
  • +
  • Save
  • +
+
+
+
+

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

+ +
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

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

+

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

+

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

+
+
+
+ + diff --git a/social_media_base/static/img/readme/CREATE_CAMPAIGN.png b/social_media_base/static/img/readme/CREATE_CAMPAIGN.png new file mode 100644 index 0000000000..a9028b0c02 Binary files /dev/null and b/social_media_base/static/img/readme/CREATE_CAMPAIGN.png differ diff --git a/social_media_base/static/img/readme/CREATE_GROUP_CAMPAIGN.png b/social_media_base/static/img/readme/CREATE_GROUP_CAMPAIGN.png new file mode 100644 index 0000000000..ea007c2f8f Binary files /dev/null and b/social_media_base/static/img/readme/CREATE_GROUP_CAMPAIGN.png differ diff --git a/social_media_base/static/src/components/social_account/social_account.esm.js b/social_media_base/static/src/components/social_account/social_account.esm.js new file mode 100644 index 0000000000..f2708f8650 --- /dev/null +++ b/social_media_base/static/src/components/social_account/social_account.esm.js @@ -0,0 +1,66 @@ +import { + Component, + onMounted, + onWillStart, + onWillUnmount, + useRef, + useState, +} from "@odoo/owl"; +import {useBus, useService} from "@web/core/utils/hooks"; +import {Popover} from "@web/core/popover/popover"; +import {browser} from "@web/core/browser/browser"; + +export class SocialAccount extends Component { + static template = "social_media_base.SocialAccount"; + static props = { + socialAccounts: {type: Array}, + }; + + setup() { + super.setup(); + this.orm = useService("dialog"); + this.dashboard = useRef("dashboard"); + this.popovers = []; + this.state = useState({ + needUpdate: false, + }); + + onWillStart(async () => { + this.state.needUpdate = + this.props.socialAccounts.filter((item) => item.need_update).length > 0; + }); + + onMounted(() => this._initPopovers()); + + onWillUnmount(() => this._disposePopovers()); + + useBus(this.env.bus, "SOCIAL:NEED-UPDATE", async ({detail: data}) => { + this.state.needUpdate = data.needUpdate; + }); + } + + _initPopovers() { + // Initialize Bootstrap popovers for metric tooltips + // Popover is available globally from Bootstrap + if (typeof Popover !== "undefined") { + const popoverElements = browser.document.querySelectorAll( + '[data-bs-toggle="popover"]' + ); + popoverElements.forEach((el) => { + this.popovers.push( + new Popover(el, { + trigger: "hover", + delay: {show: 500, hide: 0}, + }) + ); + }); + } + } + + _disposePopovers() { + this.popovers.forEach((popover) => { + popover.dispose(); + }); + this.popovers = []; + } +} diff --git a/social_media_base/static/src/components/social_account/social_account.scss b/social_media_base/static/src/components/social_account/social_account.scss new file mode 100644 index 0000000000..41a574c70e --- /dev/null +++ b/social_media_base/static/src/components/social_account/social_account.scss @@ -0,0 +1,5 @@ +div.social-account-oca { + > div { + margin: 0 0 5px 0 !important; + } +} diff --git a/social_media_base/static/src/components/social_account/social_account.xml b/social_media_base/static/src/components/social_account/social_account.xml new file mode 100644 index 0000000000..ce4944150f --- /dev/null +++ b/social_media_base/static/src/components/social_account/social_account.xml @@ -0,0 +1,104 @@ + + + + +
+ + +
+ +
+
+ Platform + +
+ +
+ + +
+ + Number of times your posts have been viewed +
+ + +
+ + Number of times people have engaged with your posts (likes, comments, shares...) +
+ + +
+ + Engagement rate showing how actively your audience interacts with your content +
+ + +
+
+
+
+
+ +
diff --git a/social_media_base/static/src/components/social_ads/social_ads.esm.js b/social_media_base/static/src/components/social_ads/social_ads.esm.js new file mode 100644 index 0000000000..ee8c76cf65 --- /dev/null +++ b/social_media_base/static/src/components/social_ads/social_ads.esm.js @@ -0,0 +1,54 @@ +import {Component} from "@odoo/owl"; +import {browser} from "@web/core/browser/browser"; + +export class SocialAds extends Component { + static template = "social_media_base.SocialAds"; + static props = { + socialAds: {type: Object, required: true}, + }; + + /** + * Gets the list of ads. + * + * @returns {Object[]} - The list of ads. + */ + get ads() { + return this.props.socialAds; + } + + /** + * Gets the statistic object of the ads. + * + * @returns {Object} - The statistic object of the ads. + */ + get statistic() { + return this.props.socialAds.statistic; + } + + /** + * Gets the campaign object from the social ads. + * + * @returns {Object} - The campaign object of the ads. + */ + get campaign() { + return this.props.socialAds.campaign; + } + + /** + * Gets the post object from the social ads. + * + * @returns {Object} - The post object of the ads. + */ + get post() { + return this.props.socialAds.post; + } + + /** + * Opens the ad link in a new tab. + * + * @returns {void} + */ + onAdsClick() { + browser.open(this.ads.url); + } +} diff --git a/social_media_base/static/src/components/social_ads/social_ads.scss b/social_media_base/static/src/components/social_ads/social_ads.scss new file mode 100644 index 0000000000..76f84aae7c --- /dev/null +++ b/social_media_base/static/src/components/social_ads/social_ads.scss @@ -0,0 +1,4 @@ +div.social-network-ads { + border: 1px solid var(--border-color, #dee2e6); + background-color: white; +} diff --git a/social_media_base/static/src/components/social_ads/social_ads.xml b/social_media_base/static/src/components/social_ads/social_ads.xml new file mode 100644 index 0000000000..5f6767436d --- /dev/null +++ b/social_media_base/static/src/components/social_ads/social_ads.xml @@ -0,0 +1,50 @@ + + + + + + diff --git a/social_media_base/static/src/components/social_ads_account/social_ads_account.esm.js b/social_media_base/static/src/components/social_ads_account/social_ads_account.esm.js new file mode 100644 index 0000000000..af72bd98b5 --- /dev/null +++ b/social_media_base/static/src/components/social_ads_account/social_ads_account.esm.js @@ -0,0 +1,126 @@ +import {Component, onWillStart, useState} from "@odoo/owl"; +import {ControlPanel} from "@web/search/control_panel/control_panel"; +import {SocialAds} from "../social_ads/social_ads.esm"; +import {SocialFilter} from "../social_filter/social_filter.esm"; +import {registry} from "@web/core/registry"; +import {useService} from "@web/core/utils/hooks"; + +const {DateTime} = luxon; + +export class SocialAdsAccount extends Component { + static template = "social_media_base.SocialAdsAccount"; + static components = { + ControlPanel, + SocialAds, + SocialFilter, + }; + + /** + * Initializes the component by setting up services and initializing state. + * + * This method sets up the ORM and notification services, and initializes + * the state variables for social ads, campaigns, and posts. It also + * triggers the loading of ad accounts before the component starts. + */ + setup() { + this.ormService = useService("orm"); + this.notification = useService("notification"); + this.socialAdsAccount = []; + this.campaigns = []; + this.posts = []; + this.social_state = useState({ + socialAds: [], + loaderAds: false, + }); + onWillStart(async () => { + await this._loadAdsAccount(); + }); + } + + filter_ads(item, startDate, endDate, val_search) { + const created = DateTime.fromFormat(item.created, "dd/MM/yyyy"); + const start_date = DateTime.fromFormat(startDate, "yyyy-MM-dd"); + const end_date = DateTime.fromFormat(endDate, "yyyy-MM-dd"); + return ( + (start_date ? created >= start_date : false) && + (end_date ? created <= end_date : false) && + (val_search + ? (item.campaign.name + ? item.campaign.name.includes(val_search) + : false) || + (item.post.name ? item.post.name.includes(val_search) : false) || + item.status.includes(val_search) + : true) + ); + } + + onFilterAds({startDate, endDate, val_search}) { + if (val_search || startDate || endDate) { + this.social_state.socialAds = this.socialAdsAccount.ads.filter((item) => { + return this.filter_ads(item, startDate, endDate, val_search); + }); + } else { + this.clearFilter(); + } + } + + /** + * Clears the filter and displays all ads again. + * + * When called, this method resets the `socialAds` state to the original + * list of ads retrieved from the server, effectively clearing any + * filtering criteria. + */ + clearFilter() { + this.social_state.socialAds = this.socialAdsAccount.ads; + } + + /** + * Gets the ads after applying the filter criteria. + * + * @returns {Object[]} - The ads after applying the filter criteria. + */ + get ads() { + return this.social_state.socialAds; + } + + /** + * Loads all ads again from the server. + * + * This method is triggered by the "Sync ads" button and is used to + * reload all ads from the server. It will clear any filtering criteria + * and display all ads again. + * + * @returns {Promise} + */ + async onUpdateAllAds() { + await this._loadAdsAccount(); + } + + /** + * Loads all ads from the server. + * + * This method is called when the user clicks on the "Sync ads" button. + * It will clear any filtering criteria, set the `loaderAds` state to + * `true`, and load all ads from the server. After loading the ads, + * it sets the `loaderAds` state to `false` and updates the component's + * state with the retrieved ads. + * + * @returns {Promise} + */ + async _loadAdsAccount() { + this.social_state.loaderAds = true; + const adsAccount = await this.ormService.call( + "social.account", + "load_ads_accounts", + [[]] + ); + this.socialAdsAccount = adsAccount; + this.social_state.socialAds = adsAccount.ads; + this.campaigns = adsAccount.campaigns; + this.posts = adsAccount.posts; + this.social_state.loaderAds = false; + } +} + +registry.category("actions").add("social_ads_account", SocialAdsAccount); diff --git a/social_media_base/static/src/components/social_ads_account/social_ads_account.scss b/social_media_base/static/src/components/social_ads_account/social_ads_account.scss new file mode 100644 index 0000000000..4c6d182540 --- /dev/null +++ b/social_media_base/static/src/components/social_ads_account/social_ads_account.scss @@ -0,0 +1,9 @@ +div.social-network-ads-account { + min-height: 100% !important; + height: 100% !important; + overflow: auto; + + div.social-ads { + margin: 0 !important; + } +} diff --git a/social_media_base/static/src/components/social_ads_account/social_ads_account.xml b/social_media_base/static/src/components/social_ads_account/social_ads_account.xml new file mode 100644 index 0000000000..6671929928 --- /dev/null +++ b/social_media_base/static/src/components/social_ads_account/social_ads_account.xml @@ -0,0 +1,57 @@ + + + +