Public release from ruodoo-project: 19.0 - 2026-07-26 21:17:35 UTC
This commit is contained in:
70
premium_client/README.md
Normal file
70
premium_client/README.md
Normal file
@ -0,0 +1,70 @@
|
||||
# Премиум (витрина сервисов)
|
||||
**name:** premium_client
|
||||
**version:** 19.0.1.0.0
|
||||
**author:** MK.Lab
|
||||
|
||||
### 1. Общее описание
|
||||
|
||||
Модуль добавляет в Odoo витрину дополнительных сервисов и интеграций — каталог в виде канбан-карточек, сгруппированных по категориям. Пользователь может ознакомиться с описанием каждого сервиса и оставить заявку на подключение прямо из интерфейса.
|
||||
|
||||
### 2. Установка
|
||||
|
||||
1. Скопируйте папку `premium_client` в директорию `addons`
|
||||
|
||||
2. Перезапустите сервер Odoo
|
||||
|
||||
3. Обновите список приложений: **Настройки → Приложения → Обновить список**
|
||||
|
||||
4. Найдите модуль **«Премиум»** и установите
|
||||
|
||||
5. Настройте адрес и токен API: **Настройки → раздел Премиум**
|
||||
|
||||

|
||||
|
||||
### 3. Функциональность
|
||||
|
||||
3.1 Категории сервисов:
|
||||
|
||||
| Категория | Описание |
|
||||
|---|---|
|
||||
| Услуги | Интеграции и доработки |
|
||||
| Банки, кассы, ЭДО | Эквайринг, электронный документооборот |
|
||||
| WMS, логистика и ЧЗ | Управление складом, маршрутизация, честный знак |
|
||||
| Маркетплейсы | Интеграции с Ozon, Wildberries, Яндекс |
|
||||
| Отраслевые | Готовые шаблоны для конкретных отраслей |
|
||||
| Другое | Эксклюзивные интеграции и доработки |
|
||||
|
||||
3.2 Форма заявки:
|
||||
|
||||
При нажатии кнопки **«Заказать»** открывается форма с автоматически заполненными данными из профиля компании:
|
||||
|
||||
| Поле | Откуда берётся |
|
||||
|---|---|
|
||||
| Название компании | Из текущей компании Odoo |
|
||||
| ИНН | Из поля ИНН/НДС компании |
|
||||
| Email | Email компании или текущего пользователя |
|
||||
| Телефон/Телеграм | Телефон компании |
|
||||
| Контактное лицо | Имя текущего пользователя |
|
||||
|
||||
После отправки заявка уходит во внешнюю систему Project через API.
|
||||
|
||||
## Как это выглядит в интерфейсе
|
||||
|
||||
В главном меню появляется раздел **«Премиум»**. Главная страница — канбан с карточками сервисов, сгруппированных по категориям.
|
||||
|
||||

|
||||
|
||||
### Пример использования
|
||||
|
||||
### Открыть карточку сервиса
|
||||
|
||||
Нажмите кнопку **«Подробнее»** на нужной карточке. Откроется страница с полным описанием сервиса.
|
||||
|
||||

|
||||
|
||||
### Оставить заявку
|
||||
|
||||
Нажмите кнопку **«Заказать»** на странице сервиса. Откроется форма с данными вашей компании — проверьте и при необходимости скорректируйте. Нажмите **«Отправить»**.
|
||||
|
||||

|
||||

|
||||
160
premium_client/TECHNICAL.md
Normal file
160
premium_client/TECHNICAL.md
Normal file
@ -0,0 +1,160 @@
|
||||
# Премиум (витрина сервисов)
|
||||
**name:** premium_client
|
||||
**version:** 19.0.1.0.0
|
||||
**author:** MK.Lab
|
||||
**depends:** `base`, `web`
|
||||
|
||||
## Архитектура
|
||||
|
||||
Модуль состоит из двух моделей и минимального набора представлений. Не содержит внешних зависимостей, кроме стандартной библиотеки `requests`.
|
||||
|
||||
```
|
||||
premium_client
|
||||
├── premium.service — карточка сервиса (каталог)
|
||||
├── premium.order.wizard — форма заявки (временная, удаляется после отправки)
|
||||
└── res.config.settings — расширение: поля настроек API
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Модели
|
||||
|
||||
### `premium.service` — Карточка сервиса
|
||||
|
||||
**Файл:** `models/premium_service.py`
|
||||
|
||||
Хранит информацию об одном сервисе в витрине. Сортируется по полю `sequence`, затем по имени.
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|---|---|---|
|
||||
| `name` | Char (required, translate) | Название сервиса |
|
||||
| `sequence` | Integer (default: 10) | Порядок отображения в канбане |
|
||||
| `category` | Selection (required) | Категория: `services`, `bank`, `wms`, `marketplace`, `industry`, `other` |
|
||||
| `short_description` | Text (translate) | Краткий текст на карточке канбана |
|
||||
| `description` | Html (sanitize, translate) | Полное описание на странице сервиса |
|
||||
| `author_url` | Char | Ссылка на автора или страницу сервиса |
|
||||
| `image` | Image (max 1024×768) | Изображение сервиса |
|
||||
|
||||
#### Права доступа
|
||||
|
||||
Создание, изменение и удаление карточек доступно **только администраторам** (`base.group_system`). Обычные пользователи могут только просматривать. Проверка выполняется в переопределённых методах `create()`, `write()` и `unlink()` — при попытке нарушить правило срабатывает ошибка `AccessError`.
|
||||
|
||||
#### Методы
|
||||
|
||||
**`action_open_form()`** — открывает форму карточки сервиса. Вызывается по кнопке «Подробнее» в канбане.
|
||||
|
||||
**`action_open_order_wizard()`** — открывает визард заявки в диалоговом окне. В контекст передаётся `default_service_id`, чтобы визард знал какой сервис заказывается.
|
||||
|
||||
---
|
||||
|
||||
### `premium.order.wizard` — Форма заявки
|
||||
|
||||
**Файл:** `wizard/premium_order_wizard.py`
|
||||
|
||||
Временная модель (`TransientModel`) — запись существует только пока пользователь заполняет форму и удаляется после отправки. Предзаполняется данными текущей компании и пользователя.
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|---|---|---|
|
||||
| `service_id` | Many2one → `premium.service` (required) | Сервис, на который подаётся заявка |
|
||||
| `company_name` | Char (required) | Название компании |
|
||||
| `inn` | Char | ИНН (10 или 12 цифр) |
|
||||
| `email` | Char (required) | Email для связи |
|
||||
| `phone_telegram` | Char | Телефон или Telegram |
|
||||
| `contact_person` | Char | Имя контактного лица |
|
||||
|
||||
#### Предзаполнение (`default_get`)
|
||||
|
||||
При открытии визарда поля заполняются автоматически:
|
||||
- `company_name` ← `env.company.name`
|
||||
- `inn` ← `env.company.vat`
|
||||
- `email` ← `env.company.email` или `env.user.email`
|
||||
- `phone_telegram` ← `env.company.phone` или `env.company.mobile`
|
||||
- `contact_person` ← `env.user.name`
|
||||
|
||||
#### Валидация
|
||||
|
||||
Проверки выполняются как при изменении поля (`@api.onchange` — показывает предупреждение), так и при сохранении (`@api.constrains` — блокирует отправку):
|
||||
|
||||
| Поле | Правило |
|
||||
|---|---|
|
||||
| `email` | Соответствие шаблону `*@*.*` |
|
||||
| `inn` | Только цифры, длина 10 или 12 символов |
|
||||
|
||||
#### Метод `action_submit()` — отправка заявки
|
||||
|
||||
Выполняет POST-запрос во внешний API. Последовательность шагов:
|
||||
|
||||
1. Проверяет заполненность обязательных полей
|
||||
2. Читает URL и токен из системных параметров (`premium.project_api_url`, `premium.project_api_token`)
|
||||
3. Если URL не оканчивается на `/newlead/` — добавляет этот суффикс автоматически
|
||||
4. Формирует заголовки: `Content-Type: application/json` и `X-API-Key: {токен}` (если токен задан)
|
||||
5. Отправляет запрос с повторными попытками — **3 попытки** с паузой 3 секунды между ними
|
||||
6. Считает успехом ответ со статусом 200 или 201 и полем `"ok": true` в теле
|
||||
7. При неудаче всех трёх попыток показывает сообщение с просьбой написать на `info@inf-centre.ru`
|
||||
|
||||
**Состав отправляемых данных (`_build_payload`):**
|
||||
|
||||
| Поле | Источник |
|
||||
|---|---|
|
||||
| `service_id`, `service_name` | Карточка сервиса |
|
||||
| `service_category`, `service_author_url` | Карточка сервиса |
|
||||
| `service_description` | HTML-описание сервиса |
|
||||
| `company_name`, `inn`, `email` | Форма заявки |
|
||||
| `phone`, `contact_name` | Форма заявки |
|
||||
| `source_db` | Техническое имя базы данных Odoo |
|
||||
|
||||
---
|
||||
|
||||
### `res.config.settings` — Расширение настроек
|
||||
|
||||
**Файл:** `models/res_config_settings.py`
|
||||
|
||||
Добавляет два поля в стандартную форму Настроек Odoo. Значения хранятся как системные параметры (`ir.config_parameter`).
|
||||
|
||||
| Поле | Параметр | Описание |
|
||||
|---|---|---|
|
||||
| `premium_project_api_url` | `premium.project_api_url` | URL внешнего API для приёма заявок |
|
||||
| `premium_project_api_token` | `premium.project_api_token` | Токен авторизации (передаётся в заголовке `X-API-Key`) |
|
||||
|
||||
При установке модуля оба параметра создаются с заглушечными значениями (`noupdate="1"`) — их нужно заменить реальными в Настройках.
|
||||
|
||||
---
|
||||
|
||||
## Представления и меню
|
||||
|
||||
### Структура меню
|
||||
|
||||
```
|
||||
Премиум (sequence=20)
|
||||
└── Сервисы → action_premium_services
|
||||
Режимы: kanban (по умолчанию), list, form
|
||||
Группировка по умолчанию: category
|
||||
```
|
||||
|
||||
### Канбан-карточка
|
||||
|
||||
Каждая карточка показывает: название (по центру), изображение (слева), краткое описание (справа), кнопку «Подробнее». При клике на карточку открывается форма.
|
||||
|
||||
### Форма сервиса
|
||||
|
||||
Содержит: название, изображение, категорию, ссылку на автора, HTML-описание на вкладке, кнопку **«Заказать»**.
|
||||
|
||||
### Форма заявки (визард)
|
||||
|
||||
Диалоговое окно с полями компании. Кнопки: **«Отправить»** (primary) и **«Отмена»**.
|
||||
|
||||
---
|
||||
|
||||
## Предустановленные данные
|
||||
|
||||
**Файл:** `data/premium_service_data.xml` (`noupdate="1"`)
|
||||
|
||||
При установке создаются 6 карточек-примеров по одной на каждую категорию: Интеграция 1С, Эквайринг в Odoo, WMS модуль, Интеграции с маркетплейсами, Отраслевые решения, Прочие доработки. Карточки не перезаписываются при обновлении модуля.
|
||||
|
||||
---
|
||||
|
||||
## Безопасность
|
||||
|
||||
**Файл:** `security/ir.model.access.csv`
|
||||
|
||||
Все авторизованные пользователи (`base.group_user`) имеют право на чтение карточек сервисов и работу с визардом. Создание, редактирование и удаление карточек ограничено только для группы `base.group_system`.
|
||||
2
premium_client/__init__.py
Normal file
2
premium_client/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from . import models
|
||||
from . import wizard
|
||||
25
premium_client/__manifest__.py
Normal file
25
premium_client/__manifest__.py
Normal 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,
|
||||
}
|
||||
52
premium_client/data/premium_service_data.xml
Normal file
52
premium_client/data/premium_service_data.xml
Normal 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>
|
||||
13
premium_client/data/system_parameters.xml
Normal file
13
premium_client/data/system_parameters.xml
Normal 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>
|
||||
5
premium_client/demo/demo.xml
Normal file
5
premium_client/demo/demo.xml
Normal file
@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Карточки сервисов уже созданы в data/premium_service_data.xml -->
|
||||
<!-- Демо данные для premium_client не требуются -->
|
||||
</odoo>
|
||||
BIN
premium_client/img_1.png
Normal file
BIN
premium_client/img_1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
BIN
premium_client/img_2.png
Normal file
BIN
premium_client/img_2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 200 KiB |
BIN
premium_client/img_3.png
Normal file
BIN
premium_client/img_3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 144 KiB |
BIN
premium_client/img_4.png
Normal file
BIN
premium_client/img_4.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 171 KiB |
BIN
premium_client/img_5.png
Normal file
BIN
premium_client/img_5.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
2
premium_client/models/__init__.py
Normal file
2
premium_client/models/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from . import premium_service
|
||||
from . import res_config_settings
|
||||
75
premium_client/models/premium_service.py
Normal file
75
premium_client/models/premium_service.py
Normal 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},
|
||||
}
|
||||
17
premium_client/models/res_config_settings.py
Normal file
17
premium_client/models/res_config_settings.py
Normal 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)",
|
||||
)
|
||||
4
premium_client/security/ir.model.access.csv
Normal file
4
premium_client/security/ir.model.access.csv
Normal 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
|
||||
|
15
premium_client/views/premium_menus.xml
Normal file
15
premium_client/views/premium_menus.xml
Normal 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>
|
||||
23
premium_client/views/premium_order_wizard_views.xml
Normal file
23
premium_client/views/premium_order_wizard_views.xml
Normal 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>
|
||||
113
premium_client/views/premium_service_views.xml
Normal file
113
premium_client/views/premium_service_views.xml
Normal 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>
|
||||
25
premium_client/views/res_config_settings_view.xml
Normal file
25
premium_client/views/res_config_settings_view.xml
Normal 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>
|
||||
|
||||
1
premium_client/wizard/__init__.py
Normal file
1
premium_client/wizard/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from . import premium_order_wizard
|
||||
174
premium_client/wizard/premium_order_wizard.py
Normal file
174
premium_client/wizard/premium_order_wizard.py
Normal 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)
|
||||
Reference in New Issue
Block a user