В этой статье мы рассмотрим поэтапное создание одного вэб-проекта, начиная от 1С и заканчивая этой же 1С.
Вот уже сколько лет наблюдаю, как наши менеджеры предоставляют клиентам информацию о нашим товарах в виде excel-файлов. Огромные, почти по 8000 позиций, файлы с картинками. Сколько в 1С было написано вариантов сохранения этих прайсов... только мной - 2 версии. Постоянные проблемы у кого-то из клиентов в стиле "не открывается, не сохраняется, не получили письмо", поиски путей засунуть 8000 строк и картинок в файл так, чтобы они на выходе весили не больше 15 Мб. Разрастание почты каждого менеджера по 1 гигабайту в день. А как представляю себе этого несчастного покупателя-оптовика с той стороны, который делает заказ в 100 позиций разного товара листая длинную простыню из номенклатуры. Ни фильтров, ни поиска нормального, ничего. Сплошные страдания и плохой имидж для нашей компании - не способны продавать "удобно", "деревня" и прочее.
Попытки создания оптового онлайн-магазина предпринимались, один раз даже почти удалось, но разработчик сильно подвел халтурой. Что называется "не угадали с выбором компании". Деньги были потрачены в пустоту, остался неприятный осадок. В начале этого года вопрос поднялся снова. В очередной раз сохранение прайса в excel вдруг отказалось работать, опять некоторые клиенты не могут открыть, опять страдают планы продаж. Опять поиски подрядчика, шестизначные ценники, самый крупный не вникая в ТЗ запросил почти 600 тысяч. Руководство очень не хотело снова оказаться у разбитого корыта, проверенные разработчики продвигали свои продукты за космические суммы, остальные собирались по несколько недель продумывать дизайн, чтобы потом продать нам купленный за 5$ шаблон на wordpress.
В промежутках между заменой картриджей в офисе, телефонными звонками о пропавших кнопочках и потерявшихся письмах, эти годы я изучал самые разные языки для решения каких-то конкретных задач, своих или на работе. 1С, python, C#, php, js, способы работы с разными API в разных условиях, какие-то сайты разной направленности и сложности. Реализацию этого "Онлайн-прайса" я обдумывал уже давно и на тот момент произошло три вещи:
- Я видел фрустрацию руководства из-за прошлых провалов и нежелание рисковать.
- Мне окончательно надоело воевать с ветряными мельницами в 1С и решать очередную проблему со сломавшимся прайсом.
- Я мог сделать этот вэб-сервис сам.
В общем, я предложил - мне дали добро. Срок поставили - месяц. Справился за полтора, ибо замену картриджей никто не отменял.
И прежде, чем начать этот долгий рассказ, сразу хочу акцентировать ваше внимание на следующем:
- Статья не претендует на best-practice. Описано все на примере одного проекта со своими нюансами и допущениями.
- Да, можно сделать более технологично, улучшить часть моментов, что-то доавтоматизировать и т.д.
- Я пытаюсь показать, что иногда простую вещь не нужно делать сложно, как это часто нам пытаются навязать "конторы с мировым именем", томными вечерами изобретая дизайн и адаптируя верстку сайта-одностранички.
Схема
Если вы задавались целью найти себе разработчика на какой-то сайт - вы писали ТЗ (Техническое задание). Если у вас есть ТЗ, значит вы уже представляете себе, как это должно выглядеть, каким функционалом обладать, как оно должно вести себя в той или иной ситуации. И, в принципе, этого достаточно, осталось лишь определиться с инструментами.
Дано:
- Сервер 1С, расположенный в офисе компании.
- VPS, арендованный у хостинг-провайдера. На нем будет размещен итоговый сайт, БД сайта и бэкенд на DRF.
Если вы поднимаете VPS с нуля и он у вас тоже будет на debian 9, рекомендую мой мануал по развертыванию LEMP + DRF.
В нашем случае, было придумано так:
- Раз в день, в определенное время, из 1С выгружается информация о товарах, ценах и остатках + сервисные справочники. Она сохраняется локально на сервере в формате XML, рядом кладутся изображения номенклатуры.
- Получившиеся файлы загружаются на VPS по протоколу FTP, тем же регламентным заданием в 1С.
- Раз в день, в определенное время, на VPS происходит обработка новых XML файлов и загрузка информации из них в СУБД. Загрузка производится средствами python. СУБД - MariaDB 10.1
- К той же базе подключено приложение на Django + Django REST Framework. Задача DRF - по запросу отдавать на фронтенд нужную информацию из базы.
- Фронтенд на Vue.js. Его задачи - отрисовать сайт, общаться с бэкэндом на предмет: авторизации, получения информации о товарах и добавления новых заказов.
- DRF, получив новый заказ, отправляет ответственному за клиента менеджеру письмо с уникальным ID заказа.
- Менеджер открывает в 1С обработку, вставляет в нужное поле полученный ID и заказ загружается, создается документ в 1С.
- Менеджер управляет пользователями на сайте из 1С. Создает новых пользователей, назначает им тип цены и т.д. Общение 1С и DRF по части пользователей происходит через HTTP POST и GET запросы. Управление пользователями на самом сайте становится ненужно, а значит и админку для этого писать не придется.
Как видите, ни разу не прозвучало слово "дизайн". Это потому, что сайт у нас в первую очередь функциональный, и только потом - презентативный. Его красивость мы обеспечим бутстрапом, спецификой фреймворка и популярной оболочки для него - vuetify. Забавный факт вам скажу - попробовав эту связку один раз, вы будете видеть аналогичный дизайн везде.
Нарисуем нашу схему и приступим уже к реализации.
Часть 1. Выгрузка из 1С и загрузка на FTP
Основная задача, которая стоит перед этим этапом - выгрузить так, чтобы в дальнейшем не заниматься каким-либо изменением этих данных. Каждая дополнительная операция на других слоях разработки - увеличение времени обработки, увеличение нагрузки и размазывание области траблшутинга. Поэтому мы должны сформировать такие XML, чтобы потом их можно было просто взять и "вставить" в нашу БД на VPS.
На том, как формировать XML в файлы, останавливаться не буду. На эту тему есть достаточно материалов. Остановлюсь лишь на паре моментов.
Выгрузка изображений
Наверняка, картинок у вас много. У нас порядка 30 тысяч. Если положить их все в один каталог, вы его потом с трудом откроете. Возможно, вы уже замечали, как некоторые сервисы или CMS хранят картинки в подпапках из одной двух букв, по 100-1000 штук в каждой. Сделать такую структуру средствами 1С не сложно.
Функция ПолучитьПутьИзображения_Выгрузить(Номенклатура, Изображение)
ИмяИзображения = Номенклатура.УникальныйИдентификатор();
ДД = Изображение.Хранилище.Получить();
РасширениеФормат = ДД.Формат();
Расширение = мСоответствиеТиповФайловКартинок.Получить(РасширениеФормат);
ИмяИзображения = "" + ИмяИзображения + Расширение;
ИмяИзображенияURL = URLКаталогаИзображений;
КаталогИзображения_Локально = КаталогВыгрузкиIMAGES + "\" + ПолучитьПодкаталогИзображения(ИмяИзображения, "\");
ИмяИзображения_URL = ПолучитьПодкаталогИзображения(ИмяИзображения) + ИмяИзображения;
ПутьСохранения = "" + КаталогИзображения_Локально + ИмяИзображения;
// Проверим существование каталога
Если НЕ ПроверитьСуществованиеКаталога(КаталогИзображения_Локально) Тогда
Лог("Ошибка записи изображения на диск: " + Символы.ПС + " Не удалось создать каталог: " + КаталогИзображения_Локально);
ИмяИзображенияURL = ИмяИзображенияURL + "error-on-export.jpg";
Иначе
// Проверим, что файла там нет
Файл = Новый Файл(ПутьСохранения);
Если Файл.Существует() И НЕ ПерезаписыватьИзображения Тогда
ИмяИзображенияURL = ИмяИзображенияURL + ИмяИзображения_URL;
Иначе
Попытка
ДД.Записать(ПутьСохранения);
ИмяИзображенияURL = ИмяИзображенияURL + ИмяИзображения_URL;
Исключение
Лог("Ошибка записи изображения на диск: " + Символы.ПС + ОписаниеОшибки());
ИмяИзображенияURL = ИмяИзображенияURL + "error-on-export.jpg";
КонецПопытки;
КонецЕсли;
КонецЕсли;
Возврат ИмяИзображенияURL;
КонецФункции
Функция ПолучитьПодкаталогИзображения(ИмяИзображения, Разделитель = "/")
Если ПустаяСтрока(ИмяИзображения) Тогда
Возврат "";
КонецЕсли;
Корень = Лев(ИмяИзображения, 1);
Каталог = Сред(ИмяИзображения, 2, 1);
Путь = "" + Корень + Разделитель + Каталог + Разделитель;
Возврат Путь;
КонецФункции
Получается, что для номенклатуры с GUID bf0d2eb7-5caa-11e3-a144-001e6711eb85 картинка будет лежать сначала в папке b/, потом в папке f/ и только потом будет сама картинка bf0d2eb7-5caa-11e3-a144-001e6711eb85.jpg. С увеличением числа изображений мы просто увеличиваем в этом коде наш уровень вложенности до 3-4-5 каталогов. В XML же мы сразу пишем путь, по которому эта картинка будет доступна на сайте.
<ImagePath>/static/images/b/f/bf0d2eb7-5caa-11e3-a144-001e6711eb85.jpg</ImagePath>
Числа с плавающей точкой
В отличие от 1С, другие языки программирования разделяют целые и дробные части не запятой, а точкой. Это лучше сразу учесть на этапе выгрузки, чем заниматься преобразованием на этапе загрузки. Актуально, например, для цен товаров.
Процедура ВыгрузитьXML_Prices(ТипЦены, СП, ПутьДоФайлаЦен)
Лог("Получение данных выгрузки");
#Если Клиент Тогда
Состояние("Получение данных: Prices");
#КонецЕсли
Данные = ПолучитьДанныеЦен(ТипЦены, СП);
ЗаписатьСтандартныйXML_ID_Value(Данные, ПутьДоФайлаЦен, "ЧДЦ=2; ЧРД=.; ЧН=0.00; ЧГ=", Истина, "[Prices]",
"PriceValues", "Значения для типа цены - " + ТипЦены.Наименование, ТипЦены.Код);
Лог("Выгрузка цен завершена. Обработано строк: " + Данные.Количество());
КонецПроцедуры
Процедура ЗаписатьСтандартныйXML_ID_Value(Данные, ПутьДоФайла, ФорматЗначения, IDКакGUID,
Этап, InfoKey = "", InfoDescription = "", InfoArg = "")
XML = Новый ЗаписьXML;
XML.ОткрытьФайл(ПутьДоФайла, "UTF-8");
XML.ЗаписатьОбъявлениеXML();
XML.ЗаписатьНачалоЭлемента("Root");
ДобавитьЗаписьINFO(XML, InfoKey, InfoDescription, InfoArg);
XML.ЗаписатьНачалоЭлемента("Items");
#Если Клиент Тогда
МАКС_ИНД = Данные.Количество();
ИНД = 0;
Состояние("Обработка данных " + Этап + ": " + ИНД + "/" + МАКС_ИНД);
#КонецЕсли
// .Ссылка
// .Значение
Для Каждого СтрокаТЧ ИЗ Данные Цикл
#Если Клиент Тогда
ИНД = ИНД + 1;
Состояние("Обработка данных " + Этап + ": " + ИНД + "/" + МАКС_ИНД);
#КонецЕсли
// Формируем XML
XML.ЗаписатьНачалоЭлемента("Item");
Если IDКакGUID Тогда
ЗаписатьРеквизит(XML, "ID", СокрЛП(СтрокаТЧ.Ссылка.УникальныйИдентификатор()));
Иначе
ЗаписатьРеквизит(XML, "ID", СокрЛП(СтрокаТЧ.Ссылка.Код));
КонецЕсли;
ЗаписатьРеквизит(XML, "Value", Формат(СтрокаТЧ.Значение, ФорматЗначения));
XML.ЗаписатьКонецЭлемента(); //Item
КонецЦикла;
XML.ЗаписатьКонецЭлемента(); //Items
XML.ЗаписатьКонецЭлемента(); //Root
XML.Закрыть();
КонецПроцедуры
Процедура ЗаписатьРеквизит(ЗаписьXML, Имя, Значение)
ЗаписьXML.ЗаписатьНачалоЭлемента(Имя);
ЗаписьXML.ЗаписатьТекст(Строка(Значение));
ЗаписьXML.ЗаписатьКонецЭлемента();
КонецПроцедуры
Иерархические справочники
С одной стороны, структура справочника номенклатуры в самой СУБД в любом случае будет храниться двумерно, с другой - распарсить питоном иерархический XML в список, а потом этот список обратно преобразовать в иерархический объект - не сложно. Поэтому выгружаем иерархически. Получаем запросом список каталогов, формируем древо значений через Выгрузить(ОбходРезультатаЗапроса.ПоГруппировкамСИерархией) и обходим
Процедура ОбойтиУровеньДерева(ЗаписьXML, Строки, Уровень, Root = неопределено) Экспорт
#Если Клиент Тогда
ИНД = 0;
МАКС_ИНД = Строки.Количество();
#КонецЕсли
Для каждого Строка из Строки Цикл
#Если Клиент Тогда
ИНД = ИНД + 1;
Состояние("Обработка данных [Catalog: " + Уровень + "]: " + ИНД + "/" + МАКС_ИНД);
#КонецЕсли
Номенклатура = Строка.Номенклатура;
Если НЕ ЗначениеЗаполнено(Номенклатура) Тогда
Продолжить;
КонецЕсли;
Если НЕ Номенклатура.ЭтоГруппа Тогда
Продолжить;
КонецЕсли;
// Пропустить дубликат
Если Root <> неопределено И Root = Номенклатура Тогда
Продолжить;
КонецЕсли;
ЗаписьXML.ЗаписатьНачалоЭлемента("Item");
ЗаписатьРеквизит(ЗаписьXML, "ID", Номенклатура.УникальныйИдентификатор());
ЗаписатьРеквизит(ЗаписьXML, "Name", Номенклатура.Наименование);
ЗаписатьРеквизит(ЗаписьXML, "Level", Уровень);
Если Строка.Строки.Количество() > 0 Тогда
ЗаписьXML.ЗаписатьНачалоЭлемента("Childrens");
ОбойтиУровеньДерева(ЗаписьXML, Строка.Строки, Число(Уровень) + 1, Номенклатура);
ЗаписьXML.ЗаписатьКонецЭлемента(); //Childrens
КонецЕсли;
ЗаписьXML.ЗаписатьКонецЭлемента(); //Item
КонецЦикла;
КонецПроцедуры
Блок Info в каждом XML
Представим, что выгрузкой будет заниматься один программист, а загрузкой данных - другой. С одной стороны, они могут договориться, что будут сформированы вот такие файлы и с вот такими именами, с другой - такая договоренность может через года забыться, имена файлов вдруг изменятся и вообще. Поэтому, в каждый файл в самом начале будет неплохо добавить какие-то флаги, маркеры, объясняющие - что это и как с этим работать.
<?xml version="1.0" encoding="UTF-8"?>
<Root>
<Info>
<Key>PriceValues</Key>
<Description>Значения для типа цены - 1234</Description>
<Argument>000001234</Argument>
</Info>
<Items>
<Item>
<ID>bb5b71eb-3915-11e7-80b7-001e6711eb85</ID>
<Value>899.99</Value>
</Item>
...
</Items>
</Root>
И на той стороне уже увидят, что в каждом файле есть некий ключ Key, который можно использовать как идентификатор - что это за файл и как его обработать. А если появится программист, который будет что-то менять в выгрузке, он увидит этот блок и (будем надеяться) поймет, что это важно.
Выгрузка на FTP
По итогам формирования мы получили несколько файлов: один файл всех товаров с их реквизитами и параметрами, файл остатков, несколько файлов цен, по одному на каждый тип цены, а также небольшие файлы сопутствующих справочников, такие как производители, страны происхождения, единицы измерения и т.д.
Все это находится в двух корневых каталогах: IMAGES для картинок и XML для всех получившихся XML-файлов.
Сначала мы загрузим на VPS xml-файлы, потому что это быстро и критично, а уже потом включим синхронизацию изображений, потому что дожидаться завершения этого процесса для дальнейшей работы нам не нужно.
Сразу предостерегу вас - не используйте для этих задач встроенные механизмы FTP-клиента 1С. Он ужасен, он неэффективен, он ненадежен. Проверено. Вместо него в windows лучше воспользоваться сторонним приложением, вызывая его через COM-объект. Например, WinSCP.
Просто посмотрите, насколько лаконичнее и, поверьте, эффективнее, становится код в 1С. Приведу его полностью.
Выгрузка на FTP средствами WinSCP
// *********************** FTP ***********************
#Область Выгрузка_на_FTP
Процедура COM_ВыгрузитьНаFTP(КаталогВыгрузкиНаFTP, subdir="", МаскаФайловВыгрузки = "*.xml", ОчищатьFTPПередЗагрузкой = Истина)
session = ПолучитьОбъектSession();
Если session = неопределено Тогда
Возврат;
КонецЕсли;
НачатьСекциюЛога("ВЫГРУЗКА ФАЙЛОВ ИЗ: " + КаталогВыгрузкиНаFTP + "\" + subdir);
Если COM_ВыгрузитьXML_Sync(session, КаталогВыгрузкиНаFTP, subdir) Тогда
Лог(" --- Выгрузка завершена успешно.");
Иначе
Лог(" !!! Выгрузка завершена с ошибками.");
КонецЕсли;
// *************************************************
Если ВыгружатьИзображенияНаFTP Тогда
НачатьСекциюЛога("ВЫГРУЗКА ИЗОБРАЖЕНИЙ ИЗ: " + КаталогВыгрузкиIMAGES);
Если COM_ВыгрузитьIMG(session) Тогда
Лог(" --- Выгрузка IMG завершена успешно.");
Иначе
Лог(" !!! Выгрузка IMG завершена с ошибками.");
КонецЕсли;
КонецЕсли;
// *************************************************
session.Dispose();
КонецПроцедуры
Процедура COM_ВыгрузитьImagesНаFTP_Отладка() Экспорт
НачатьЛог("ВЫГРУЗКА НА FTP");
session = ПолучитьОбъектSession();
Если session = неопределено Тогда
Возврат;
КонецЕсли;
Если COM_ВыгрузитьIMG(session) Тогда
Лог(" --- Выгрузка IMG завершена успешно.");
Иначе
Лог(" !!! Выгрузка IMG завершена с ошибками.");
КонецЕсли;
ЗавершитьЛог();
КонецПроцедуры
Функция COM_УдалитьXML(session, subdir, Маска)
#Если Клиент Тогда
Состояние("Очищаем каталог /" + subdir + " на FTP...");
#КонецЕсли
Попытка
ПутьУдаленияНаФТП = КаталогXMLнаFTP + subdir + Маска;
transferResult = session.RemoveFiles(ПутьУдаленияНаФТП);
transferResult.Check();
//Обрабатываем результат выгрузки
Лог(" > Удалено файлов: " + transferResult.Removals.Count);
Для каждого УдаленныйФайл Из transferResult.Removals Цикл
Лог(" >>> Удален файл: " + УдаленныйФайл.FileName);
КонецЦикла;
Лог(" > Ошибок удаления: " + transferResult.Failures.Count);
Для каждого Ошибка Из transferResult.Failures Цикл
Лог(" >>> Описание ошибки: " + Ошибка.Message);
КонецЦикла;
Исключение
Лог(" >>> Ошибка удаления с FTP: " + Символы.ПС + " <<< " + ОписаниеОшибки());
Возврат Ложь;
КонецПопытки;
Возврат Истина;
КонецФункции
Функция COM_ВыгрузитьXML(session, subdir, КаталогВыгрузкиНаFTP, Маска)
#Если Клиент Тогда
Состояние("Выгружаем файлы на FTP...");
#КонецЕсли
Попытка
ПутьВыгрузки = КаталогВыгрузкиНаFTP + "\" + Маска;
ПутьЗагрузкиНаФТП = КаталогXMLнаFTP + subdir;
transferResult = session.PutFiles(ПутьВыгрузки, ПутьЗагрузкиНаФТП);
transferResult.Check();
//Обрабатываем результат выгрузки
Лог(" > Выгружено файлов: " + transferResult.Transfers.Count);
Для каждого ВыгруженныйФайл Из transferResult.Transfers Цикл
Лог(" >>> Выгружен файл: " + ВыгруженныйФайл.FileName);
КонецЦикла;
Лог(" > Ошибок выгрузки: " + transferResult.Failures.Count);
Для каждого Ошибка Из transferResult.Failures Цикл
Лог(" >>> Описание ошибки: " + Ошибка.Message);
КонецЦикла;
Исключение
Лог(" >>> Ошибка выгрузки XML: " + Символы.ПС + " <<< " + ОписаниеОшибки());
Возврат Ложь;
КонецПопытки;
Возврат Истина;
КонецФункции
Функция COM_ВыгрузитьXML_Sync(session, КаталогВыгрузкиНаFTP, subdir)
БезОшибок = Истина;
#Если Клиент Тогда
Состояние("Выгружаем XML на FTP. [Synchronization in progress...]");
#КонецЕсли
Попытка
ПутьВыгрузки = КаталогВыгрузкиНаFTP + "\" + ?(НЕ ПустаяСтрока(subdir), subdir + "\", "");
ПутьЗагрузкиНаФТП = КаталогXMLнаFTP + ?(НЕ ПустаяСтрока(subdir), subdir + "/", "");
//SynchronizationMode mode: 0 - local, 1 - remote, , 2 - both
//SynchronizationCriteria criteria: 0 - None, 1 - Time, 2 - Size, 3 - Either
//
//Public Function SynchronizeDirectories(
// mode As SynchronizationMode,
// localPath As String,
// remotePath As String,
// removeFiles As Boolean,
// Optional mirror As Boolean = False,
// Optional criteria As SynchronizationCriteria = 1,
// Optional options As TransferOptions = Nothing
//) As SynchronizationResult
transferResult = session.SynchronizeDirectories(1, ПутьВыгрузки, ПутьЗагрузкиНаФТП,
True, False);
transferResult.Check();
Лог(" > Выгружено файлов: " + transferResult.Uploads.Count);
Лог(" > Удалено файлов: " + transferResult.Removals.Count);
Лог(" > Ошибок: " + transferResult.Failures.Count);
Исключение
БезОшибок = Ложь;
Лог(" >>> Ошибка выгрузки IMG: " + Символы.ПС + " <<< " + ОписаниеОшибки());
КонецПопытки;
Возврат БезОшибок;
КонецФункции
Функция COM_ВыгрузитьIMG(session)
БезОшибок = Истина;
#Если Клиент Тогда
Состояние("Выгружаем IMAGES на FTP. [Synchronization in progress...]");
#КонецЕсли
// Тонко. Засинхроним корневые каталоги
Попытка
ПутьВыгрузки = КаталогВыгрузкиIMAGES + "\";
ПутьЗагрузкиНаФТП = КаталогIMAGESнаFTP;
//SynchronizationMode mode: 0 - local, 1 - remote, , 2 - both
//SynchronizationCriteria criteria: 0 - None, 1 - Time, 2 - Size, 3 - Either
//
//Public Function SynchronizeDirectories(
// mode As SynchronizationMode,
// localPath As String,
// remotePath As String,
// removeFiles As Boolean,
// Optional mirror As Boolean = False,
// Optional criteria As SynchronizationCriteria = 1,
// Optional options As TransferOptions = Nothing
//) As SynchronizationResult
transferResult = session.SynchronizeDirectories(1, ПутьВыгрузки, ПутьЗагрузкиНаФТП,
False, ПерезаписыватьИзображенияFTP);
transferResult.Check();
Лог(" > Выгружено файлов: " + transferResult.Uploads.Count);
Лог(" > Удалено файлов: " + transferResult.Removals.Count);
Лог(" > Ошибок: " + transferResult.Failures.Count);
Исключение
БезОшибок = Ложь;
Лог(" >>> Ошибка выгрузки IMG: " + Символы.ПС + " <<< " + ОписаниеОшибки());
КонецПопытки;
Возврат БезОшибок;
КонецФункции
// Служебные
Функция ПолучитьОбъектSession()
#Если Клиент Тогда
Состояние("Устанавливаем соединение...");
#КонецЕсли
Попытка
//Задаем параметры подключения
sessionOptions = Новый COMОбъект("WinSCP.SessionOptions"); //Создаем объект SessionOptions
sessionOptions.Protocol = 2; // FTP
//Sftp = 0,
//Scp = 1,
//Ftp = 2,
//Webdav = 3,
sessionOptions.HostName = FTP_Address;
sessionOptions.PortNumber = 21;
sessionOptions.UserName = FTP_User;
sessionOptions.Password = FTP_Password;
session = Новый COMОбъект("WinSCP.Session"); //Создаем объект Session
//TimeSpan Timeout
// default = 1 min
//на всякий случай
session.ExecutablePath = "C:\Program Files (x86)\WinSCP\winscp.exe";
// Подключаемся
session.Open(sessionOptions);
Возврат session;
Исключение
Лог(" >>> Ошибка соединения: " + Символы.ПС + " <<< " + ОписаниеОшибки());;
ЗавершитьЛог();
Возврат неопределено;
КонецПопытки;
КонецФункции
#КонецОбласти
Итог
На этом этап выгрузки данных завершен. Необходимые файлы сейчас находятся на удаленном сервере, где они через какое-то время, по заданию в crontab, будут обработаны.
Часть 2. Загрузка в СУБД
В принципе, вовсе не обязательно, чтобы данные из XML были загружены в СУБД именно с помощью pyhton. Этот этап полностью изолирован от всех остальных, он чисто сервисный. Я выбрал python потому, что понимал, как это нужно сделать. Основу скрипта составит библиотека SQLAlchemy. Для ее работы сначала мы создадим модели наших таблиц. Делать это мы будем декларативно, потому что мы не хотим заниматься ручным созданием таблиц в нашей базе, SQLAlchemy сделает это для нас. От нас на подготовительном этапе потребуется только создать саму базу и пользователя для работы с ней.
Пример модели Goods (Товары)
from sqlalchemy import Column, Index, Integer, DECIMAL, String, Text, Boolean, ForeignKey
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship
from sqlalchemy.dialects.mysql import INTEGER
Base = declarative_base()
class Goods(Base):
__tablename__ = 'main_goods'
id = Column('id', String(36),
primary_key = True,
comment = '1С - GUID')
name = Column('name', String(100),
nullable = True,
default = None,
comment = '1С - Наименование')
code = Column('code', String(11),
nullable = True,
default = None,
comment = '1С - Код элемента')
article = Column('article', String(25),
nullable = True,
default = None,
comment = '1C - Артикул')
description = Column('description', Text,
nullable = True,
default = None,
comment = '1С - Описание')
package_qty = Column('package_qty', INTEGER(display_width = 10),
default = 1,
comment = '1С - Штук в коробке')
id_parent = Column('id_parent', String(36),
# goods = relationship('Goods')
ForeignKey('main_catalog.id', ondelete = 'SET NULL', onupdate = 'CASCADE'),
nullable = True,
default = None,
index = True,
comment = '1С - GUID каталога (Родителя)')
name_parent_good = Column('name_parent_good', String(100),
nullable = True,
default = None,
comment = '1С - Имя каталога (Родителя)')
id_brand = Column('id_brand', String(9),
# goods = relationship('Goods')
ForeignKey('svc_brands.id', ondelete = 'SET NULL', onupdate = 'CASCADE'),
nullable = True,
default = None,
index = True,
comment = '1С - Производитель, Код')
name_brand = Column('name_brand', String(50),
nullable = True,
default = None,
comment = '1С - Производитель, Имя')
id_unit = Column('id_unit', String(4),
# goods = relationship('Goods')
ForeignKey('svc_units.id', ondelete = 'SET NULL', onupdate = 'CASCADE'),
nullable = True,
default = None,
index = True,
comment = '1С - Единица измерения, Код')
name_unit = Column('name_unit', String(25),
nullable = True,
default = None,
comment = '1С - Единица измерения, Имя')
id_country = Column('id_country', String(3),
# goods = relationship('Goods')
ForeignKey('svc_countries.id', ondelete = 'SET NULL', onupdate = 'CASCADE'),
nullable = True,
default = None,
index = True,
comment = '1С - Страна, Код')
name_country = Column('name_country', String(60),
nullable = True,
default = None,
comment = '1С - Страна, Имя')
image_path = Column('image_path', String(150),
default = '/static/images/noimage.jpg',
comment = 'URL до картинки')
sku_pcs = Column('sku_pcs', String(30),
nullable = True,
default = '',
comment = '1С - Штрихкод штуки')
sku_pkg = Column('sku_pkg', String(30),
nullable = True,
default = '',
comment = '1С - Штрихкод коробки')
category_new = Column('category_new', Boolean,
default = False,
index = True,
comment = '1С - Категория Новинка')
category_promo = Column('category_promo', Boolean,
default = False,
index = True,
comment = '1С - Категория Акция')
category_sale = Column('category_sale', Boolean,
default = False,
index = True,
comment = '1С - Категория Распродажа')
# Relationships
rests = relationship('Rests')
prices = relationship('Prices')
def __init__(self, *initial_data, **kwargs):
for dictionary in initial_data:
for key in dictionary:
value_to_init = dictionary[key]
if value_to_init == 'null':
value_to_init = None
elif value_to_init == 'True':
value_to_init = True
elif value_to_init == 'False':
value_to_init = False
if key == 'package_qty':
try:
value_to_init = int(dictionary[key])
except ValueError:
pass
setattr(self, key, value_to_init)
for key in kwargs:
setattr(self, key, kwargs[key])
self.set_defaults()
def set_defaults(self):
# Item attributes
if is_empty(self.name):
self.name = None
if is_empty(self.code):
self.code = None
if is_empty(self.article):
self.article = None
# Names of attributes
if is_empty(self.name_parent_good):
self.name_parent_good = None
if is_empty(self.name_brand):
self.name_brand = None
if is_empty(self.name_unit):
self.name_unit = None
if is_empty(self.name_country):
self.name_country = None
# Package QTY
if not isinstance(self.package_qty, int):
self.package_qty = 1
# Description
if is_empty(self.description):
self.description = None
# Image
if not isinstance(self.image_path, str):
self.image_path = '/static/images/noimage.jpg'
# SKU
if is_empty(self.sku_pcs):
self.sku_pcs = ''
if is_empty(self.sku_pkg):
self.sku_pkg = ''
# Categories
if not isinstance(self.category_new, bool):
self.category_new = False
if not isinstance(self.category_promo, bool):
self.category_promo = False
if not isinstance(self.category_sale, bool):
self.category_sale = False
def __repr__(self):
return '<Good object (ID: %s, Name: %s, Code: %s, Article: %s)>' % (
self.id, self.name, self.code, self.article)
Пример модели Prices (Цены)
from sqlalchemy import Column, Index, Integer, DECIMAL, String, Text, Boolean, ForeignKey
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship
from sqlalchemy.dialects.mysql import INTEGER
class Prices(Base):
__tablename__ = 'main_prices'
id = Column('id', INTEGER(display_width = 9),
primary_key = True,
autoincrement = True,
comment = 'AutoIncrement')
id_good = Column('id_good', String(36),
ForeignKey('main_goods.id', ondelete = 'CASCADE', onupdate = 'CASCADE'),
comment = '1С - GUID товара')
id_price = Column('id_price', String(9),
# prices = relationship('Prices')
ForeignKey('svc_pricetypes.id', ondelete = 'CASCADE', onupdate = 'CASCADE'),
comment = '1С - Код типа цены')
value = Column('value', DECIMAL(15,2),
default = 0.00,
comment = '1C - Число (15,2)')
__table_args__ = (Index('id_good_price', "id_good", "id_price"), )
def __init__(self, *initial_data, **kwargs):
for dictionary in initial_data:
for key in dictionary:
value_to_init = dictionary[key]
if value_to_init == 'null':
value_to_init = None
elif value_to_init == 'True':
value_to_init = True
elif value_to_init == 'False':
value_to_init = False
if key == 'value':
try:
value_to_init = float(dictionary[key])
except ValueError:
pass
setattr(self, key, value_to_init)
for key in kwargs:
setattr(self, key, kwargs[key])
self.set_defaults()
def set_defaults(self):
if not isinstance(self.value, float):
self.value = 0.00
def __repr__(self):
return '<Rest object (ID: %s, Good ID: %s, Value: %s)>' % (
self.id, self.id_good, self.value)
Обратите внимание на блоки init в каждой модели. Дело в том, что при чтении наших XML, False, True, Null будут представлены для python в виде текста и для дальнейшей работы с этими полями уже из самой БД нам нужно преобразовать их в типы, понятные python. Поэтому мы и проверяем, что если поле == 'null', то эквивалентом для python будет тип None и т.д. Если этого не сделать, мы столкнемся с проблемой при загрузке, когда попытаемся в поле БД с типом TINY_INT засунуть строку с текстом 'False'.
Описание этих моделей можно считать самым нудным этапом всей разработки. Это как писать техническую документацию на каждую колонку каждой таблицы всей базы данных.
Чтобы не заниматься прописыванием методов и полей в коде загрузки, сделаем некий словарь, где по ключу в XML будем определять, какие там поля и как они должны быть загружены в БД.
BULK_METHODS = ['PriceValues', 'RestValues']
# ------------- SQL<->XML RELATIONS -----------
# XML-Key -> SQL-Table | XML-Tags -> SQL-Columns
XML_SQL_RELATIONS = {
# --- MAIN ---
'GoodsCollection' : {
'table': 'main_goods',
'columns': {
'ID': 'id',
'Name': 'name',
'Code': 'code',
'Article': 'article',
'Description': 'description',
'PackageQty': 'package_qty',
'ParentID': 'id_parent',
'ParentName': 'name_parent_good',
'BrandID': 'id_brand',
'BrandName': 'name_brand',
'UnitID': 'id_unit',
'UnitName': 'name_unit',
'CountryID': 'id_country',
'CountryName': 'name_country',
'ImagePath': 'image_path',
'SKU_pcs': 'sku_pcs',
'SKU_pkg': 'sku_pkg',
'Category_New' : 'category_new',
'Category_Promo': 'category_promo',
'Category_Sale': 'category_sale',
},
},
'CatalogHierarchy' : {
'table': 'main_catalog',
'columns': {
'ID': 'id',
'Name': 'name',
'Level': 'level',
# parent -> ID : 'id_parent',
},
},
'PriceValues': {
'table': 'main_prices',
'columns': {
'ID': 'id_good',
'Value': 'value',
# info -> Argument : 'id_price',
},
},
'RestValues': {
'table': 'main_rests',
'columns': {
'ID': 'id_good',
'Value': 'value',
},
},
# --- SVC ---
'BrandsCollection' : {
'table': 'svc_brands',
'columns': {
'ID': 'id',
'Name': 'name',
},
},
'CountriesCollection': {
'table': 'svc_countries',
'columns': {
'ID': 'id',
'Name': 'name',
},
},
'ManagersCollection': {
'table': 'svc_managers',
'columns': {
'ID': 'id',
'Name': 'name',
'Code': 'code',
'Email': 'email',
'PhoneMobile': 'phone_mobile',
'PhoneOffice': 'phone_office',
},
},
'PricetypesCollection': {
'table': 'svc_pricetypes',
'columns': {
'ID': 'id',
'Name': 'name',
},
},
'UnitsCollection': {
'table': 'svc_units',
'columns': {
'ID': 'id',
'Name': 'name',
},
},
# --- TMP ---
'UsersUpdate': {
'table': 'main_users',
'columns': {
'ID': 'id',
'Name': 'name',
},
},
}
Для того, чтобы получить данные из наших XML не сплошным текстом, а неким объектом, воспользуемся библиотекой lxml.
from glob import glob
from lxml import etree as ElementTree
def get_folder_files(directory, mask ='*.xml'):
files = glob('{directory}/{mask}'.format(directory = directory, mask = mask))
return files
def read_xml_file(file_path):
xml_content = ElementTree.parse(file_path)
xml_root = xml_content.getroot()
# INFO
info = dict(key = None, description = None, argument = None)
# we have INFO and ITEMS
child_info = xml_root.find('Info')
if child_info is not None:
read_xml_info(child_info, info)
child_items = xml_root.find('Items')
return dict(info = info, items = child_items)
def read_xml_info(node, info):
# node is an object of lxml Etree
info['key'] = node.find('Key').text if node.find('Key') is not None else None
info['description'] = node.find('Description').text if node.find('Description') is not None else None
info['argument'] = node.find('Argument').text if node.find('Argument') is not None else None
Когда механизм чтения готов, можно приступать к самой загрузке.
Главный метод, которым все и загружается
def main():
# Load all XMLs from main and svc folders
# '''
files = _xml.get_folder_files(ROOT_XML_SERVICE)
# Order is important
# first - we load catalog
for xml_file in _xml.get_folder_files(ROOT_XML_MAIN, mask = 'catalog.xml'):
files.append(xml_file)
# Second - we load goods
for xml_file in _xml.get_folder_files(ROOT_XML_MAIN, mask = 'goods.xml'):
files.append(xml_file)
# Third - we load rests
for xml_file in _xml.get_folder_files(ROOT_XML_MAIN, mask = 'rests.xml'):
files.append(xml_file)
# Finally - we load prices
for xml_file in _xml.get_folder_files(ROOT_XML_MAIN, mask = 'prices*'):
files.append(xml_file)
# '''
#files = _xml.get_folder_files(ROOT_XML_MAIN, mask = '*prices-*.xml')
_sql.init_connection()
# We clear db first.
# We don't need outdated information
delete_table_contents()
# Then we load Service files first
# And Main XMLs last
for xml_file in files:
log.info('\nParsing XML file: %s' % xml_file)
xml_content = _xml.read_xml_file(xml_file)
if xml_content['info']['key'] is not None and xml_content['info']['key'] in XML_SQL_RELATIONS.keys():
method = xml_content['info']['key']
log.info('Parsing XML by key: %s' % method)
xml_items = xml_content['items'].findall('.//Item')
if len(xml_items) > 0:
log.info(' > File contains: %s items' % len(xml_items))
max_trans_roll = MAX_TRANSACTIONS
if len(xml_items) > max_trans_roll * 10:
max_trans_roll *= 10
stack = 0
bulk_stack = []
for xml_item in xml_items:
try:
if method in BULK_METHODS:
bulk_stack.append(_sql.add_string(method, xml_item,
argument = xml_content['info']['argument']))
else:
_sql.add_string(method, xml_item,
argument = xml_content['info']['argument'])
except Exception as e:
log.error('Failed on adding string: %s' % e)
stack += 1
if stack >= max_trans_roll:
print('%s: %s - %s' % (method, stack, max_trans_roll))
# Commit session
stack = 0
if method in BULK_METHODS:
_sql.bulk_commmit_session(bulk_stack)
bulk_stack = []
else:
_sql.commit_session()
# Commit the leftovers
if max_trans_roll > stack > 0:
print('%s: %s - %s' % (method, stack, max_trans_roll))
if method in BULK_METHODS:
_sql.bulk_commmit_session(bulk_stack)
else:
_sql.commit_session()
else:
log.error(' > File contains: 0 items. File will be skipped')
else:
log.error('Unable to identify XML by Key')
_sql.close_session()
def delete_table_contents():
list_tables = ('main_catalog', 'main_goods', 'main_prices', 'main_rests',
'svc_brands', 'svc_countries', 'svc_managers', 'svc_pricetypes', 'svc_units')
_sql.set_foreign_checks(0)
for table_name in list_tables:
_sql.truncate_table(table_name)
_sql.set_foreign_checks(1)
_sql.close_session()
#!/usr/bin/env python3
# -*- coding: UTF-8 -*-
from pprint import pprint
from config import *
from sqlalchemy import create_engine
from sqlalchemy import text
from sqlalchemy.orm import sessionmaker, relationship
import _sql_classes as SC
import os
import logging
log = logging.getLogger(__file__)
log.setLevel(logging.DEBUG)
fh = logging.FileHandler('{path_log}/sql.log'.format(path_log = PATH_LOGS))
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
fh.setFormatter(formatter)
log.addHandler(fh)
USE_ECHO = False
if os.name == 'nt':
USE_ECHO = True
# Do not forget to:
# Return MariaDB to localhost /etc/mysql/mariadb.conf.d/50-server
# Return priceadmin user to @localhost
engine = create_engine('mysql://{username}:{password}@{host}:{port}/{db}?charset=utf8'.format(
username = SQL_RW_USER,
password = SQL_RW_PASSWORD,
host = SQL_HOST,
port = SQL_PORT,
db = SQL_DB), encoding='utf-8', echo = USE_ECHO)
Session = sessionmaker(bind = engine)
session = Session()
def init_connection():
SC.Base.metadata.create_all(bind = engine)
def add_string(method, item, argument = None):
# method - string
# item - lxml Etree
# We identify fields by config Dict XML_SQL_RELATIONS with provided 'method'
rel_dict = {}
if method in XML_SQL_RELATIONS.keys():
columns = XML_SQL_RELATIONS[method]['columns']
for k in columns.keys():
item_value = item.find(k)
if item_value is not None:
# SQL column name : value to add
rel_dict[columns[k]] = item_value.text
# For some type we add Argument
if method == 'PriceValues' and argument is not None:
rel_dict['id_price'] = argument
# For Hierarchy-type XML, like Catalog we add parent_id
# Parent ID will be found by lxml Etree '..' getparent navigation
if method == 'CatalogHierarchy':
parent_item = item.getparent()
if parent_item is not None:
parent_item = parent_item.getparent()
parent_item_id = None
if parent_item is not None:
parent_item_id = parent_item.find('ID').text if parent_item.find('ID') is not None else None
rel_dict['id_parent'] = parent_item_id
else:
raise Exception('method not declared in XML_SQL_RELATIONS (config)')
if len(rel_dict.keys()) > 0:
new_string = None
# ----------- MAIN
if method == 'GoodsCollection':
new_string = SC.Goods(rel_dict)
elif method == 'CatalogHierarchy':
new_string = SC.Catalogs(rel_dict)
elif method == 'PriceValues':
new_string = SC.Prices(rel_dict)
elif method == 'RestValues':
new_string = SC.Rests(rel_dict)
# ----------- SVC
elif method == 'BrandsCollection':
new_string = SC.Brands(rel_dict)
elif method == 'CountriesCollection':
new_string = SC.Countries(rel_dict)
elif method == 'ManagersCollection':
new_string = SC.Managers(rel_dict)
elif method == 'PricetypesCollection':
new_string = SC.PriceTypes(rel_dict)
elif method == 'UnitsCollection':
new_string = SC.Units(rel_dict)
elif method == 'UsersUpdate':
new_string = SC.Users(rel_dict)
if new_string is not None:
if method in BULK_METHODS:
return new_string
else:
session.add(new_string)
return None
def get_table_content_all(table):
table_content = session.query(table).all()
for row in table_content:
print(row.id)
def set_foreign_checks(value):
_query = text('SET FOREIGN_KEY_CHECKS = {value}'.format(
value = value))
session.execute(_query)
def truncate_table(str_table):
truncate_query = text('TRUNCATE TABLE {table_name}'.format(
table_name = str_table))
session.execute(truncate_query)
try:
session.commit()
log.info('table `{table_name}` truncated'.format(
table_name = str_table))
except Exception as e:
session.rollback()
log.error('table `{table_name}` not truncated: {error_msg}'.format(
table_name = str_table, error_msg = e))
def commit_session():
try:
session.commit()
except Exception as e:
print(e)
session.rollback()
def bulk_commmit_session(bulk_stack):
try:
session.bulk_save_objects(bulk_stack)
session.commit()
except Exception as e:
print(e)
session.rollback()
def merge_session():
try:
session.merge()
except Exception as e:
print(e)
session.rollback()
def close_session():
session.close()
Вы могли заметить, что вызов загрузки также вызывает очистку всех таблиц в базе данных. Да, это так. Специфика нашего проекта позволяет нам просто взять и удалить данные из всех таблиц, в которые мы собираемся что-то загружать. Поэтому мы не тратим время и ресурсы на какую-то сложную логику по определению уже существующих данных, их соответствие загружаемым и т.д. (ON DUPLICATE UPDATE создаст дополнительный слой операций) Мы точно знаем, что перед загрузкой таблица будет пуста и точно знаем, что нужно загрузить абсолютно все. А это, в свою очередь, позволяет нам формировать INSERT-команды пачками по 100-1000 штук и засылать в commit. Таким образом, загрузка данных общей протяженностью около 400.000 строк занимает секунд 15. Если в других таблицах нашей БД (например, в заказах) будут использоваться ключи от очищенных таблиц - они будут оставлены как есть (потому что перед загрузкой мы отключаем для БД ForeignKeyChecks) и после загрузки снова станут актуальны.
Итог
Данные загружены в СУБД, теперь с ними можно работать.
Часть 3. Django, REST Framework
Прежде чем начать, рекомендую обзавестись удобным инструментом для отладки HTTP-запросов - Postman
С помощью него вам будет гораздо удобнее отлаживать свои запросы к API.
Models
Django (Джанго), как и SQLAlchemy, в своей работе тоже использует ORM-принципы, поэтому для того, чтобы научить наш бэкенд понимать таблицы в нашей БД, нам снова придется описать все модели. По одной на каждую таблицу. Но, в отличие от загрузки из XML, тут мы также будем описывать такие модели как "Profile, Order, OrderItems". Опять таки, мы ничего не будем создавать непосредственно в СУБД, механизмы Django сделают это для нас (python manage.py makemigrations и pyhton manage.py migrate).
Это второй нудный этап разработки, нам снова нужно описать все модели и даже больше, чем мы это делали для SQLAlchemy.
И снова пример для Goods и Prices
from django.db import models
from django.contrib.auth.models import User
from django.db.models.signals import post_save
from django.dispatch import receiver
import datetime
from decimal import Decimal
class MainGoods(models.Model):
id = models.CharField(primary_key = True, max_length = 36)
name = models.CharField(max_length = 100, blank = True, null = True)
code = models.CharField(max_length = 11, blank = True, null = True)
article = models.CharField(max_length = 25, blank = True, null = True)
description = models.TextField(blank = True, null = True)
package_qty = models.IntegerField()
id_parent = models.ForeignKey(MainCatalog,
models.SET_NULL,
related_name = 'goods',
db_column = 'id_parent', blank = True, null = True)
name_parent_good = models.CharField(max_length = 100, blank = True, null = True)
id_brand = models.ForeignKey('SvcBrands',
models.SET_NULL,
related_name = 'goods',
db_column = 'id_brand', blank = True, null = True)
name_brand = models.CharField(max_length = 50, blank = True, null = True)
id_unit = models.ForeignKey('SvcUnits',
models.SET_NULL,
related_name = 'goods',
db_column = 'id_unit', blank = True, null = True)
name_unit = models.CharField(max_length = 25, blank = True, null = True)
id_country = models.ForeignKey('SvcCountries',
models.SET_NULL,
related_name = 'goods',
db_column = 'id_country', blank = True, null = True)
name_country = models.CharField(max_length = 60, blank = True, null = True)
image_path = models.CharField(max_length = 150)
# SKU
sku_pcs = models.CharField(
db_column = 'sku_pcs',
max_length = 30, blank = True, null = True, default = '')
sku_pkg = models.CharField(
db_column = 'sku_pkg',
max_length = 30, blank = True, null = True, default = '')
# Categories
category_new = models.IntegerField()
category_promo = models.IntegerField()
category_sale = models.IntegerField()
class Meta:
managed = False
db_table = 'main_goods'
verbose_name = 'Товар'
verbose_name_plural = 'Товары'
ordering = ('name',)
def __str__(self):
return '{article}: {name} ({code})'.format(
article = self.article, name = self.name, code = self.code)
class MainPrices(models.Model):
id = models.IntegerField(primary_key=True)
id_good = models.ForeignKey(MainGoods,
models.CASCADE,
related_name = 'prices',
db_column = 'id_good')
id_price = models.ForeignKey('SvcPricetypes',
models.CASCADE,
related_name = 'prices',
db_column = 'id_price')
value = models.DecimalField(max_digits = 15, decimal_places = 2)
class Meta:
managed = False
db_table = 'main_prices'
verbose_name = 'Цена'
verbose_name_plural = 'Цены'
ordering = ('id',)
def __str__(self):
return '{id_good} ({id_price}) : {value}'.format(
id_good = self.id_good, id_price = self.id_price, value = self.value)
А это уже новая таблица - Profiles
Управление данными в этой таблице будет производиться из 1С. Каждый созданный User автоматически создаст для себя соотв. строку в Profiles. Это обеспечивается двумя Signal-методами в конце.
from django.db import models
from django.contrib.auth.models import User
from django.db.models.signals import post_save
from django.dispatch import receiver
import datetime
from decimal import Decimal
#
# The only managed table for Django. It is not created via XML import
# This table should be linked to User as OneToOne Relation
#
class Profile(models.Model):
# ! --- IMPORTANT --- !
user = models.OneToOneField(User, on_delete = models.CASCADE, related_name = 'profile')
id_one_c = models.CharField(
db_index = True,
max_length = 9,
help_text = '1С - Код элемента (Пользователи ОП)'
)
name = models.CharField(
max_length = 50,
help_text = '1С - Имя элемента (Пользователи ОП)'
)
id_owner = models.CharField(
db_index = True,
max_length = 9,
help_text = '1С - Код элемента (Контрагент)'
)
name_owner = models.CharField(
max_length = 100,
help_text = '1С - Имя элемента (Контрагент)'
)
# User model have those
#login = models.CharField(unique = True, max_length = 30)
#password = models.CharField(max_length = 30)
#email = models.CharField(max_length = 50)
id_manager = models.ForeignKey(
'SvcManagers',
models.SET_NULL,
db_index = True,
related_name = 'users',
db_column = 'id_manager', blank = True, null = True,
help_text = '1С - Менеджер, GUID'
)
id_contract = models.CharField(
max_length = 9,
help_text = '1С - Договор, Код'
)
id_organization = models.CharField(
max_length = 9,
help_text = '1С - Организация, Код'
)
name_organization = models.CharField(
max_length = 50,
help_text = '1С - Организация, Имя'
)
id_price = models.ForeignKey(
'SvcPricetypes',
models.SET_NULL,
db_index = True,
related_name = 'users',
db_column = 'id_price', blank = True, null = True,
help_text = '1С - Тип цены, Код'
)
markup = models.DecimalField(
max_digits = 5, decimal_places = 2,
help_text = 'Наценка в %. +-999.99',
default = 0.00,
)
class Meta:
db_table = 'price_users'
verbose_name = 'Прайс: Профиль пользователя'
verbose_name_plural = 'Прайс: Профили пользователей'
ordering = ('id',)
def __str__(self):
return('{user}: {name} - {name_owner}'.format(
user = self.user,
name = self.name,
name_owner = self.name_owner
))
# Signals
@receiver(post_save, sender = User)
def create_user_profile(sender, instance, created, **kwargs):
if created:
Profile.objects.create(user = instance)
@receiver(post_save, sender = User)
def save_user_profile(sender, instance, created, **kwargs):
instance.profile.save()
Ну и последние две таблицы - Orders и OrderItems
По аналогии с документами в 1С. В одной таблице - заказ и его реквизиты, в другой - товары для табличной части этого заказа. Номер заказа у нас формируется автоматически из префикса, текущей даты и порядкового номера процедурой increment_order_id().
#
# API only table. XML upload does not affect this
#
def increment_order_id():
""" Creates Order ID of format ABCD . Change as per neeed."""
prefix = 'ORDR'
# if you are gonna change it but its not 4 character long
# make sure to change the numbers below too
last_order = Order.objects.all().order_by('id').last()
if not last_order:
return str(prefix) + str(datetime.date.today().year) + str(
datetime.date.today().month).zfill(2) + str(
datetime.date.today().day).zfill(2) + '0000'
order_id = last_order.order_id
order_id_int = int(order_id[12:16])
new_order_id_int = order_id_int + 1
new_order_id = str(prefix) + str(str(datetime.date.today().year)) + str(
datetime.date.today().month).zfill(2) + str(
datetime.date.today().day).zfill(2) + str(new_order_id_int).zfill(4)
return new_order_id
def new_order_allowed(order):
active_order = Order.objects.filter(
user = order.user, order_id = order.order_id, status = 0).order_by('id'.last())
if active_order:
if active_order.id != order.id:
return False
return True
class Order(models.Model):
#id = models.AutoField(primary_key = True)
STATUS_ACTIVE = 0
STATUS_SENT = 1
STATUS_ARCHIVED = 2
ORDER_STATUS = (
(STATUS_ACTIVE, 'Текущий'),
(STATUS_SENT, 'Отправлен'),
(STATUS_ARCHIVED, 'Архивный')
)
user = models.ForeignKey(
User,
on_delete = models.SET_NULL,
null = True,
related_name = 'orders'
)
created = models.DateTimeField(
auto_now_add = True
)
modified = models.DateTimeField(
auto_now = True
)
status = models.IntegerField(default = STATUS_ACTIVE, choices = ORDER_STATUS)
order_id = models.CharField(
max_length = 20,
default = increment_order_id,
null = True, blank = True,
editable = False,
unique = True,
)
commentary = models.TextField(
max_length = 1024,
null = True, editable = True, blank = True)
class Meta:
db_table = 'price_orders'
verbose_name = 'Прайс: Заказ'
verbose_name_plural = 'Прайс: Заказы'
ordering = ('status', '-created', '-id')
def __str__(self):
username = ''
if self.user:
username = self.user.username
return('{order_id} ({user}): {status} - Total: {total}'.format(
order_id = self.order_id,
user = username,
status = self.ORDER_STATUS[self.status][-1],
total = self.total()
))
def total(self):
total = Decimal('0.00').quantize(Decimal('0.01'))
#for item in self.orderitems.all().filter(status=True):
for item in self.orderitems.all():
total = total + Decimal(item.total()).quantize(Decimal('0.01'))
return str(total)
def new_allowed(self):
return new_order_allowed(self)
class OrderItems(models.Model):
#id = models.AutoField(primary_key = True)
order = models.ForeignKey(
Order,
null = True,
on_delete = models.PROTECT,
related_name = 'orderitems'
)
item = models.ForeignKey(
MainGoods,
on_delete = models.DO_NOTHING,
related_name = 'in_orders',
)
quantity = models.IntegerField(
default = 1,
null = False
)
price = models.DecimalField(
max_digits = 15, decimal_places = 2, default = 0.00
)
class Meta:
db_table = 'price_orders_goods'
verbose_name = 'Прайс: Товар заказа'
verbose_name_plural = 'Прайс: Товары заказов'
ordering = ('id',)
def __str__(self):
return ('{order} - {item}'.format(
order = self.order,
item = self.item
))
def description(self):
# If items is linked, return item's description
if self.item:
return str(self.item.name)
else:
return 'Order Item'
def total(self):
total = (self.price * Decimal(self.quantity)).quantize(Decimal('0.01'))
return str(total)
Serializers и Views
Когда с описанием моделей закончено, мы приступаем к написанию необходимых нам сериализаторов (Serializers) и представлений (Views).
В этих двух местах будет заключена вся логика нашего бэкенда. Именно тут мы будем определять, как выводить данные, кому их показывать, в каком виде, как фильтровать и т.д. И не только показывать, но и модифицировать HTTP-запросами.
Мы еще не сделали наш фронтенд, но мы примерно представляем, как он будет выглядеть в отношении запрашиваемых данных. Например, таблица прайса - должна содержать в себе товары, их описание, остаток, который мы будем маскировать с определенного значения и два типа цены - Рекомендуемая Розничная и цена, доступная нашему контрагенту.
И мы понимаем, что эту информацию будет гораздо эффективнее сразу отдать одним запросом, а не формировать на фронтенде из нескольких микрозапросов. Поэтому мы создаем один view, в котором отдадим всю требуемую информацию и на который повесим нужные фильтры.
Класс для отображения товаров в прайсе
class MainGoodsSimpleFilter(filters.FilterSet):
class Meta:
model = MainGoods
fields = {
'name': ['icontains',],
'article': ['icontains',],
'name_brand': ['icontains',],
'id_parent': ['exact', 'in'],
'id_brand': ['exact', 'in'],
'category_new': ['exact',],
'category_promo': ['exact',],
'category_sale': ['exact',],
'prices__id_price': ['exact'],
'prices__value': ['lt', 'lte', 'gt', 'gte'],
'rests__value': ['lte', 'gte'],
}
class MainGoodsViewSet(viewsets.ReadOnlyModelViewSet):
queryset = MainGoods.objects.all()
permission_classes = (permissions.AllowAny,)
serializer_class = MainGoodsSerializer
filter_backends = (
ComplexFilterBackend,
drf_filters.SearchFilter,
drf_filters.OrderingFilter,
)
filterset_class = MainGoodsSimpleFilter
search_fields = ['name', 'article', 'name_brand']
ordering_fields = ('name', 'article', 'name_brand', 'prices__value', 'prices__id_price', 'rests__value')
#ordering = ('name',)
def get_queryset(self):
qs = self.queryset
qpu_lte = self.request.query_params.get('price_user__lte', None)
qpu_gte = self.request.query_params.get('price_user__gte', None)
if qpu_lte or qpu_gte:
price_id = get_price_id(self.request)
if qpu_lte:
qs = qs.filter(prices__id_price = price_id['user'], prices__value__lte = qpu_lte)
else:
qs = qs.filter(prices__id_price = price_id['user'], prices__value__gte = qpu_gte)
return qs
Тем, кто плотно занимается 1С, эта логика будет вполне понятна. Есть некий отчет view, написанный программно на построителе. Есть запрос - queryset, есть фильтры и отборы построителя, которые могут применяться, а могут и не применяться - search_fields, filterset, есть поля сортировки - ordering_fields. Ну и, конечно, наш построитель - serializer_class. Все это, естественно, несколько сложнее, но любое понимание сложного начинается с раскладывания его на простые части.
Выбор полей для нашего отчета - это как раз к сериализации.
Вот так выглядит наш класс MainGoodsSerializer
class MainGoodsSerializer(serializers.ModelSerializer):
id_parent = MainCatalogStructureSerializer(required = False)
id_brand = SvcBrandsSerializer(required = False)
id_unit = SvcUnitsSerializer(required = False)
id_country = SvcCountriesSerializer(required = False)
#prices = serializers.SerializerMethodField(read_only = True)
price_pub = serializers.SerializerMethodField(read_only = True)
price_user = serializers.SerializerMethodField(read_only = True)
#rests = MainRestsSerializer(many = False, read_only = True)
rests = serializers.SerializerMethodField(read_only = True)
price_id = None
class Meta:
model = MainGoods
fields = ('id', 'name', 'code', 'article', 'description', 'package_qty',
'id_parent', 'name_parent_good',
'id_brand', 'name_brand',
'id_unit', 'name_unit',
'id_country', 'name_country',
'image_path',
'sku_pcs', 'sku_pkg',
'category_new', 'category_promo', 'category_sale',
'price_pub', 'price_user',
#'prices',
'rests',
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
context = kwargs.get('context', None)
if context:
request = kwargs['context']['request']
self.price_id = get_price_id(request)
def get_price_pub(self, obj):
if self.price_id:
if self.price_id['public']:
qs = obj.prices.filter(id_price = self.price_id['public']).first()
else:
return []
return MainPricesSerializer(
qs, many = False,
context = {'request': self.context.get("request", None)}).data
else:
return []
def get_price_user(self, obj):
if self.price_id:
if self.price_id['user']:
qs = obj.prices.filter(id_price = self.price_id['user']).first()
else:
return []
return MainPricesMarkupSerializer(
qs, many = False,
context={'request': self.context.get("request", None),
'markup': self.price_id['markup']}).data
else:
return []
def get_rests(self, obj):
qs = obj.rests.all().first()
return MainRestsSerializer(
qs, many = False,
context = {'request': self.context.get("request", None)}).data
И, естественно, мы можем эти поля модифицировать еще на этапе получения.
Если вы обратили внимание, некоторые поля у нас ссылаются на другие сериализаторы, например
id_brand = SvcBrandsSerializer(required = False)
Который очень прост
class SvcBrandsSerializer(serializers.ModelSerializer):
class Meta:
model = SvcBrands
fields = ('id', 'name')
Если мы еще раз взглянем на нашу модель товаров, то увидим
class MainGoods(models.Model):
...
id_brand = models.ForeignKey('SvcBrands',
models.SET_NULL,
related_name = 'goods',
db_column = 'id_brand', blank = True, null = True)
Т.е. поле id_brand - это ссылка, ForeignKey на соотв. запись в таблице SvcBrands, one-to-many relational field. Поэтому на этапе сериализации мы можем при получении объекта автоматически получить не только ID нашего брэнда, но и все остальные сериализуемые поля из связанной таблицы. И view отдаст нам данные не в виде представления поля:
id_brand: '123123'
А уже как объект:
id_brand: {
id: '123123',
name: 'SAKURA'
}
Собственно, таким образом мы делаем сериализацию и представления для всех запросов, которые планируем использовать. Не только товары для прайса, но и запрос\модификация информации профиля авторизованного пользователя:
# -------------- Profile
class ProfileSimpleFilter(filters.FilterSet):
class Meta:
model = Profile
fields = {
'id': ['exact',],
'id_one_c': ['exact',],
'id_owner': ['exact', ],
'id_manager': ['exact', ],
'id_price': ['exact', ],
'name': ['icontains', ],
'name_owner': ['icontains', ],
}
class ProfilesViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Profile.objects.all()
serializer_class = ProfileSerializer
permission_classes = (permissions.IsAuthenticated, )
pagination_class = None
#filterset_class = ProfileSimpleFilter
def get_queryset(self):
user = self.request.user
if user.is_anonymous:
raise exceptions.NotAuthenticated(
detail = 'User was not provided to view his profile.')
return Profile.objects.filter(user = user)
class ProfileSecretFilter(filters.FilterSet):
class Meta:
model = Profile
fields = {
'id_one_c': ['exact',],
'id_owner': ['exact', ],
'id_manager': ['exact', ],
}
class ProfilesPostView(viewsets.ModelViewSet):
queryset = Profile.objects.all()
serializer_class = ProfilePOSTSerializer
permission_classes = (permissions.AllowAny, )
pagination_class = None
filterset_class = ProfileSecretFilter
def validate_secret(self, secret):
if not secret:
return False
return secret == PROFILE_SECRET
def create_update_call(self, data):
operation = data.get('operation')
username = data['username']
user = User.objects.filter(username = username).first()
results = {
'user': '',
'profile': '',
'data': None,
'errors': None
}
# ---------------------------------------------------------------
if operation == 'update_password':
password = data['password']
if user:
user.set_password(password)
user.save()
results['user'] = 'Password for user: %s - Set' % username
return Response(results, status = status.HTTP_200_OK,)
else:
results['errors'] = 'User with login name: %s - Not found' % username
return Response(results, status = status.HTTP_404_NOT_FOUND)
# ---------------------------------------------------------------
elif operation == 'create_new':
if user:
results['user'] = 'User with login: %s - Updated' % username
serialized = UserPOSTSerializer(user, data = data)
else:
results['user'] = 'User with login: %s - Created' % username
serialized = UserPOSTSerializer(data = data)
if serialized.is_valid():
serialized.save()
# user is saved. Profile auto-created
# Fill profile with provided data
return self.override_profile(data, results)
else:
results['errors'] = serialized.errors
return Response(results, status = status.HTTP_400_BAD_REQUEST)
# ---------------------------------------------------------------
elif operation == 'deactivate':
if user:
if not user.is_active:
results['errors'] = 'User with login: %s - Already deactivated' % username
return Response(results, status = status.HTTP_304_NOT_MODIFIED)
user.is_active = False
user.save()
results['user'] = 'User with login: %s - Deactivated' % username
return Response(results, status = status.HTTP_200_OK)
else:
results['errors'] = 'User with login: %s - Not found' % username
return Response(results, status = status.HTTP_404_NOT_FOUND)
# ---------------------------------------------------------------
elif operation == 'activate':
if user:
if user.is_active:
results['errors'] = 'User with login: %s - Already active' % username
return Response(results, status = status.HTTP_304_NOT_MODIFIED)
user.is_active = True
user.save()
results['user'] = 'User with login: %s - Activated' % username
return Response(results, status = status.HTTP_200_OK)
else:
results['errors'] = 'User with login: %s - Not found' % username
return Response(results, status = status.HTTP_404_NOT_FOUND)
# ---------------------------------------------------------------
else:
# regular update of profile. User fields excluded
#if data['password']:
# data.pop('password')
if user:
return self.update_profile(data, user, results)
else:
results['errors'] = 'User with login: %s - Not found' % username
return Response(results, status = status.HTTP_404_NOT_FOUND)
def update_profile(self, data, user, results):
profile = Profile.objects.filter(user = user).first()
if profile:
serialized = ProfilePOSTSerializer(profile, data = data)
if serialized.is_valid():
serialized.save()
results['profile'] = 'Updated'
results['data'] = serialized.data
return Response(results, status = status.HTTP_200_OK)
else:
results['errors'] = serialized.errors
return Response(results, status = status.HTTP_400_BAD_REQUEST)
else:
return Response({'details': 'Profile not found'}, status = status.HTTP_404_NOT_FOUND)
def override_profile(self, data, results):
username = data['username']
user = User.objects.filter(username = username).first()
if user:
profile = Profile.objects.filter(user = user).first()
results['profile'] = 'Updated'
if not profile:
profile = Profile.objects.create(user = user)
results['profile'] = 'Created'
serialized = ProfilePOSTSerializer(profile, data = data)
if serialized.is_valid():
serialized.save()
results['data'] = serialized.data
return Response(results, status = status.HTTP_200_OK)
else:
results['errors'] = serialized.errors
return Response(results, status = status.HTTP_400_BAD_REQUEST)
else:
results['errors'] = 'Just created user: %s was not found. This is bad.' % username
return Response(results, status = status.HTTP_404_NOT_FOUND)
def create(self, request, *args, **kwargs):
secret_valid = self.validate_secret(request.META.get('HTTP_SECRET'))
#pprint(request.META)
#pprint(request.data)
if not secret_valid:
return Response(
{'details': 'Not available'},
status = status.HTTP_400_BAD_REQUEST)
return self.create_update_call(self.request.data.copy())
def list(self, request, *args, **kwargs):
secret_valid = self.validate_secret(request.META.get('HTTP_SECRET'))
if not secret_valid:
return Response(
{'details': 'Not available'},
status = status.HTTP_400_BAD_REQUEST)
#id_one_c = request.query_params.get('id_one_c')
#id_owner = request.query_params.get('id_owner')
#id_manager = request.query_params.get('id_manager')
if request.query_params == {}:
return Response({'details': 'Expected filters'}, status = status.HTTP_400_BAD_REQUEST)
qs = self.filter_queryset(self.get_queryset())
serializer = ProfileSerializer(qs, many = True)
return Response(serializer.data)
И запрос\модификация к нашим моделям Order и OrderItems:
ViewSet заказов и товаров заказа
# -------------- Order & OrderItems
class OrderSimpleFilter(filters.FilterSet):
class Meta:
model = Order
fields = {
'id': ['exact',],
'status': ['exact', 'gte', 'lte', 'gt', 'lt', ],
'order_id': ['exact', ],
}
class OrderItemsSimpleFilter(filters.FilterSet):
class Meta:
model = OrderItems
fields = {
'id': ['exact',],
'order': ['exact',],
}
class OrderViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Order.objects.all()
serializer_class = OrderSerializer
permission_classes = (permissions.AllowAny, )
filterset_class = OrderSimpleFilter
pagination_class = None
# Custom action decorator
# his decorator can be used to add any custom endpoints that
# don't fit into the standard create/update/delete style
#@action(detail=True, renderer_classes = [renderers.StaticHTMLRenderer])
def perform_create(self, serializer):
serializer.save(user = self.request.user)
def get_queryset(self):
user = self.request.user
if user.is_anonymous:
# check for secret key
if self.request.META.get('HTTP_MASTERKEY'):
if self.request.META.get('HTTP_MASTERKEY') == ORDER_SECRET:
return Order.objects.all()
raise exceptions.NotAuthenticated(
detail = 'User was not provided to view order items.')
return Order.objects.filter(user = user)
class OrderItemsViewSet(viewsets.ReadOnlyModelViewSet):
queryset = OrderItems.objects.all()
serializer_class = OrderItemsSerializer
permission_classes = (permissions.AllowAny,)
filterset_class = OrderItemsSimpleFilter
pagination_class = None
def get_queryset(self):
# First - check user
user = self.request.user
if user.is_anonymous:
if self.request.META.get('HTTP_MASTERKEY'):
if self.request.META.get('HTTP_MASTERKEY') == ORDER_SECRET:
order_id = self.request.query_params.get('order_id')
if not order_id:
raise exceptions.NotAcceptable(
detail = 'Order ID was not provided.')
order_item = Order.objects.filter(order_id = order_id).first()
if not order_item:
raise exceptions.NotFound(
detail = 'Order by this ID not found.')
return OrderItems.objects.filter(order = order_item)
# All else require user
raise exceptions.NotAuthenticated(
detail = 'User was not provided to view order items.')
# Second - get order_id as a param
order_id = self.request.query_params.get('order_id')
if not order_id:
# get first order with status 0
order_item = Order.objects.filter(status = 0, user = user).first()
if not order_item:
raise exceptions.APIException(
detail = 'Order ID was not provided in order_id param')
else:
order_id = order_item.id
order_item = Order.objects.filter(id = order_id, user = user).first()
if not order_item:
raise exceptions.NotFound(
detail = 'Order by ID: %s was not found for this user' % order_id)
return OrderItems.objects.filter(order = order_item)
class OrderPOSTView(viewsets.ModelViewSet):
queryset = Order.objects.all()
serializer_class = OrderPOSTSerializer
permission_classes = (permissions.IsAuthenticated, )
def list(self, request, *args, **kwargs):
return Response({'status': 'Not available'})
def create(self, request, *args, **kwargs):
return Response({'details': 'Direct creation not allowed'}, status = status.HTTP_400_BAD_REQUEST)
def partial_update(self, request, *args, **kwargs):
kwargs['partial'] = True
if 'perform_send' in request.data.keys():
if request.data['perform_send'] == 'true':
if 'pk' in kwargs.keys():
pk = kwargs['pk']
send_mail_order(pk)
return self.update(request, *args, **kwargs)
class OrderItemsPOSTView(viewsets.ModelViewSet):
queryset = OrderItems.objects.all()
serializer_class = OrderItemsPOSTSerializer
permission_classes = (permissions.IsAuthenticated, )
def list(self, request, *args, **kwargs):
return Response({'status': 'Not available'})
def create(self, request, *args, **kwargs):
results = {'order': '',
'item': '',
'data': None,
'errors': ''}
# first - user and profile
user = request.user
if user.is_anonymous:
results['errors'] = 'Anonymous orders not allowed'
return Response(results, status = status.HTTP_400_BAD_REQUEST)
try:
profile = user.profile
except:
results['errors'] = 'User has no Profile'
return Response(results, status = status.HTTP_400_BAD_REQUEST)
# Get order with status 0 (active) or create one
try:
order = self.get_order_or_create(user)
except Exception as e:
results['errors'] = e
return Response(results, status = status.HTTP_400_BAD_REQUEST)
# Check if item already in order
# If it's so - add quantity to it
# Else - create new string
r_data = request.data.copy()
r_data['order'] = order.id
item_id = r_data['item']
item_in_order = order.orderitems.filter(item = item_id).first()
if item_in_order:
# already in order. Increase QTY, update price
serialized = OrderItemsPOSTSerializer(item_in_order, data = r_data)
else:
# add item to order
serialized = OrderItemsPOSTSerializer(data = r_data)
if serialized.is_valid():
serialized.save()
results['data'] = serialized.data
return Response(results, status = status.HTTP_200_OK)
else:
results['errors'] = serialized.errors
return Response(results, status = status.HTTP_400_BAD_REQUEST)
# Finish
def get_order_or_create(self, user):
order = Order.objects.filter(user = user.id, status = 0).first()
if not order:
serialized = OrderPOSTSerializer(data = {'user': user.id})
if serialized.is_valid():
serialized.save()
return Order.objects.filter(user = user.id, status = 0).first()
else:
raise Exception(serialized.errors)
else:
return order
Вместо авторизации из 1С каким-то особым пользователем, я решил применить метод секретных ключей в самих запросах. Да, это менее безопасно, но эти запросы у нас будут делаться только сервером 1С с использованием SSL-соединений.
Авторизация пользователей
Авторизацию мы полностью отдаем на расширение для Django - Djoser. Как только мы зарегистрировали его в своем конфиге, добавили его url в urls.py - нам уже доступны методы управления пользователем через HTTP-запросы. Мы можем получить authorization_token, отправив правильный POST-запрос с логином\паролем на нужный url, а потом добавлять этот токен в наш ajax.headers, и наш бэкенд будет видеть нас как авторизованного пользователя. У Djoser нет механизмов token_expiration, однажды выданный пользователю токен будет валиден, пока его не уничтожат или пока пользователь не запросит для себя новый токен. Token_expiration можно реализовать самостоятельно, но для нашего проекта в этом не было необходимости.
Отправка почты
То, что мы используем DRF, никак не ограничивает нас в использовании всех остальных возможностей python. Наши "представления" (Views) вовсе не обязаны ссылаться на какие-то модели и сериализации. Мы можем объявить любой удобный для нас url и привязать к нему свою процедуру обработки. Так и происходит в нашем случае с отправкой почты.
from django.conf.urls import url, include
from django.urls import path
from . import views
urlpatterns = [
...
# -------- POST service requests
path('service/account/new/', views.service_account_new),
]
@api_view(['POST',])
@permission_classes((permissions.AllowAny,))
def service_account_new(request):
if request.method == 'POST':
if 'validation' in request.data.keys():
validation = request.data.get('validation')
if validation is not None and validation != '':
contact_mail = request.data.get('reg_info[email]')
if contact_mail is not None and contact_mail != '':
send_mail_new_account(request.data)
return Response({'details': 'Mail sent'}, status = status.HTTP_200_OK)
return Response({'details': 'Invalid request'}, status = status.HTTP_400_BAD_REQUEST)
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.header import Header
def create_smtp_connection(login, password):
smtp_object = smtplib.SMTP(EMAIL_HOST, EMAIL_PORT, timeout = 10)
smtp_object.login(login, password)
return smtp_object
def create_multipart_msg(text, html, subject, mail_from, mail_to):
msg = MIMEMultipart('alternative')
msg['Subject'] = Header("{0}".format(subject), 'utf-8')
msg['From'] = mail_from
msg['To'] = mail_to
part1 = MIMEText(text, 'plain', 'utf-8')
part2 = MIMEText(html, 'html', 'utf-8')
msg.attach(part1)
msg.attach(part2)
return msg
def send_mail_new_account(data):
reg_info = {
'organization': data.get('reg_info[organization]'),
'inn': data.get('reg_info[inn]'),
'region': data.get('reg_info[region]'),
'contact_name': data.get('reg_info[contact_name]'),
'contact_phone_primary': data.get('reg_info[contact_phone_primary]'),
'contact_phone_secondary': data.get('reg_info[contact_phone_secondary]'),
'email': data.get('reg_info[email]'),
'is_buyer': 'Да' if data.get('reg_info[is_buyer]') == 'true' else 'Нет',
'is_seller': 'Да' if data.get('reg_info[is_seller]') == 'true' else 'Нет',
'commentary': data.get('reg_info[commentary]'),
}
commentary = reg_info['commentary']
if commentary is None:
commentary = ''
subject = "Запрос на регистрацию в Онлайн-прайсе: {0}".format(reg_info['organization'])
txt = "Пользователь предоставил следующую информацию:\n" \
"\nНазвание организации: {organization}" \
"\nИНН: {inn}" \
"\nРегион: {region}" \
"\n\nКонтактное лицо: {contact_name}" \
"\nКонтактный телефон: {contact_phone_primary}" \
"\nДополнительный телефон: {contact_phone_secondary}" \
"\nE-mail: {email}" \
"\n\n Выступает как:" \
"\nПокупатель: {is_buyer}" \
"\nПоставщик: {is_seller}" \
"\n\nКомментарий:" \
"\n{commentary}".format(
organization = reg_info['organization'],
inn = reg_info['inn'],
region = reg_info['region'],
contact_name = reg_info['contact_name'],
contact_phone_primary = reg_info['contact_phone_primary'],
contact_phone_secondary = reg_info['contact_phone_secondary'],
email = reg_info['email'],
is_buyer = reg_info['is_buyer'],
is_seller = reg_info['is_seller'],
commentary = commentary,
)
commentary = "<br />".join(commentary.split("\n"))
html = """\
<html>
<head></head>
<body>
<h3>Поступил запрос на регистрацию в <span style="color:red;">Онлайн-прайсе СДТ</span></h3>
<p>Пользователь предоставил следующую информацию:</p>
<ul>
<li>Название организации: {organization}</li>
<li>ИНН: {inn}</li>
<li>Регион: {region}</li>
</ul>
<ul>
<li>Контактное лицо: {contact_name}</li>
<li>Контактный телефон: {contact_phone_primary}</li>
<li>Дополнительный телефон: {contact_phone_secondary}</li>
<li>E-mail: <a href="mailto:{email}">{email}</a></li>
</ul>
<p><b>Выступает как:</b></p>
<ul>
<li>Покупатель: {is_buyer}</li>
<li>Поставщик: {is_seller}</li>
</ul>
<p><b>Комментарий:</b></p>
<p>{commentary}</p>
</body>
</html>
""".format(
organization = reg_info['organization'],
inn = reg_info['inn'],
region = reg_info['region'],
contact_name = reg_info['contact_name'],
contact_phone_primary = reg_info['contact_phone_primary'],
contact_phone_secondary = reg_info['contact_phone_secondary'],
email = reg_info['email'],
is_buyer = reg_info['is_buyer'],
is_seller = reg_info['is_seller'],
commentary = commentary,
)
mail_from = reg_info['email']
mail_to = ACCOUNT_REQUEST_MAIL
msg = create_multipart_msg(txt, html, subject, mail_from, mail_to)
smtp_object = create_smtp_connection(EMAIL_HOST_USER, EMAIL_HOST_PASSWORD)
smtp_object.sendmail(EMAIL_HOST_USER, ACCOUNT_REQUEST_MAIL, msg.as_string())
smtp_object.quit()
Итог
Первая версия нашего бэкенда готова. На следующих этапах мы можем возвращаться сюда и вносить правки, специфика Django позволяет вносить изменения в код в процессе работы приложения. Как только Django зарегистрирует изменения в коде - он автоматически перезапустится сам, а удобный дебаггер покажет с точностью до символа, где что не так и даже предположит, что нужно сделать.
Для входа в Django и DRF я рекомендую пройти начальный курс на youtube. Например, вот этот русскоязычный канал. Потратьте на него недельку и вы уже будете понимать, что вы делаете и что вам нужно сделать. Там же вы найдете и практические применение написанного бэкенда при разработке фронта на том же Vue.js
Часть 4. Фронтенд и Vue.js
До этого я не сталкивался с фронтенд фреймворками на javascript. Только всякие CMS типа джумлы, ворпресса или битрикса. Я не стал останавливаться на REACT.js или Angular.js в виду того, что Vue позиционирует себя как очень простой, легкий как в работе, так и в освоении инструмент. Достаточно понять его логику, пройти пару туториалов и вы готовы начинать. Все ваши дальнейшие вопросы будут скорее касаться непосредственно JS, а не самого Vue. И в некоторых случаях ваша идея уже будет иметь практически готовое решение в виде компоненты для Vue.
При выборе инструментов помимо Vue, я остановился на популярном Material Design компонентном фреймворке Vuetify. Здесь вы найдете практически все необходимые компоненты, чтобы нарисовать ваш сайт.
Для хранения и модификации глобальных переменных, я использовал Vuex. Тоже общепринятая практика, рекомендуется один раз понять, как им пользоваться и больше не искать ответ на вопрос "а как взять переменную А и чтобы она была единой для компонента Б1 и Д2? И чтобы оба компонента реактивно реагировали на ее изменение?"
Сразу хочу сказать, красота и удобство тут заключается не в том, что кода мало и он красивый. Нет. Кода будет много, много абстракции, но через какое-то время придет понимание происходящего.
В основе своей, страница на Vue описывается двумя обязательными и одним необязательным компонентом.
- template
- script
- style *scoped
В template вы расписываете визуализацию вашей страницы. С использованием тэгов html или тэгов компонент.
<template>
<v-app class="bg_main">
<Navbar></Navbar>
<v-content v-scroll="on_scroll">
<v-container grid-list-md class="mx-1 my-1" fluid>
<router-view></router-view>
</v-container>
</v-content>
<v-footer height="auto" color="bg_navbar">
<v-card class="flex" flat tile>
<v-card-title class="bg_navbar">
<a @click="$router.push('/policy')"
class="text-xs-center caption mx-3">
<v-icon small class="primary--text">security</v-icon>
Политика конфиденциальности
</a>
<ContactForm></ContactForm>
<v-spacer></v-spacer>
<div>
<v-btn
v-for="social in socials"
:key="social.id" :to="social.href"
class="mx-3 secondary--text"
icon
>
<simple-svg style="margin-top: 5px;"
:filepath="social.icon"
fill="#37474F"
width="24px" height="24px"></simple-svg>
</v-btn>
</div>
</v-card-title>
<v-card-actions class="justify-center secondary bg_navbar--text">
<span>©2019</span>
</v-card-actions>
</v-card>
</v-footer>
<div class="hidden-sm-and-down">
<v-fab-transition>
<v-btn fixed dark fab
bottom right color="primary"
v-show="btn_to_top"
@click="scroll_to_top"
>
<v-icon>keyboard_arrow_up</v-icon>
</v-btn>
</v-fab-transition>
</div>
<div class="hidden-md-and-up">
<v-fab-transition>
<v-btn fixed dark fab small
bottom right color="primary"
v-show="btn_to_top"
@click="scroll_to_top"
>
<v-icon>keyboard_arrow_up</v-icon>
</v-btn>
</v-fab-transition>
</div>
</v-app>
</template>
В script - код вашего компонента, переменные, методы и т.д.
<script>
import Navbar from '@/components/Navbar'
import ContactForm from '@/components/ContactForm'
export default {
name: 'App',
data() {
return {
// to-top
btn_to_top: false,
socials: [
{id: 1, icon: '/content/icons/logo-vk.svg', href: ''},
{id: 2, icon: '/content/icons/logo-facebook.svg', href: ''},
{id: 3, icon: '/content/icons/logo-twitter.svg', href: ''},
{id: 4, icon: '/content/icons/logo-instagram.svg', href: ''},
],
}
},
components: {
Navbar,
ContactForm,
},
created() {
this.$store.dispatch('logged_state_init', true);
},
methods: {
// Offset scroll
on_scroll(e) {
this.btn_to_top = e.target.scrollingElement.scrollTop > 0;
},
scroll_to_top() {
window.scrollTo({ top: 0, behavior: 'smooth' });
},
},
}
</script>
В style - css для каких-то элементов, если это необходимо. Если добавить scoped - эти css будут применяться только для элементов, описанных в данном компоненте.
<style>
.btn-mini {
min-width: 10px;
width: 36px;
}
.mob-price-total {
position: relative;
bottom: -8px;
left: -10px;
}
.table-container {
display: inline-block;
width: 100%;
}
table.v-table thead td:not(:nth-child(1)),
table.v-table tbody td:not(:nth-child(1)),
table.v-table thead th:not(:nth-child(1)),
table.v-table tbody th:not(:nth-child(1)),
table.v-table thead td:first-child,
table.v-table tbody td:first-child,
table.v-table thead th:first-child,
table.v-table tbody th:first-child {
padding: 0 10px;
}
.mob-s-item-title {
padding-bottom: 5px;
padding-top: 5px;
}
.toc-ul {
list-style-type: disc;
}
.toc-ul ul{
list-style-type: circle;
}
.toc-ul a{
color: #B71C1C;
}
code:before {
content: none;
}
</style>
Как вы уже, наверное, поняли, style - необязательный.
Как именно и с какой логикой вы будете строить свой сайт - это уже ваша фантазия.
Ниже будет ссылка на итоговый проект. Могу показать, как выглядит структура фронтенда.
И больше всего кода тут всегда сконцентрировано в .vue-файлах. И то только из-за толстых template секций.
Если вас интересует, как же происходит общение с бэк-эндом. Очень просто - через ajax и jquery
Выжимки из script компоненты PriceTable
import $ from 'jquery'
import { Globals } from '@/globals.js';
...
created() {
if(sessionStorage.getItem('auth_token')) {
$.ajaxSetup({
headers: {'Authorization': 'Token ' + sessionStorage.getItem('auth_token')},
});
}
this.load_brands();
this.load_items();
this.$eventBus.$on('EVENT_price_view_catalog', this.EVENT_price_view_catalog);
// autoload chunks
window.addEventListener('scroll', () => {
this.bottom = this.bottom_visible();
});
this.load_next_chunk();
},
...
methods: {
load_items() {
var sta = this.get_load_settings();
if (!sta.proceed_load)
return;
this.is_loading_table = true;
this.show_alert('accent', 'update', 'Идет загрузка...');
$.ajax({
url: '' + Globals.API.ROOT + Globals.API.MAIN + 'goods/',
type: 'GET',
data: sta.payload_data,
success: (response) => {
if (this.is_loading_chunk)
this.payload_list = this.payload_list.concat(response.results);
else
this.payload_list = response.results;
this.payload_meta = response.meta;
this.payload_links = response.links;
this.pagination.selected_page = this.payload_meta.pagination.page;
// stop loading animation
// Show Nothing found error
if (this.payload_meta.pagination.count === 0){
this.show_alert('primary', 'priority_high', 'Ничего не найдено в текущем представлении!')
}
},
error: (response) => {
if (response.status === 400){
this.show_alert('primary', 'priority_high', 'HTTP 400: Bad request!')
} else {
this.show_alert('primary', 'priority_high', 'Нет данных!')
}
// set defaults
this.drop_table();
},
complete: () => {
this.stop_loading();
}
})
},
Простой пример POST запроса из секции script в компоненте NewAccount.vue
new_account_send_mail(reg_info, recaptcha_validation) {
$.ajax({
url: '' + Globals.API.ROOT + Globals.API.MAIN + 'service/account/new/',
type: 'POST',
data: {
reg_info: reg_info,
validation: recaptcha_validation,
},
success: () => {
this.show_snack('success', 'Запрос отправлен');
this.dialog_reg_sent = true;
this.resetForm();
},
error: (response) => {
this.show_snack('error', 'Ошибка: ' + response.status);
},
})
},
Да, оно все настолько просто. Главное, это заслать на бэкенд информацию в нужном виде, как и получить от DRF информацию в нужном виде, а значит и хранить эту информацию в БД в нужном виде, а значит и выгрузить ее из 1С в нужно виде.
Тем не менее, создание фронтенда заняло у меня больше всего времени. Для меня эта была самая интересная часть и если на все, что было до этого я потратил две недели, то vue я занимался месяц и даже брал две недели отпуска, чтобы плотно за ним посидеть (читай "сутками").
Как и в Django, development-mode Vue (npm run serve) позволяет реагировать на изменения в коде и автоматически обновляться отображение страницы в браузере. Т.е., вы сидите за двумя мониторами, в одном открыт ваш сайт на localhost, в другом IDE (pycharm, в моем случае). Вы что-то меняете в компоненте vue и это сразу визуализируется в окне браузера. Вы сразу видите результат. А еще для chrome и firefox есть расширение, которое в dev-mode на вашем сайте может показать прямо в браузере, в page-inspection, все ваши переменные отображаемого компонента. Поищите Vue.js devtools
Я не буду больше расписывать какие-то элементы с этого этапа, в конце концов, там все сводится к стандартной js-акробатике. Преобразование и манипуляция с данными, их обработка по событиям, хранение каких-то значений в local storage браузера и т.д.
Часть 5. Возвращаемся в 1С и заканчиваем
По сути, мы могли не дожидаться, пока закончим четвертую часть. Для реализации этих вещей нам достаточно готового бэкенда.
Сейчас нам нужно решить две вещи:
- Управление пользователями сайта из 1С
- Загрузка заказов с сайта в 1С
Управление пользователями
Для этого мы создадим в нашей конфигурации новый справочник - ПользователиОнлайнПрайса. Он у нас будет подчиненным справочнику Контрагенты.
В реквизитах справочника мы разместим все те поля, что участвуют в моделях Profile и User на сайте.
Мы больше не будем заниматься формированием каких-то XML, зачем, ведь у нас уже есть API, который готов обрабатывать HTTP-запросы.
Код кнопки "Создать профиль"
Процедура Кнопка_СоздатьПрофильНажатие(Элемент)
СтрокаОшибки = "Не заполнены реквизиты: ";
// Модифицированность
Если ЭтаФорма.Модифицированность Тогда
Предупреждение("Перед выполнением необходимо записать элемент!");
Возврат;
КонецЕсли;
Если НЕ ПроверкаЗаполнения(СтрокаОшибки) Тогда
Предупреждение(СтрокаОшибки,,"Не заполнены реквизиты");
Возврат;
КонецЕсли;
URL_Profiles = "/example/api/profiles/";
Соединение = ОнлайнПрайс.СоздатьСоединение(АдресСервера, ПортСервера);
ЧтениеJSON = Новый ЧтениеJSON();
СтруктураДанных = ПолучитьПолнуюСтруктуруПрофиля();
ЗаголовокPOST = ОнлайнПрайс.ПолучитьЗаголовок_POST(URL_Profiles, , Истина);
ТелоЗапроса = ОнлайнПрайс.ПолучитьТелоСтроки(СтруктураДанных);
Результат = ОнлайнПрайс.ВыполнитьЗапрос(Соединение, URL_Profiles, ЗаголовокPOST, ТелоЗапроса);
ПолучитьДанныеССервера();
ОбновитьВидимость();
Если Результат.КодСостояния = 200 Тогда
Предупреждение("Профиль создан");
Иначе
Предупреждение("Ошибка создания профиля!");
КонецЕсли;
ЗаписатьЛог(Результат.КодСостояния, Результат.ПолучитьТелоКакСтроку());
Если Результат.КодСостояния = 200 Тогда
Ответ = Вопрос("Отправить пользователю на адрес: " + мДанныеССайта.user.email + " письмо с информацией о входе (логин, пароль)?",
РежимДиалогаВопрос.ДаНет, , КодВозвратаДиалога.Да, "Отправка письма");
Если Ответ = КодВозвратаДиалога.Да Тогда
ОтправитьПисьмо("Новый пользователь", мДанныеССайта.user.email);
КонецЕсли;
КонецЕсли;
КонецПроцедуры
Код общего модуля "ОнлайнПрайс", часть процедур
Функция СоздатьСоединение(АдресAPI, Порт) Экспорт
ЗащищенноеСоединение = Новый ЗащищенноеСоединениеOpenSSL;
Соединение = Новый HTTPСоединение(АдресAPI, Порт,,,,5, ?(Порт = 443, ЗащищенноеСоединение, неопределено));
Возврат Соединение;
КонецФункции
Функция ПолучитьЗаголовок_POST(URL, ИспользоватьМастерКлюч = Ложь, ИспользоватьСекрет = Ложь) Экспорт
Заголовок = Новый Соответствие();
Заголовок.Вставить("POST " + URL + " HTTP/1.1");
Заголовок.Вставить("Content-Type", "application/x-www-form-urlencoded");
Если ИспользоватьМастерКлюч Тогда
Заголовок.Вставить("MasterKey", MasterKey());
КонецЕсли;
Если ИспользоватьСекрет Тогда
Заголовок.Вставить("secret", Secret());
КонецЕсли;
Возврат Заголовок;
КонецФункции
Функция ВыполнитьЗапрос(Соединение, URL, Заголовок, Тело = неопределено) Экспорт
Запрос = Новый HTTPЗапрос(URL, Заголовок);
Если Тело <> неопределено Тогда
Запрос.УстановитьТелоИзСтроки(Тело);
Возврат Соединение.ОтправитьДляОбработки(Запрос);
КонецЕсли;
Возврат Соединение.Получить(Запрос);
КонецФункции
Аналогично мы поведем себя и в случае с обработкой входящих заказов. У нас менеджер получает письмо, в котором сообщается, что клиент сделал заказ с сайта на сумму N, у заказа такой-то ID.
Менеджер копирует этот ID и вставляет его в поле обработки в 1С.
Функция получения товаров заказа с сайта
Функция ПолучитьССайта()
ИД_Заказа = СокрЛП(ИД_Заказа);
URL_Orders = "/api/orders/?order_id="+ИД_Заказа;
URL_Items = "/api/order-items/?order_id="+ИД_Заказа;
Соединение = ОнлайнПрайс.СоздатьСоединение(мАдресСайта, 443);
ЧтениеJSON = Новый ЧтениеJSON();
// Заказ
ЗаголовокGET = ОнлайнПрайс.ПолучитьЗаголовок_GET(URL_Orders, Истина);
Результат = ОнлайнПрайс.ВыполнитьЗапрос(Соединение, URL_Orders, ЗаголовокGET);
ЧтениеJSON.УстановитьСтроку(Результат.ПолучитьТелоКакСтроку());
СериализованныеДанные = ПрочитатьJSON(ЧтениеJSON);
Если НЕ ОнлайнПрайс.СериализованныеДанныеЗаказа_Валидны(Результат.КодСостояния, СериализованныеДанные) Тогда
#Если Клиент Тогда
Предупреждение("Ошибка получения информации о Профиле пользователя от API.");
Сообщить("Код возврата: " + Результат.КодСостояния + Символы.ПС + "Ответ: " + Результат.ПолучитьТелоКакСтроку());
#КонецЕсли
Возврат Ложь;
КонецЕсли;
// Raise
Отказ = Ложь;
мДанныеДляЗаполнения = ПолучитьСтруктуруЗаказа(СериализованныеДанные[0], Отказ);
Если Отказ Тогда
#Если Клиент Тогда
Предупреждение("Не удалось получить данные для заполнения шапки заказа.");
Сообщить("Код возврата: " + Результат.КодСостояния + Символы.ПС + "Ответ: " + Результат.ПолучитьТелоКакСтроку());
#КонецЕсли
Возврат Ложь;
КонецЕсли;
мДанныеДляЗаполнения.Комментарий = "ID: " + ИД_Заказа + " Наценка: [" + мМаркап + "]";
// Заполнение полей контрагента и договора
Контрагент = мДанныеДляЗаполнения.Контрагент;
ДоговорКонтрагента = мДанныеДляЗаполнения.ДоговорКонтрагента;
// Товары заказа
ЗаголовокGET = ОнлайнПрайс.ПолучитьЗаголовок_GET(URL_Items, Истина);
Результат = ОнлайнПрайс.ВыполнитьЗапрос(Соединение, URL_Items, ЗаголовокGET);
ЧтениеJSON.УстановитьСтроку(Результат.ПолучитьТелоКакСтроку());
СериализованныеДанные = ПрочитатьJSON(ЧтениеJSON);
Если НЕ ОнлайнПрайс.СериализованныеДанныеТоваров_Валидны(Результат.КодСостояния, СериализованныеДанные) Тогда
#Если Клиент Тогда
Предупреждение("Ошибка получения информации о товарах заказа от API.");
Сообщить("Код возврата: " + Результат.КодСостояния + Символы.ПС + "Ответ: " + Результат.ПолучитьТелоКакСтроку());
#КонецЕсли
Возврат Ложь;
КонецЕсли;
ТЧ_Товары = ДесериализоватьТоварыВТаблицу(СериализованныеДанные);
ЗаполнитьТаблицуПоДаннымAPI(ТЧ_Товары);
Возврат Истина;
КонецФункции
// Десериализация товаров
Функция ДесериализоватьТоварыВТаблицу(Данные)
ТЧ = Новый ТаблицаЗначений;
ТЧ.Колонки.Добавить("GUID");
ТЧ.Колонки.Добавить("Номенклатура");
ТЧ.Колонки.Добавить("Код");
ТЧ.Колонки.Добавить("Артикул");
ТЧ.Колонки.Добавить("ЕдиницаИзмерения");
ТЧ.Колонки.Добавить("Количество");
ТЧ.Колонки.Добавить("Цена");
ТЧ.Колонки.Добавить("Сумма");
ИНД = 0;
Для Каждого Товар ИЗ Данные Цикл
ИНД = ИНД + 1;
Попытка
GUID = Товар.item.id;
LinkedGUID = Новый УникальныйИдентификатор(GUID);
Номенклатура = Справочники.Номенклатура.ПолучитьСсылку(LinkedGUID);
Если НЕ ЗначениеЗаполнено(Номенклатура) Тогда
#Если Клиент Тогда
Сообщить("#" + ИНД + " Не удалось опознать объект по GUID: " + GUID + " Попытка найти по коду.");
#КонецЕсли
Код = Товар.item.code;
Номенклатура = Справочники.Номенклатура.НайтиПоКоду(Код);
Если НЕ ЗначениеЗаполнено(Номенклатура) Тогда
#Если Клиент Тогда
СтрокаСообщения = "#" + ИНД + " Не удалось опознать объект по Коду: " + Код + " - строка пропущена"
+ Символы.ПС + "ID по данным: " + Товар.id
+ Символы.ПС + "Наименование по данным: " + Товар.item.name
+ Символы.ПС + "Артикул по данным: " + Товар.item.article
+ Символы.ПС + "Количество по данным: " + Товар.quantity
+ Символы.ПС + "Цена по данным: " + Товар.price
+ Символы.ПС + "Сумма по данным: " + Товар.total + Символы.ПС + "---------------";
Сообщить(СтрокаСообщения);
#КонецЕсли
Продолжить;
КонецЕсли;
КонецЕсли;
СтрокаТЧ = ТЧ.Добавить();
СтрокаТЧ.GUID = GUID;
СтрокаТЧ.Номенклатура = Номенклатура;
СтрокаТЧ.Код = Номенклатура.Код;
СтрокаТЧ.Артикул = Номенклатура.Артикул;
СтрокаТЧ.ЕдиницаИзмерения = Номенклатура.ЕдиницаХраненияОстатков;
СтрокаТЧ.Количество = Товар.quantity;
СтрокаТЧ.Цена = Товар.price;
СтрокаТЧ.Сумма = Товар.total;
Исключение
#Если Клиент Тогда
Сообщить("#" + ИНД + "Не удалось десериализовать объект: " + Символы.ПС + ОписаниеОшибки());
#КонецЕсли
Продолжить;
КонецПопытки;
КонецЦикла;
Возврат ТЧ;
КонецФункции
На этом все, если фронтенд готов, можно начинать тестировать, а потом и работать.
Итог
Менеджер управляет аккаунтами своих клиентов прямо из 1С. Ему сайт в принципе не нужен, он на него не заходит и ничего там не делает. Менеджер как работал в 1С, так и продолжает в ней работать, необходимости осваивать какой-то новый инструмент и быть в каких-то местах еще у него нет. Для абсолютного удобства, функционал загрузки заказов был добавлен в ту же обработку, которой раньше менеджер грузил excel-прайсы с количествами заказа от клиента.
Покупатели теперь могут всегда получать актуальную (в разрезе дня) информацию о товарах, остатках и ценах, видеть историю своих заказов, изменение цен на товары из заказов в сравнении с ценами на текущую дату, ну и, конечно, менять свой логин\пароль\email. И самое главное - ему не нужно ждать актуальный прайс-лист на свою почту от менеджера, заполнять в нем колонку "Заказ", как и не нужно потом отправлять его менеджеру обратно.
Заключение
Если кого-то интересует, как оно в итоге выглядит - пожалуйста. Без авторизации сайт работает как публичное предложение.
Просьба не кидаться тухлыми яйцами, у меня тонкая ранимая натура (нет). У меня уже сейчас есть идеи, как сделать лучше там, как оптимизировать тут, улучшить безопасность здесь и т.д. Работа над сайтом будет продолжаться. Суть в том, что, зачастую, не все так сложно, как нам пытается преподнести подрядчик. Когда мы искали разработчика и озвучивали сроки с ценником (месяц за пятизначный), в одной компании мне ответили: "если вам где-то говорят, что оно стоит столько и готовы сделать это в такой срок - задумайтесь, это явно какие-то разводилы." Я более чем уверен, что опытный full-stack программист (без 1С) сделал бы все за этот месяц, сделал бы лучше и явно не за полмиллиона.
Навязывание многочасовой разработки там, где оно не нужно, разработка и проектирование уровня энтерпрайз-решений, когда вы хотите онлайн-магазин с 7-ю товарами и баннер с котиком.
Или наоборот, халтурное и дешевое выполнение с использованием уж совсем ужасных конструкторов, таких как джумла или вордпресс. Получаете на выходе много условностей, много костылей, сложности в реализации дополнительных возможностей и отсутствие какого-то структурного единообразия всех компонентов, потому что это разные модули, написанные разными людьми. А вам их потом еще как-то адаптировать под ваш шаблон и задачу.
Или использование дорогих и громоздких решений, таких как битрикс там, где в них попросту нет необходимости. Вы получите сайт с прицепом из кучи ненужных для вас вещей. Это все равно, что пойти в магазин за хлебом, а выйти из него с полной тележкой товаров (а хлеб забыть).
Да, Vue и DRF, по сути своей, тоже можно называть "конструктором для программиста" (фреймворки же) и они не идеальны, но на выходе вы получите проект, который будет работать так и только так, как вы его описали кодом. И ничего лишнего.
Ну а тем, кому еще и важен внешний вид и анимации фронтенда - рекомендую посмотреть, какое кунг-фу в нем исполняют на Vue.js некоторые опытные люди. Заметьте, там тоже говорят, что это все "просто".
В разделе для скачивания будет архив. В нем вы найдете:
- Две обработки для 1С: 1. Выгрузка в XML и загрузка на VPS через FTP. 2. Загрузка заказа покупателя. Она еще умеет excel грузить, но это, как я и говорил, мы расширили функционал того, что уже было. Вырезать его мне лень, поэтому отдаю как есть.
- Тексты общих модулей, которые мы используем для работы с API и для отправки почты.
- Программа на python для обработки файлов XML и загрузки информации из них в БД. Исполняемый файл - load_xml.py
- Шаблон-заготовка для Django + DRF. Можно скопировать, заполнить настройки, выполнить миграцию и разрабатывать.
- Шаблон для Vue + Vuex + Vuetify. Сперва, конечно, нужно будет предварительно все проинсталлить через npm. Это шаблон к только что созданному проекту.