Public release from ruodoo-project: 19.0 - 2026-05-10 21:19:01 UTC

This commit is contained in:
CI Publish Bot
2026-05-10 21:19:11 +00:00
commit cbf9e6e6d6
1213 changed files with 183945 additions and 0 deletions

View File

@ -0,0 +1,2 @@
from . import models
from . import wizard

View File

@ -0,0 +1,25 @@
{
"name": "Премиум (витрина сервисов)",
"summary": "Канбан карточек сервисов + визард заказа с отправкой в Project",
"version": "19.0.1.0.0",
"author": "Your Company",
"website": "https://example.com",
"license": "OEEL-1",
"category": "Sales/CRM",
"depends": ["base", "web"],
"data": [
"security/ir.model.access.csv",
"data/system_parameters.xml", # Добавить эту строку
"views/premium_menus.xml",
"views/premium_service_views.xml",
"views/premium_order_wizard_views.xml",
"views/res_config_settings_view.xml",
"data/premium_service_data.xml",
],
"assets": {},
"demo": [
"demo/demo.xml",
],
"installable": True,
"application": True,
}

View File

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo noupdate="1">
<!-- Примеры карточек -->
<record id="premium_service_demo_1" model="premium.service">
<field name="name">Интеграция 1С</field>
<field name="category">services</field>
<field name="short_description">Обмен данными с 1С:Предприятие</field>
<field name="description"><![CDATA[<p>Надёжный двусторонний обмен с 1С.</p>]]></field>
<field name="author_url">https://example.com/1c</field>
<field name="sequence">10</field>
</record>
<record id="premium_service_demo_2" model="premium.service">
<field name="name">Эквайринг в Odoo</field>
<field name="category">bank</field>
<field name="short_description">Приём оплат и фискализация</field>
<field name="description"><![CDATA[<p>Поддержка популярных банков и касс.</p>]]></field>
<field name="author_url">https://example.com/acquiring</field>
<field name="sequence">20</field>
</record>
<record id="premium_service_demo_3" model="premium.service">
<field name="name">WMS модуль</field>
<field name="category">wms</field>
<field name="short_description">Склад, адресное хранение, ТСД</field>
<field name="description"><![CDATA[<p>Управление складом, маршрутизация, ЧЗ.</p>]]></field>
<field name="author_url">https://example.com/wms</field>
<field name="sequence">30</field>
</record>
<record id="premium_service_demo_4" model="premium.service">
<field name="name">Интеграция с маркетплейсами</field>
<field name="category">marketplace</field>
<field name="short_description">Ozon, Wildberries, Yandex</field>
<field name="description"><![CDATA[<p>Синхронизация заказов и остатков.</p>]]></field>
<field name="author_url">https://example.com/mp</field>
<field name="sequence">40</field>
</record>
<record id="premium_service_demo_5" model="premium.service">
<field name="name">Отраслевые решения</field>
<field name="category">industry</field>
<field name="short_description">Готовые шаблоны для отраслей</field>
<field name="description"><![CDATA[<p>Комплекты модулей под отрасли.</p>]]></field>
<field name="author_url">https://example.com/verticals</field>
<field name="sequence">50</field>
</record>
<record id="premium_service_demo_6" model="premium.service">
<field name="name">Прочие доработки</field>
<field name="category">other</field>
<field name="short_description">Эксклюзивные интеграции и UI</field>
<field name="description"><![CDATA[<p>Индивидуальные проекты.</p>]]></field>
<field name="author_url">https://example.com/custom</field>
<field name="sequence">60</field>
</record>
</odoo>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo noupdate="1">
<!-- Автоматические настройки при установке -->
<record id="premium_project_api_url_param" model="ir.config_parameter">
<field name="key">premium.project_api_url</field>
<field name="value">http://localhost:8069/newlead/</field>
</record>
<record id="premium_project_api_token_param" model="ir.config_parameter">
<field name="key">premium.project_api_token</field>
<field name="value">default-premium-token-2024</field>
</record>
</odoo>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Карточки сервисов уже созданы в data/premium_service_data.xml -->
<!-- Демо данные для premium_client не требуются -->
</odoo>

View File

@ -0,0 +1,2 @@
from . import premium_service
from . import res_config_settings

View File

@ -0,0 +1,75 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models, _
from odoo.exceptions import AccessError
class PremiumService(models.Model):
_name = "premium.service"
_description = "Премиум: сервис/продукт"
_order = "sequence, name"
name = fields.Char(string="Название", required=True, translate=True)
sequence = fields.Integer(default=10)
category = fields.Selection(
selection=[
("services", "Услуги"),
("bank", "Банки, кассы, ЭДО"),
("wms", "WMS, логистика и ЧЗ"),
("marketplace", "Маркетплейсы"),
("industry", "Отраслевые"),
("other", "Другое"),
],
string="Категория",
required=True,
default="services",
translate=False,
)
short_description = fields.Text(string="Краткое описание", translate=True)
description = fields.Html(string="Описание", sanitize=True, translate=True)
author_url = fields.Char(string="Автор (URL)")
image = fields.Image(string="Изображение", max_width=1024, max_height=768)
def write(self, vals):
# Проверяем, является ли пользователь администратором
if not self.env.user.has_group('base.group_system'):
# Если не администратор, запрещаем запись
raise AccessError("Только администратор может редактировать карточки сервисов.")
return super(PremiumService, self).write(vals)
@api.model
def create(self, vals):
# Проверяем, является ли пользователь администратором
if not self.env.user.has_group('base.group_system'):
# Если не администратор, запрещаем создание
raise AccessError("Только администратор может создавать карточки сервисов.")
return super(PremiumService, self).create(vals)
def unlink(self):
# Проверяем, является ли пользователь администратором
if not self.env.user.has_group('base.group_system'):
# Если не администратор, запрещаем удаление
raise AccessError("Только администратор может удалять карточки сервисов.")
return super(PremiumService, self).unlink()
def action_open_form(self):
self.ensure_one()
return {
"type": "ir.actions.act_window",
"res_model": self._name,
"res_id": self.id,
"view_mode": "form",
"target": "current",
}
def action_open_order_wizard(self):
self.ensure_one()
return {
"name": _("Заказ сервиса"),
"type": "ir.actions.act_window",
"res_model": "premium.order.wizard",
"view_mode": "form",
"target": "new",
"context": {"default_service_id": self.id},
}

View File

@ -0,0 +1,17 @@
# -*- coding: utf-8 -*-
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
_inherit = "res.config.settings"
premium_project_api_url = fields.Char(
string="Project API URL",
config_parameter="premium.project_api_url",
help="Базовый URL инстанса Project, например https://project.example.com/newlead/ или только домен (маршрут добавится автоматически)",
)
premium_project_api_token = fields.Char(
string="Project API token",
config_parameter="premium.project_api_token",
help="Токен, который проверяет Project (в заголовке X-API-Key)",
)

View File

@ -0,0 +1,4 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_premium_service_user,access.premium.service.user,model_premium_service,base.group_user,1,0,0,0
access_premium_service_manager,access.premium.service.manager,model_premium_service,base.group_system,1,1,1,1
access_premium_order_wizard_user,access.premium.order.wizard.user,model_premium_order_wizard,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_premium_service_user access.premium.service.user model_premium_service base.group_user 1 0 0 0
3 access_premium_service_manager access.premium.service.manager model_premium_service base.group_system 1 1 1 1
4 access_premium_order_wizard_user access.premium.order.wizard.user model_premium_order_wizard base.group_user 1 1 1 1

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- Верхнеуровневое меню. Ставим sequence=20, чтобы идти вторым после "Общение" -->
<menuitem id="menu_premium_root" name="Премиум" sequence="20"/>
<record id="action_premium_services" model="ir.actions.act_window">
<field name="name">Премиум</field>
<field name="res_model">premium.service</field>
<field name="view_mode">kanban,list,form</field>
<field name="context">{'search_default_group_by_category': 1}</field>
<field name="help">Создайте или откройте карточку сервиса</field>
</record>
<menuitem id="menu_premium_services" name="Сервисы" parent="menu_premium_root" action="action_premium_services" sequence="1"/>
</odoo>

View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="premium_order_wizard_view_form" model="ir.ui.view">
<field name="name">premium.order.wizard.form</field>
<field name="model">premium.order.wizard</field>
<field name="arch" type="xml">
<form string="Заявка на сервис">
<group>
<field name="service_id" invisible="1"/>
<field name="company_name" required="1"/>
<field name="inn"/>
<field name="email" required="1"/>
<field name="phone_telegram"/>
<field name="contact_person"/>
</group>
<footer>
<button name="action_submit" type="object" string="Отправить" class="btn-primary"/>
<button string="Отмена" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
</odoo>

View File

@ -0,0 +1,113 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- Поиск с группировкой -->
<record id="premium_service_view_search" model="ir.ui.view">
<field name="name">premium.service.search</field>
<field name="model">premium.service</field>
<field name="arch" type="xml">
<search string="Поиск сервисов">
<filter name="group_by_category" string="Категория" context="{'group_by': 'category'}"/>
<field name="name"/>
<field name="category"/>
</search>
</field>
</record>
<!-- Вид списка (list), а не tree -->
<record id="premium_service_view_tree" model="ir.ui.view">
<field name="name">premium.service.tree</field>
<field name="model">premium.service</field>
<field name="arch" type="xml">
<list>
<field name="sequence"/>
<field name="name"/>
<field name="category"/>
</list>
</field>
</record>
<record id="premium_service_view_kanban" model="ir.ui.view">
<field name="name">premium.service.kanban</field>
<field name="model">premium.service</field>
<field name="arch" type="xml">
<kanban default_group_by="category">
<templates>
<t t-name="card">
<div class="oe_kanban_global_click o_kanban_record">
<!-- Заголовок по центру -->
<div class="text-center mb-2">
<strong class="o_kanban_title">
<field name="name"/>
</strong>
</div>
<!-- Изображение слева, текст справа с большим отступом -->
<div class="d-flex align-items-center mb-2">
<field name="image"
widget="image"
options="{'size': [80, 80]}"
class="mr-6" /> <!-- mr-6 = 4rem = 64px → очень большой отступ -->
<div>
<div class="text-muted o_kanban_body">
<field name="short_description"/>
</div>
</div>
</div>
<!-- Кнопка "Подробнее" -->
<div class="o_kanban_buttons mt-2">
<button type="object" name="action_open_form" class="btn btn-primary btn-sm">
<span>Подробнее</span>
</button>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<!-- Единая форма (контроль прав будет в Python) -->
<record id="premium_service_view_form" model="ir.ui.view">
<field name="name">premium.service.form</field>
<field name="model">premium.service</field>
<field name="arch" type="xml">
<form string="Сервис">
<sheet>
<!-- Заголовок -->
<div class="oe_title">
<h1><field name="name"/></h1>
</div>
<!-- Изображение слева, данные справа -->
<div class="row mt-3">
<div class="col-md-4">
<field name="image" widget="image" class="w-100"/>
</div>
<div class="col-md-8">
<group>
<field name="category"/>
<field name="author_url" widget="url"/>
</group>
<notebook>
<page string="Описание">
<field name="description" widget="html"/>
</page>
</notebook>
</div>
</div>
<!-- Кнопка "Заказать" справа под описанием - увеличенная зеленая -->
<div class="mt-4 d-flex justify-content-end">
<button name="action_open_order_wizard"
type="object"
class="btn btn-success btn-lg"
string="Заказать"
style="padding: 20px 50px; font-size: 22px; font-weight: bold; border-radius: 10px;"/>
</div>
</sheet>
</form>
</field>
</record>
</odoo>

View File

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="res_config_settings_view_form_premium" model="ir.ui.view">
<field name="name">res.config.settings.view.form.premium</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="base_setup.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//block[@name='integration']" position="inside">
<setting string="Премиум" help="URL обслуживающего партнера" id="premium_integration_setting">
<div class="content-group" id="premium_settings">
<div class="mt16 row">
<label for="premium_project_api_url" string="API URL" class="col-3 o_light_label"/>
<field name="premium_project_api_url" placeholder="API URL" class="w-100"/>
</div>
<div class="mt16 row">
<label for="premium_project_api_token" string="API Token" class="col-3 o_light_label"/>
<field name="premium_project_api_token" password="True" class="w-100"/>
</div>
</div>
</setting>
</xpath>
</field>
</record>
</odoo>

View File

@ -0,0 +1 @@
from . import premium_order_wizard

View File

@ -0,0 +1,174 @@
# -*- coding: utf-8 -*-
import json
import logging
import re
import time
from odoo import api, fields, models, _
from odoo.exceptions import UserError, ValidationError
_logger = logging.getLogger(__name__)
try:
import requests
except Exception: # pragma: no cover
requests = None
class PremiumOrderWizard(models.TransientModel):
_name = "premium.order.wizard"
_description = "Премиум: заявка на сервис"
service_id = fields.Many2one("premium.service", string="Сервис", required=True)
company_name = fields.Char(string="Название компании", required=True)
inn = fields.Char(string="ИНН") # будет браться из VAT
email = fields.Char(string="Email", required=True)
phone_telegram = fields.Char(string="Телефон/Телеграм")
contact_person = fields.Char(string="Контактное лицо")
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
company = self.env.company
res.setdefault("company_name", company.name)
res.setdefault("inn", company.vat)
res.setdefault("email", company.email or self.env.user.email)
res.setdefault("phone_telegram", company.phone or company.mobile)
res.setdefault("contact_person", self.env.user.name)
_logger.info("Premium order wizard default values: %s", res)
return res
@api.constrains("email")
def _check_email(self):
email_regex = r"^[\w\.-]+@[\w\.-]+\.\w+$"
for rec in self:
if rec.email and not re.match(email_regex, rec.email):
raise ValidationError(_("Некорректный формат email."))
@api.constrains("inn")
def _check_inn(self):
for rec in self:
if rec.inn and (not rec.inn.isdigit() or len(rec.inn) not in (10, 12)):
raise ValidationError(_("ИНН должен содержать 10 или 12 цифр."))
@api.onchange("email")
def _onchange_email(self):
if self.email and not re.match(r"^[\w\.-]+@[\w\.-]+\.\w+$", self.email):
return {
"warning": {
"title": _("Предупреждение"),
"message": _("Некорректный формат email."),
}
}
@api.onchange("inn")
def _onchange_inn(self):
if self.inn and (not self.inn.isdigit() or len(self.inn) not in (10, 12)):
return {
"warning": {
"title": _("Предупреждение"),
"message": _("ИНН должен содержать 10 или 12 цифр."),
}
}
def _build_payload(self):
self.ensure_one()
service = self.service_id
payload = {
"service_id": service.id,
"service_name": service.name,
"service_category": service.category,
"service_author_url": service.author_url,
"service_description": (service.description or ""),
"company_name": self.company_name,
"inn": self.inn,
"email": self.email,
"phone": self.phone_telegram,
"contact_name": self.contact_person,
"source_db": self.env.cr.dbname,
}
_logger.info("Premium order payload built: %s", payload)
return payload
def action_submit(self):
self.ensure_one()
if not self.email or not self.company_name:
raise UserError(_("Заполните обязательные поля: Email и Название компании."))
if requests is None:
raise UserError(_("Библиотека requests недоступна на сервере Odoo."))
icp = self.env["ir.config_parameter"].sudo()
base_url = (icp.get_param("premium.project_api_url") or "").strip()
token = (icp.get_param("premium.project_api_token") or "").strip()
_logger.info("Premium API configuration - URL: %s, Token present: %s", base_url, bool(token))
if not base_url:
raise UserError(
_("Не настроен адрес Project API (Settings → Technical → Parameters → System Parameters).")
)
if base_url.endswith("/newlead/"):
url = base_url
else:
url = base_url.rstrip("/") + "/newlead/"
_logger.info("Premium API final URL: %s", url)
headers = {"Content-Type": "application/json"}
if token:
headers["X-API-Key"] = token
payload = self._build_payload()
_logger.info("Attempting to send payload to %s: %s", url, payload)
retries = 3
for attempt in range(1, retries + 1):
try:
_logger.info("Premium order attempt %s of %s", attempt, retries)
resp = requests.post(url, json=payload, headers=headers, timeout=15)
_logger.info("Response status: %s, headers: %s", resp.status_code, dict(resp.headers))
ok = resp.status_code in (200, 201)
try:
body = resp.json()
_logger.info("Response body: %s", body)
except Exception as e:
body = {}
_logger.warning("Failed to parse response JSON: %s", e)
_logger.info("Response text: %s", resp.text)
if ok and (body.get("ok") is True):
_logger.info("Premium order successful, lead_id: %s", body.get("lead_id"))
return {
"type": "ir.actions.client",
"tag": "display_notification",
"params": {
"title": _("Заявка успешно отправлена"),
"type": "success",
"sticky": False,
},
}
else:
_logger.error("Premium order failed: %s %s", resp.status_code, body)
if attempt == retries:
raise UserError(
_("Сервис временно недоступен, пожалуйста, отправьте заявку на info@inf-centre.ru")
)
except requests.exceptions.ConnectionError as e:
_logger.warning("Connection error on attempt %s: %s", attempt, e)
if attempt == retries:
_logger.exception("Premium order connection exception: %s", e)
raise UserError(
_("Не удалось подключиться к серверу Project. Проверьте настройки подключения или отправьте заявку на info@inf-centre.ru")
)
time.sleep(3)
except Exception as e:
_logger.warning("Attempt %s failed: %s", attempt, e)
if attempt == retries:
_logger.exception("Premium order exception: %s", e)
raise UserError(
_("Сервис временно недоступен, пожалуйста, отправьте заявку на info@inf-centre.ru")
)
time.sleep(3)