forked from Mapan/odoo17e
433 lines
19 KiB
Python
433 lines
19 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
import json
|
|
import threading
|
|
|
|
from collections import defaultdict
|
|
from markupsafe import Markup
|
|
|
|
from odoo import _, api, fields, models
|
|
from odoo.exceptions import AccessError, UserError, ValidationError
|
|
|
|
|
|
class SocialPost(models.Model):
|
|
""" A social.post represents a post that will be published on multiple social.accounts at once.
|
|
It doesn't do anything on its own except storing the global post configuration (message, images, ...).
|
|
|
|
This model inherits from `social.post.template` which contains the common part of both
|
|
(all fields related to the post content like the message, the images...). So we do not
|
|
duplicate the code by inheriting from it. We can generate a `social.post` from a
|
|
`social.post.template` with `action_generate_post`.
|
|
|
|
When posted, it actually creates several instances of social.live.posts (one per social.account)
|
|
that will publish their content through the third party API of the social.account. """
|
|
|
|
_name = 'social.post'
|
|
_inherit = ['mail.thread', 'mail.activity.mixin', 'social.post.template', 'utm.source.mixin']
|
|
_description = 'Social Post'
|
|
_order = 'create_date desc'
|
|
|
|
state = fields.Selection([
|
|
('draft', 'Draft'),
|
|
('scheduled', 'Scheduled'),
|
|
('posting', 'Posting'),
|
|
('posted', 'Posted')],
|
|
string='Status', default='draft', readonly=True, required=True,
|
|
help="The post is considered as 'Posted' when all its sub-posts (one per social account) are either 'Failed' or 'Posted'")
|
|
has_post_errors = fields.Boolean("There are post errors on sub-posts", compute='_compute_has_post_errors')
|
|
account_ids = fields.Many2many(domain="[('id', 'in', account_allowed_ids)]")
|
|
account_allowed_ids = fields.Many2many('social.account', string='Allowed Accounts', compute='_compute_account_allowed_ids',
|
|
help='List of the accounts which can be selected for this post.')
|
|
company_id = fields.Many2one('res.company', string='Company',
|
|
default=lambda self: self.env.company,
|
|
domain=lambda self: [('id', 'in', self.env.companies.ids)])
|
|
media_ids = fields.Many2many('social.media', compute='_compute_media_ids', store=True,
|
|
help="The social medias linked to the selected social accounts.")
|
|
live_post_ids = fields.One2many('social.live.post', 'post_id', string="Posts By Account", readonly=True,
|
|
help="Sub-posts that will be published on each selected social accounts.")
|
|
live_posts_by_media = fields.Char('Live Posts by Social Media', compute='_compute_live_posts_by_media', readonly=True,
|
|
help="Special technical field that holds a dict containing the live posts names by media ids (used for kanban view).")
|
|
post_method = fields.Selection([
|
|
('now', 'Send now'),
|
|
('scheduled', 'Schedule later')], string="When", default='now', required=True,
|
|
help="Publish your post immediately or schedule it at a later time.")
|
|
scheduled_date = fields.Datetime('Scheduled Date')
|
|
published_date = fields.Datetime('Published Date', readonly=True,
|
|
help="When the global post was published. The actual sub-posts published dates may be different depending on the media.")
|
|
# stored for better calendar view performance
|
|
calendar_date = fields.Datetime('Calendar Date', compute='_compute_calendar_date', store=True, readonly=False)
|
|
# technical field used by the calendar view (hatch the social post)
|
|
is_hatched = fields.Boolean(string="Hatched", compute='_compute_is_hatched')
|
|
#UTM
|
|
utm_campaign_id = fields.Many2one('utm.campaign', domain="[('is_auto_campaign', '=', False)]",
|
|
string="Campaign", ondelete="set null")
|
|
source_id = fields.Many2one(readonly=True)
|
|
# Statistics
|
|
stream_posts_count = fields.Integer("Feed Posts Count", compute='_compute_stream_posts_count')
|
|
engagement = fields.Integer("Engagement", compute='_compute_post_engagement',
|
|
help="Number of people engagements with the post (Likes, comments...)")
|
|
click_count = fields.Integer('Number of clicks', compute="_compute_click_count")
|
|
|
|
|
|
@api.constrains('account_ids')
|
|
def _check_account_ids(self):
|
|
"""All social accounts must be in the same company."""
|
|
for post in self.sudo(): # SUDO to bypass multi-company ACLs
|
|
if not (post.account_ids <= post.account_allowed_ids):
|
|
raise ValidationError(_(
|
|
'Selected accounts (%s) do not match the selected company (%s)',
|
|
','.join((post.account_ids - post.account_allowed_ids).mapped('name')),
|
|
post.company_id.name
|
|
))
|
|
|
|
@api.constrains('state', 'scheduled_date')
|
|
def _check_scheduled_date(self):
|
|
if any(post.state in ('draft', 'scheduled') and post.scheduled_date
|
|
and post.scheduled_date < fields.Datetime.now() for post in self):
|
|
raise ValidationError(_('You cannot schedule a post in the past.'))
|
|
|
|
@api.depends('live_post_ids.engagement')
|
|
def _compute_post_engagement(self):
|
|
results = self.env['social.live.post']._read_group(
|
|
[('post_id', 'in', self.ids)],
|
|
['post_id'],
|
|
['engagement:sum']
|
|
)
|
|
engagement_per_post = {
|
|
post.id: engagement_total
|
|
for post, engagement_total in results
|
|
}
|
|
for post in self:
|
|
post.engagement = engagement_per_post.get(post.id, 0)
|
|
|
|
@api.depends('account_allowed_ids')
|
|
def _compute_has_active_accounts(self):
|
|
for post in self:
|
|
post.has_active_accounts = bool(post.account_allowed_ids)
|
|
|
|
@api.depends('live_post_ids')
|
|
def _compute_stream_posts_count(self):
|
|
for post in self:
|
|
stream_post_domain = post._get_stream_post_domain()
|
|
if stream_post_domain:
|
|
post.stream_posts_count = self.env['social.stream.post'].search_count(
|
|
stream_post_domain)
|
|
else:
|
|
post.stream_posts_count = 0
|
|
|
|
@api.depends('company_id')
|
|
def _compute_account_ids(self):
|
|
super(SocialPost, self)._compute_account_ids()
|
|
|
|
@api.depends('company_id')
|
|
def _compute_account_allowed_ids(self):
|
|
"""Compute the allowed social accounts for this social post.
|
|
|
|
If the company is set on the post, we can attach to it account in the same company
|
|
or without a company. If no company is set on this post, we can attach to it any
|
|
social account.
|
|
"""
|
|
all_account_allowed_ids = self.env['social.account'].search([])
|
|
|
|
for post in self:
|
|
post.account_allowed_ids = all_account_allowed_ids.filtered_domain(post._get_company_domain())
|
|
|
|
@api.depends('live_post_ids.state')
|
|
def _compute_has_post_errors(self):
|
|
for post in self:
|
|
post.has_post_errors = any(live_post.state == 'failed' for live_post in post.live_post_ids)
|
|
|
|
@api.depends('account_ids.media_id')
|
|
def _compute_media_ids(self):
|
|
for post in self:
|
|
post.media_ids = post.with_context(active_test=False).account_ids.mapped('media_id')
|
|
|
|
@api.depends('state', 'post_method', 'scheduled_date', 'published_date')
|
|
def _compute_calendar_date(self):
|
|
for post in self:
|
|
if post.state == 'posted':
|
|
post.calendar_date = post.published_date
|
|
elif post.post_method == 'now':
|
|
post.calendar_date = False
|
|
else:
|
|
post.calendar_date = post.scheduled_date
|
|
|
|
@api.depends('live_post_ids.account_id', 'live_post_ids.display_name')
|
|
def _compute_live_posts_by_media(self):
|
|
""" See field 'help' for more information. """
|
|
for post in self:
|
|
accounts_by_media = {media_id: [] for media_id in post.media_ids.ids}
|
|
for live_post in post.live_post_ids.filtered(lambda lp: lp.account_id.media_id.ids):
|
|
accounts_by_media[live_post.account_id.media_id.id].append(live_post.display_name)
|
|
post.live_posts_by_media = json.dumps(accounts_by_media)
|
|
|
|
@api.depends('state')
|
|
def _compute_is_hatched(self):
|
|
for post in self:
|
|
post.is_hatched = post.state == 'draft'
|
|
|
|
def _compute_click_count(self):
|
|
# Filter by `medium_id` so we can compute the click count based
|
|
# on the current companies (1 account == 1 medium)
|
|
medium_ids = self.account_ids.mapped('utm_medium_id')
|
|
|
|
if not self.source_id.ids or not medium_ids.ids:
|
|
# not "source_id", the records are not yet created
|
|
for post in self:
|
|
post.click_count = 0
|
|
else:
|
|
query = """
|
|
SELECT COUNT(DISTINCT(click.id)) as click_count, link.source_id
|
|
FROM link_tracker_click click
|
|
INNER JOIN link_tracker link ON link.id = click.link_id
|
|
WHERE link.source_id IN %s AND link.medium_id IN %s
|
|
GROUP BY link.source_id
|
|
"""
|
|
|
|
self.env.cr.execute(query, [tuple(self.source_id.ids), tuple(medium_ids.ids)])
|
|
click_data = self.env.cr.dictfetchall()
|
|
mapped_data = {datum['source_id']: datum['click_count'] for datum in click_data}
|
|
for post in self:
|
|
post.click_count = mapped_data.get(post.source_id.id, 0)
|
|
|
|
def _prepare_preview_values(self, media):
|
|
values = super(SocialPost, self)._prepare_preview_values(media)
|
|
if self._name == 'social.post':
|
|
live_posts = self.live_post_ids._filter_by_media_types([media])
|
|
# Take first live post for preview. Should always have at least one.
|
|
values['live_post_link'] = live_posts[0].live_post_link if len(live_posts) >= 1 else False
|
|
return values
|
|
|
|
@api.depends('message', 'state')
|
|
def _compute_display_name(self):
|
|
""" We use the first 20 chars of the message (or "Post" if no message yet).
|
|
We also add "(Draft)" at the end if the post is still in draft state. """
|
|
for post in self:
|
|
post.display_name = self._prepare_post_name(
|
|
post.message,
|
|
state=post.state if post.state == 'draft' else False,
|
|
)
|
|
|
|
@api.model
|
|
def default_get(self, fields):
|
|
""" When created from the calendar view, we set the post as scheduled at the selected date. """
|
|
|
|
result = super(SocialPost, self).default_get(fields)
|
|
default_calendar_date = self.env.context.get('default_calendar_date')
|
|
if default_calendar_date and ('post_method' in fields or 'scheduled_date' in fields):
|
|
result.update({
|
|
'post_method': 'scheduled',
|
|
'scheduled_date': default_calendar_date
|
|
})
|
|
return result
|
|
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
"""Every post will have a unique corresponding utm.source for statistics computation purposes.
|
|
This way, it will be possible to see every leads/quotations generated through a particular post."""
|
|
|
|
# if a scheduled_date / published_date is specified, it should be the one used as the calendar date
|
|
# this is normally handled by the `_compute_calendar_date` but in create mode,
|
|
# it is not called when a default value for the calendar_date field is passed
|
|
# if the post_method is set to 'now' unset the calendar_date to avoid displaying it in the calendar
|
|
for vals in vals_list:
|
|
if vals.get('state') == 'posted' and 'published_date' in vals:
|
|
vals['calendar_date'] = vals['published_date']
|
|
elif vals.get('post_method') == 'now':
|
|
vals['calendar_date'] = False
|
|
elif 'scheduled_date' in vals:
|
|
vals['calendar_date'] = vals['scheduled_date']
|
|
|
|
res = super(SocialPost, self).create(vals_list)
|
|
|
|
cron = self.env.ref('social.ir_cron_post_scheduled')
|
|
cron_trigger_dates = set([
|
|
post.scheduled_date
|
|
for post in res
|
|
if post.scheduled_date
|
|
])
|
|
if cron_trigger_dates:
|
|
cron._trigger(cron_trigger_dates)
|
|
|
|
return res
|
|
|
|
def write(self, vals):
|
|
if vals.get('calendar_date'):
|
|
if any(post.state not in ('draft', 'scheduled') for post in self):
|
|
raise UserError(_("You cannot reschedule a post that has already been posted."))
|
|
|
|
vals['scheduled_date'] = vals['calendar_date']
|
|
|
|
if vals.get('scheduled_date'):
|
|
cron = self.env.ref('social.ir_cron_post_scheduled')
|
|
cron._trigger(at=fields.Datetime.from_string(vals.get('scheduled_date')))
|
|
|
|
return super(SocialPost, self).write(vals)
|
|
|
|
def social_stream_post_action_my(self):
|
|
action = self.env["ir.actions.actions"]._for_xml_id("social.action_social_stream_post")
|
|
action['name'] = _('Feed Posts')
|
|
action['domain'] = self._get_stream_post_domain()
|
|
action['context'] = {
|
|
'search_default_search_my_streams': True,
|
|
'search_default_group_by_stream': True
|
|
}
|
|
return action
|
|
|
|
def _check_post_access(self):
|
|
"""
|
|
Raise an error if the user cannot post on a social media
|
|
"""
|
|
if any(not post.account_ids for post in self):
|
|
raise UserError(_(
|
|
'Please specify at least one account to post into (for post ID(s) %s).',
|
|
', '.join([str(post.id) for post in self if not post.account_ids])
|
|
))
|
|
errors = defaultdict(list)
|
|
for post in self:
|
|
for media in post.media_ids.filtered(lambda media: media.max_post_length and post.message_length > media.max_post_length):
|
|
errors[post].append(_("%s (max %s chars)", media.name, media.max_post_length))
|
|
if bool(errors):
|
|
raise ValidationError(_(
|
|
"Due to length restrictions, the following posts cannot be posted:\n %s",
|
|
"\n".join(["%s : %s" % (post.display_name, ",".join(err)) for post, err in errors.items()])
|
|
))
|
|
|
|
def action_schedule(self):
|
|
self._check_post_access()
|
|
self.write({'state': 'scheduled'})
|
|
|
|
def action_set_draft(self):
|
|
self._check_post_access()
|
|
self.write({'state': 'draft'})
|
|
|
|
def action_post(self):
|
|
self._check_post_access()
|
|
|
|
self.write({
|
|
'post_method': 'now',
|
|
'scheduled_date': False
|
|
})
|
|
|
|
self._action_post()
|
|
|
|
def action_redirect_to_clicks(self):
|
|
action = self.env["ir.actions.actions"]._for_xml_id("link_tracker.link_tracker_action")
|
|
action['domain'] = [
|
|
('source_id', '=', self.source_id.id),
|
|
('medium_id', 'in', self.account_ids.mapped('utm_medium_id').ids),
|
|
]
|
|
return action
|
|
|
|
def _action_post(self):
|
|
""" Called when the post is published on its social.accounts.
|
|
It will create one social.live.post per social.account and call '_post' on each of them. """
|
|
|
|
for post in self:
|
|
post.write({
|
|
'state': 'posting',
|
|
'published_date': fields.Datetime.now(),
|
|
'live_post_ids': [
|
|
(0, 0, live_post)
|
|
for live_post in post._prepare_live_post_values()]
|
|
})
|
|
|
|
if not getattr(threading.current_thread(), 'testing', False):
|
|
# If there's a link in the message, the Facebook / Twitter API will fetch it
|
|
# to build a preview. But when posting, the SQL transaction will not
|
|
# yet be committed, and so the link tracker associated to this link
|
|
# will not yet exist for the Facebook API and the preview will be
|
|
# broken. So we force the compute of the message field and therefor the
|
|
# creation of the link trackers (flush will compute only stored fields).
|
|
self.mapped('live_post_ids.message')
|
|
self.env.cr.commit()
|
|
|
|
for post in self:
|
|
# send the live posts
|
|
failed_posts = self.env['social.live.post']
|
|
for live_post in post.live_post_ids:
|
|
try:
|
|
live_post._post()
|
|
except Exception:
|
|
failed_posts |= live_post
|
|
failed_posts.write({
|
|
'state': 'failed',
|
|
'failure_reason': _('Unknown error')
|
|
})
|
|
|
|
def _prepare_live_post_values(self):
|
|
self.ensure_one()
|
|
|
|
return [{
|
|
'post_id': self.id,
|
|
'account_id': account.id,
|
|
} for account in self.account_ids]
|
|
|
|
@api.model
|
|
def _prepare_post_name(self, message, state=False):
|
|
name = _('Post')
|
|
if message:
|
|
message = message.replace('\n', ' ') # replace carriage returns as needed name is usually a Char
|
|
if len(message) < 24:
|
|
name = message
|
|
else:
|
|
name = message[:20] + '...'
|
|
|
|
if state:
|
|
state_description_values = {elem[0]: elem[1] for elem in self._fields['state']._description_selection(self.env)}
|
|
state_translated = state_description_values.get(state)
|
|
name += f' ({state_translated})'
|
|
|
|
return name
|
|
|
|
def _get_company_domain(self):
|
|
self.ensure_one()
|
|
if self.company_id:
|
|
return ['|', ('company_id', '=', False), ('company_id', '=', self.company_id.id)]
|
|
return ['|', ('company_id', '=', False), ('company_id', 'in', self.env.companies.ids)]
|
|
|
|
def _get_default_accounts_domain(self):
|
|
return self._get_company_domain()
|
|
|
|
|
|
def _get_stream_post_domain(self):
|
|
return []
|
|
|
|
def _check_post_completion(self):
|
|
""" This method will check if all live.posts related to the post are completed ('posted' / 'failed').
|
|
If it's the case, we can mark the post itself as 'posted'. """
|
|
|
|
posts_to_complete = self.filtered(
|
|
lambda post: all(
|
|
live_post.state in ('posted', 'failed')
|
|
for live_post in post.live_post_ids
|
|
)
|
|
)
|
|
|
|
for post in posts_to_complete:
|
|
posts_failed = Markup('<br>').join([
|
|
' - ' + live_post.display_name
|
|
for live_post in post.live_post_ids
|
|
if live_post.state == 'failed'
|
|
])
|
|
|
|
if posts_failed:
|
|
post._message_log(body=_("Message posted partially. These are the ones that couldn't be posted:%s", Markup("<br/>") + posts_failed))
|
|
else:
|
|
post._message_log(body=_("Message posted"))
|
|
|
|
if posts_to_complete:
|
|
posts_to_complete.sudo().write({'state': 'posted'})
|
|
|
|
@api.model
|
|
def _cron_publish_scheduled(self):
|
|
""" Method called by the cron job that searches for social.posts that were scheduled and need
|
|
to be published and calls _action_post() on them."""
|
|
|
|
self.search([
|
|
('post_method', '=', 'scheduled'),
|
|
('state', '=', 'scheduled'),
|
|
('scheduled_date', '<=', fields.Datetime.now())
|
|
])._action_post()
|