Initial commit: Core packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:45 +02:00
commit 12c29a983b
9512 changed files with 8379910 additions and 0 deletions

View file

@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import utm_campaign
from . import utm_medium
from . import utm_mixin
from . import utm_source
from . import utm_stage
from . import utm_tag
from . import ir_http

View file

@ -0,0 +1,26 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
from odoo.http import request, Response
class IrHttp(models.AbstractModel):
_inherit = 'ir.http'
@classmethod
def get_utm_domain_cookies(cls):
return request.httprequest.host
@classmethod
def _set_utm(cls, response):
# Make sure response is an odoo Response.
response = Response.load(response)
domain = cls.get_utm_domain_cookies()
for url_parameter, __, cookie_name in request.env['utm.mixin'].tracking_fields():
if url_parameter in request.params and request.httprequest.cookies.get(cookie_name) != request.params[url_parameter]:
response.set_cookie(cookie_name, request.params[url_parameter], max_age=31 * 24 * 3600, domain=domain, cookie_type='optional')
@classmethod
def _post_dispatch(cls, response):
cls._set_utm(response)
super()._post_dispatch(response)

View file

@ -0,0 +1,59 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, api, SUPERUSER_ID
class UtmCampaign(models.Model):
_name = 'utm.campaign'
_description = 'UTM Campaign'
_rec_name = 'title'
name = fields.Char(string='Campaign Identifier', required=True, compute='_compute_name',
store=True, readonly=False, precompute=True, translate=False)
title = fields.Char(string='Campaign Name', required=True, translate=True)
user_id = fields.Many2one(
'res.users', string='Responsible',
required=True, default=lambda self: self.env.uid)
stage_id = fields.Many2one(
'utm.stage', string='Stage', ondelete='restrict', required=True,
default=lambda self: self.env['utm.stage'].search([], limit=1),
group_expand='_group_expand_stage_ids')
tag_ids = fields.Many2many(
'utm.tag', 'utm_tag_rel',
'tag_id', 'campaign_id', string='Tags')
is_auto_campaign = fields.Boolean(default=False, string="Automatically Generated Campaign", help="Allows us to filter relevant Campaigns")
color = fields.Integer(string='Color Index')
_sql_constraints = [
('unique_name', 'UNIQUE(name)', 'The name must be unique'),
]
@api.depends('title')
def _compute_name(self):
new_names = self.env['utm.mixin'].with_context(
utm_check_skip_record_ids=self.ids
)._get_unique_names(self._name, [c.title for c in self])
for campaign, new_name in zip(self, new_names):
campaign.name = new_name
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get('title') and vals.get('name'):
vals['title'] = vals['name']
new_names = self.env['utm.mixin']._get_unique_names(self._name, [vals.get('name') for vals in vals_list])
for vals, new_name in zip(vals_list, new_names):
if new_name:
vals['name'] = new_name
return super().create(vals_list)
@api.model
def _group_expand_stage_ids(self, stages, domain, order):
"""Read group customization in order to display all the stages in the
Kanban view, even if they are empty.
"""
stage_ids = stages._search([], order=order, access_rights_uid=SUPERUSER_ID)
return stages.browse(stage_ids)

View file

@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, api, fields, models
from odoo.exceptions import UserError
class UtmMedium(models.Model):
_name = 'utm.medium'
_description = 'UTM Medium'
_order = 'name'
name = fields.Char(string='Medium Name', required=True, translate=False)
active = fields.Boolean(default=True)
_sql_constraints = [
('unique_name', 'UNIQUE(name)', 'The name must be unique'),
]
@api.model_create_multi
def create(self, vals_list):
new_names = self.env['utm.mixin']._get_unique_names(self._name, [vals.get('name') for vals in vals_list])
for vals, new_name in zip(vals_list, new_names):
vals['name'] = new_name
return super().create(vals_list)
@api.ondelete(at_uninstall=False)
def _unlink_except_utm_medium_email(self):
utm_medium_email = self.env.ref('utm.utm_medium_email', raise_if_not_found=False)
if utm_medium_email and utm_medium_email in self:
raise UserError(_(
"The UTM medium '%s' cannot be deleted as it is used in some main "
"functional flows, such as the recruitment and the mass mailing.",
utm_medium_email.name
))

View file

@ -0,0 +1,155 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import re
from collections import defaultdict
import itertools
from odoo import api, fields, models
from odoo.http import request
from odoo.osv import expression
class UtmMixin(models.AbstractModel):
""" Mixin class for objects which can be tracked by marketing. """
_name = 'utm.mixin'
_description = 'UTM Mixin'
campaign_id = fields.Many2one('utm.campaign', 'Campaign',
help="This is a name that helps you keep track of your different campaign efforts, e.g. Fall_Drive, Christmas_Special")
source_id = fields.Many2one('utm.source', 'Source',
help="This is the source of the link, e.g. Search Engine, another domain, or name of email list")
medium_id = fields.Many2one('utm.medium', 'Medium',
help="This is the method of delivery, e.g. Postcard, Email, or Banner Ad")
@api.model
def default_get(self, fields):
values = super(UtmMixin, self).default_get(fields)
# We ignore UTM for salesmen, except some requests that could be done as superuser_id to bypass access rights.
if not self.env.is_superuser() and self.env.user.has_group('sales_team.group_sale_salesman'):
return values
for url_param, field_name, cookie_name in self.env['utm.mixin'].tracking_fields():
if field_name in fields:
field = self._fields[field_name]
value = False
if request:
# ir_http dispatch saves the url params in a cookie
value = request.httprequest.cookies.get(cookie_name)
# if we receive a string for a many2one, we search/create the id
if field.type == 'many2one' and isinstance(value, str) and value:
record = self._find_or_create_record(field.comodel_name, value)
value = record.id
if value:
values[field_name] = value
return values
def tracking_fields(self):
# This function cannot be overridden in a model which inherit utm.mixin
# Limitation by the heritage on AbstractModel
# record_crm_lead.tracking_fields() will call tracking_fields() from module utm.mixin (if not overridden on crm.lead)
# instead of the overridden method from utm.mixin.
# To force the call of overridden method, we use self.env['utm.mixin'].tracking_fields() which respects overridden
# methods of utm.mixin, but will ignore overridden method on crm.lead
return [
# ("URL_PARAMETER", "FIELD_NAME_MIXIN", "NAME_IN_COOKIES")
('utm_campaign', 'campaign_id', 'odoo_utm_campaign'),
('utm_source', 'source_id', 'odoo_utm_source'),
('utm_medium', 'medium_id', 'odoo_utm_medium'),
]
def _find_or_create_record(self, model_name, name):
"""Based on the model name and on the name of the record, retrieve the corresponding record or create it."""
Model = self.env[model_name]
record = Model.search([('name', '=', name)], limit=1)
if not record:
# No record found, create a new one
record_values = {'name': name}
if 'is_auto_campaign' in record._fields:
record_values['is_auto_campaign'] = True
record = Model.create(record_values)
return record
@api.model
def _get_unique_names(self, model_name, names):
"""Generate unique names for the given model.
Take a list of names and return for each names, the new names to set
in the same order (with a counter added if needed).
E.G.
The name "test" already exists in database
Input: ['test', 'test [3]', 'bob', 'test', 'test']
Output: ['test [2]', 'test [3]', 'bob', 'test [4]', 'test [5]']
:param model_name: name of the model for which we will generate unique names
:param names: list of names, we will ensure that each name will be unique
:return: a list of new values for each name, in the same order
"""
# Avoid conflicting with itself, otherwise each check at update automatically
# increments counters
skip_record_ids = self.env.context.get("utm_check_skip_record_ids") or []
# Remove potential counter part in each names
names_without_counter = {self._split_name_and_count(name)[0] for name in names}
# Retrieve existing similar names
search_domain = expression.OR([[('name', 'ilike', name)] for name in names_without_counter])
if skip_record_ids:
search_domain = expression.AND([
[('id', 'not in', skip_record_ids)],
search_domain
])
existing_names = {vals['name'] for vals in self.env[model_name].search_read(search_domain, ['name'])}
# Counter for each names, based on the names list given in argument
# and the record names in database
used_counters_per_name = {
name: {
self._split_name_and_count(existing_name)[1]
for existing_name in existing_names
if existing_name == name or existing_name.startswith(f'{name} [')
} for name in names_without_counter
}
# Automatically incrementing counters for each name, will be used
# to fill holes in used_counters_per_name
current_counter_per_name = defaultdict(lambda: itertools.count(1))
result = []
for name in names:
if not name:
result.append(False)
continue
name_without_counter, asked_counter = self._split_name_and_count(name)
existing = used_counters_per_name.get(name_without_counter, set())
if asked_counter and asked_counter not in existing:
count = asked_counter
else:
# keep going until the count is not already used
for count in current_counter_per_name[name_without_counter]:
if count not in existing:
break
existing.add(count)
result.append(f'{name_without_counter} [{count}]' if count > 1 else name_without_counter)
return result
@staticmethod
def _split_name_and_count(name):
"""
Return the name part and the counter based on the given name.
e.g.
"Medium" -> "Medium", 1
"Medium [1234]" -> "Medium", 1234
"""
name = name or ''
name_counter_re = r'(.*)\s+\[([0-9]+)\]'
match = re.match(name_counter_re, name)
if match:
return match.group(1), int(match.group(2) or '1')
return name, 1

View file

@ -0,0 +1,102 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, api, fields, models, tools
class UtmSource(models.Model):
_name = 'utm.source'
_description = 'UTM Source'
name = fields.Char(string='Source Name', required=True)
_sql_constraints = [
('unique_name', 'UNIQUE(name)', 'The name must be unique'),
]
@api.model_create_multi
def create(self, vals_list):
new_names = self.env['utm.mixin']._get_unique_names(self._name, [vals.get('name') for vals in vals_list])
for vals, new_name in zip(vals_list, new_names):
vals['name'] = new_name
return super().create(vals_list)
def _generate_name(self, record, content):
"""Generate the UTM source name based on the content of the source."""
if not content:
return False
content = content.replace('\n', ' ')
if len(content) >= 24:
content = f'{content[:20]}...'
create_date = record.create_date or fields.date.today()
create_date = fields.date.strftime(create_date, tools.DEFAULT_SERVER_DATE_FORMAT)
model_description = self.env['ir.model']._get(record._name).name
return _(
'%(content)s (%(model_description)s created on %(create_date)s)',
content=content, model_description=model_description, create_date=create_date,
)
class UtmSourceMixin(models.AbstractModel):
"""Mixin responsible of generating the name of the source based on the content
(field defined by _rec_name) of the record (mailing, social post,...).
"""
_name = 'utm.source.mixin'
_description = 'UTM Source Mixin'
name = fields.Char('Name', related='source_id.name', readonly=False)
source_id = fields.Many2one('utm.source', string='Source', required=True, ondelete='restrict', copy=False)
@api.model
def default_get(self, fields_list):
# Exclude 'name' from fields_list to avoid retrieving it from context.
return super().default_get([field for field in fields_list if field != "name"])
@api.model_create_multi
def create(self, vals_list):
"""Create the UTM sources if necessary, generate the name based on the content in batch."""
# Create all required <utm.source>
utm_sources = self.env['utm.source'].create([
{
'name': values.get('name')
or self.env.context.get('default_name')
or self.env['utm.source']._generate_name(self, values.get(self._rec_name)),
}
for values in vals_list
if not values.get('source_id')
])
# Update "vals_list" to add the ID of the newly created source
vals_list_missing_source = [values for values in vals_list if not values.get('source_id')]
for values, source in zip(vals_list_missing_source, utm_sources):
values['source_id'] = source.id
for values in vals_list:
if 'name' in values:
del values['name']
return super().create(vals_list)
def write(self, values):
if (values.get(self._rec_name) or values.get('name')) and len(self) > 1:
raise ValueError(
_('You cannot update multiple records with the same name. The name should be unique!')
)
if values.get(self._rec_name) and not values.get('name'):
values['name'] = self.env['utm.source']._generate_name(self, values[self._rec_name])
if values.get('name'):
values['name'] = self.env['utm.mixin'].with_context(
utm_check_skip_record_ids=self.source_id.ids
)._get_unique_names("utm.source", [values['name']])[0]
super().write(values)
def copy(self, default=None):
"""Increment the counter when duplicating the source."""
default = default or {}
default['name'] = self.env['utm.mixin']._get_unique_names("utm.source", [self.name])[0]
return super().copy(default)

View file

@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class UtmStage(models.Model):
"""Stage for utm campaigns."""
_name = 'utm.stage'
_description = 'Campaign Stage'
_order = 'sequence'
name = fields.Char(required=True, translate=True)
sequence = fields.Integer(default=1)

View file

@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from random import randint
from odoo import fields, models
class UtmTag(models.Model):
"""Model of categories of utm campaigns, i.e. marketing, newsletter, ..."""
_name = 'utm.tag'
_description = 'UTM Tag'
_order = 'name'
def _default_color(self):
return randint(1, 11)
name = fields.Char(required=True, translate=True)
color = fields.Integer(
string='Color Index', default=lambda self: self._default_color(),
help='Tag color. No color means no display in kanban to distinguish internal tags from public categorization tags.')
_sql_constraints = [
('name_uniq', 'unique (name)', "Tag name already exists !"),
]