Как стать автором
Обновить

Как опубликовать свое первое приложение на Django и не упасть духом. Гайд для выпускников курсов

Уровень сложностиПростой
Время на прочтение14 мин
Количество просмотров13K

Я закончил курсы "Fullstack разработчик на Python" от одной известной компании. Обучение завершено успешно, но не было ощущения полноценности — на курсах не учили, как сделать самостоятельно деплой приложения на Django. И никто из студентов не задавался эти вопросом 😁

Так что я решил закрыть этот вопрос и все-таки пройти путь по развертыванию django-приложения, так как, возможно, больше никогда такого случая не представится. Потом я уже могу не вернуться к этой теме, так как это потребует мучительного вспоминания и повторного прохождения материала. Все должно быть вовремя.

В этой статье я хочу дать небольшой гайд для выпускников подобных курсов и заодно рассказать про российский облачный сервис (еще раз внимательно прочитал их статью и увидел, что она опубликована буквально за 3 недели до моих тестов - то есть мне повезло).

Беседа сеньора и новоиспеченного джуна
Беседа сеньора и новоиспеченного джуна

Если ты хочешь узнать, как запустить DJango-приложение и не потерять время, как я...

Мой путь джедая

Публикацию первого приложения на Django для студента курсов можно разбить на 3 этапа:

  1. Написание приложения во время обучения. Приложение стартует, радуемся когда страница открывается, гордимся собою.

  2. Поиск сервиса для деплоя приложения, выбор и изучение сервиса, набивание шишек.

  3. Внезапное открытие, что приложение, которое успешно запускается локально, не хочет работать в проде.

Еще в середине курса, когда питон закончился и начался веб, я стал посматривать статьи на тему деплоя. Нашел среди них статью про heroku здесь на хабре, отложил себе в заметки. Этот путь казался самым простым и понятным. Уже тогда я откуда-то знал, что на курсах этого не будет и мне предстоит самостоятельно пройти этот путь (на самом деле я не собирался разворачивать приложение в продашене, но жаба заставила).

Вариант с развертыванием полноценного сайта на Linux сервере я не стал рассматривать, так как это на уровень сложнее. Хотя, сейчас, после того как я смог добиться работы 3-х приложений в облачном сервисе, есть понимание, как сделать это. Но все таки я считаю, что наиболее правильно для студента будет именно тот путь, который прошел я. И только потом идти на следующий этап - развертывание сервера. Итак, начнем.

Выбор сервиса

Начинаю поднимать свои заметки и искать гайды по деплою в Heroku. И в одной из статей прошлого года на хабре в комментах нахожу информацию, что "Heroku уже всё" - окончательно скурв монетизировался и стал неинтересен. Там же в комментах нахожу упоминание Amvera, которое мне ничего не говорит.

Также нахожу в интернете статью по pythonanywhere, вроде как альтернатива (я собрал список статей на тему деплоя в конце). Начинаю вникать и понимаю, что они не подходят. Не то, чтобы я не мог себе это позволить, но просто не было желания платить деньги за сильно урезанные возможности и доплаты за любой чих. Дело в принципе. В итоге я чуть ли ни в тот же день решил попробовать облачный сервис Amvera. Было это в субботу...

Знакомство с Amvera: как я провел первые выходные

Регаюсь, быстро пробегаюсь по документации и статье на хабре — вижу, что это одно и то же. Автор статьи аккуратно пропустил возможные проблемы и показал как опубликовать простейшее Flask-приложение. Я не знаком с этим фреймворком, хотя вижу, что, действительно, запускается как любое python приложение, ничего сложного вроде. Но django — "это другое", и единственное, что я понял — есть такой файл amvera.yml, который является конфигурационным файлом для сборки и развертывания Docker (внезапно эта тема немного пригодилась на уровне папки /data, возникли ассоциации 😃 )

Была — не была, приступаем. Но начать я хочу с простейшего Django-приложения, чтобы в будущих разбирательствах с ошибками максимально исключить косяки приложения и тратить время только на особенности сервиса. Так как в Django я далеко не спец, и наложение ошибок из разных областей может принести много боли.

Начинаю искать у себя на компьютере простейший проект, у которого есть файл requirements.txt и нахожу. К сожалению, файл не самый правильный, он был создан с помощью команды pip freeze > requirements.txt поэтому избыточен.

asgiref==3.6.0
attrs==22.2.0
autobahn==23.1.2
Automat==22.10.0
cffi==1.15.1
channels==4.0.0
constantly==15.1.0
cryptography==40.0.1
daphne==4.0.0
Django==4.2
djangorestframework==3.14.0
hyperlink==21.0.0
idna==3.4
incremental==22.10.0
pyasn1==0.4.8
pyasn1-modules==0.2.8
pycparser==2.21
pyOpenSSL==23.1.1
pytz==2023.3
service-identity==21.1.0
six==1.16.0
sqlparse==0.4.3
Twisted==22.10.0
twisted-iocpsupport==1.0.3
txaio==23.1.1
typing_extensions==4.5.0
tzdata==2023.3
zope.interface==6.0

И это оказалось даже хорошо, так как покажет, почему в requirements.txt нужно вручную писать только конкретные пакеты. В остальном проект очень простой - это чат. Полгода назад, когда на курсах дали задание по написанию чата, я решил сначала найти что-то в Youtube, чтобы понять, как это делать. Это было отличное задание — все студенты на нём испытывают шок, потому что оно требует знания материалов, которые мы не проходили.

Типичная реакция за задание про чаты
Типичная реакция за задание про чаты

При написании статьи я поднял свои заметки и выяснил, что это проект я делал по этому видео — https://www.youtube.com/watch?v=IpAk1Eu52GU, к нему есть и код в репозитории https://github.com/tomitokko/django-chat-app, так что желающие могут пройти такой же путь и запустить его в Amvera Cloud.

Вот запись из моего дневника:

Клонировал его и запустил - действительно работает без Channels - просто каждую секунду выполняет запрос к серверу.

На этом можно переходить к самому процессу заливки проекта на Amvera. Идем на https://amvera.ru/ и регистрируемся - сразу получаем 111 рублей на знакомство.

Создаю проект и 2 часа пытаюсь добиться результата.

Первая попытка окончилась провалом
Первая попытка окончилась провалом

Как видите, я уже был готов отказаться от Amvera и поискать что-то другое. Но в итоге вернулся и очень рад. Потому что нужно просто знать, как работать с этим сервисом.

Я долго бился головой о репозиторий и не мог понять, почему не могу запушить изменение в репозиторий amvera. Хотя ответ лежал на поверхности, но я не мог поверить, что это правда. Потом то в переписке поддержка подтвердила, что я сам дурак.

В ответ я получил объяснение

Когда Вы создаете проект, у Вас есть два пути

  1. Загрузить через интерфейс

  2. Загрузить через гит.

На сколько я понял, Вы воспользовались вторым. Потом на этапе задания конфигурации стоит предупреждение, что это создаст новый коммит в репозитории. Соответственно, если Вы задали конфигурацию, то оттуда и взялся коммит.

Конечно, я был неправ, но я почему-то не заметил этого. Хотя видно, что разработчики старались сделать интерфейс максимально простым и понятным, некоторые вещи я стал замечать и понимать только после того, как смог успешно запустить первое приложение.

Итак, вот как работает создание проекта.

  1. Вы жмете "Создать" и появляется окно. Вводите название проекта (Test) и выбираете "Тарифный план"

  2. Жмете "Далее" и попадаете на этап "Загрузка данных". Я рекомендую ничего не менять и остаться на "Через Git". Когда разберетесь, можете попробовать вариант "Через интерфейс". Сам я второй способ еще не использовал.

  3. После нажатия "Далее" попадаем на третий этап "Конфигурация". И вот тут есть 2 момента. Первый — вы не можете пройти дальше (нажать "Завершить"), если не задали конфигурацию. Насколько я помню, я указал в качестве окружения Python и выбрал версию 3.9. Это действие и создало коммит в репозиторий amvera.

И второй момент - если даже нажать "Отмена", то проект уже создан. И можно в него пушить из локального репозитория. Что, в общем-то, неплохо 😉.

Поэтому, кратко повторим как создать проект:

  1. Нажимаем "Создать", пишем название проекта (чем короче, тем лучше — вам потом его еще удалять нужно будет). Выбираем "Тарифный план" и жмем "Далее"

  2. На этапе "Загрузка данных" ничего не меняем и жмем "Далее"

  3. На последнем этапе "Конфигурация" задаем любую конфигурацию и жмем "Завершить". Либо жмем "Отмена" — проект в любом случае уже будет создан.

Теперь осталось залить наш локальный проект в репозиторий amvera, чтобы началась сборка. Для этого необходимы 2 файла:

  1. requirements.txt со списком модулей/зависимостей. Вы из курса знаете как его написать или создать.

  2. Конфигурационный файл amvera.yml, который определяет процесс установки зависимостей, сборки проекта и запуска приложения.

Файл amvera.yml в статье (и в документации) выглядит так, берем его за основу:

meta:
environment: python
toolchain:
name: pip

run:
command: gunicorn --bind 0.0.0.0:5000 app:app
containerPort: 5000

Добавляем в файл requirements.txt строчку gunicorn==20.1.0 как написано в статье. В будущем вы можете указать и другую версию.

Команду запуска в amvera.yml нужно изменить. Если на локальном компьютере при отладке вы запускаете Django-приложение с помощью python manage.py runserver, то здесь вам уже понадобится WSGI-сервер gunicorn, который будет использоваться для запуска вашего Django-приложения. Статья на эту тему - Введение в WSGI-серверы: Часть первая

У меня такая структура проекта:

djangochat/
├── djangochat/
│ ├── init.py
│ ├── settings.py
│ ├── wsgi.py
│ └── ...
├── manage.py
└── .

Поэтому, команда запуска в amvera.yml должна выглядеть так:

run:
command: gunicorn djangochat.wsgi:application --bind 0.0.0.0:80

Таким образом, у меня получается такой amvera.yml:

meta:
environment: python
toolchain:
name: pip
version: 3.9
build:
requirementsPath: requirements.txt
run:
command: gunicorn djangochat.wsgi:application --bind 0.0.0.0:80
persistenceMount: /data
containerPort: 80

Настало время отправить наш проект в репозиторий проекта amvera. Как пишут разработчики сервиса в статье:

Если у вас уже есть приложение, которое вы хотите развернуть в Amvera, но он уже использует другой репозиторий git , можно привязать дополнительный remote к вашему репозиторию.

Это означает, "что если у вас уже есть готовое приложение (например, находится в другом репозитории git), и вы хотите развернуть его в Amvera, вы можете связать дополнительный удаленный репозиторий (remote) с вашим текущим репозиторием git".

Я всегда работаю с git через командную строку. Для этого встаем в корень проекта и вызываем cmd. Далее выполняем привязку к репозиторию amvera
git remote add amvera URL

Посмотреть список привязанных репозиториев можно с помощью команды
git remote -v

В случае ошибок (если никак не удается запустить проекта в amvera) вам также понадобится отвязать удаленный репозиторий amvera , для этого служит команда git remote remove amvera

В самом проекте есть готовые команды-подсказки для для привязки и пуша изменений в репозиторий проекта. Наведите мышку и появится иконка копирования справа в конце строки.

Конечно, нужно предварительно перед пушем комитить сделанные изменения с помощью соответствующих команд:

git add .
git commit -m "текст комита"

Итак, всё готово для пуша в репозиторий amvera, но у меня не вышло. Потратив кучу времени (переписка выше), смог сделать паш с флагом force, то есть -f или --force :
git push amvera master -f

Будьте готовы к такому варианту. Выполняю принудительный пуш и получаю ошибку на стадии сборки проекта:

Ошибки при сборке проекта
Ошибки при сборке проекта
Ругается на twisted_iocpsupport
Ругается на twisted_iocpsupport

Трудно сказать, сколько в доGPT-шную пору я бы искал причину ошибки, так как сразу же обратился к коллективному разуму. И сразу получил ответ:

Ошибка указывает на проблему с поиском заголовочного файла io.h. Этот файл обычно ассоциируется с библиотекой Windows и может отсутствовать в среде выполнения Linux.

Ваш проект, вероятно, использует библиотеку twisted_iocpsupport, которая, похоже, предназначена для Windows. Попытка компиляции этой библиотеки на Linux вызывает ошибку, так как io.h не является стандартным заголовочным файлом в Linux.

Возможные пути решения:

...

Я не стал проверять все варианты решения, а просто в requirements.txt тупо закомментил строку , которая вызывает ошибку — typing_extensions==4.5.0:

Twisted==22.10.0
twisted-iocpsupport==1.0.3
txaio==23.1.1
# typing_extensions==4.5.0
tzdata==2023.3
zope.interface==6.0

Если бы файл requirements.txt писался правильно, вручную с указанием только необходимых пакетов, то наверняка эту ошибку не получили бы. Не проверял.

Теперь после правки requirements.txt сборка проекта проходит без ошибок, но приложение не запускается. У меня не сохранились все логи моих попыток (их было много), в итоге пришел к такой конфигурации amvera.jml, которая почти запустилась

Добавил имя папки проекта, чтобы gunicorn мог запустить wsgi.application
Добавил имя папки проекта, чтобы gunicorn мог запустить wsgi.application

Но это была еще не победа, дальше упали на запуске приложения

Не можем найти файл settings.py проекта
Не можем найти файл settings.py проекта

Я решил и эту проблему, добавив настройку окружения --env в запуск сервера gunicorn

N-ный комит  в amvera.jml в попытке запустить приложение
N-ный комит в amvera.jml в попытке запустить приложение

Из скриншота видно, что я передаю полный путь к файлу wsgi внутри папки проекта djangochat, внутри которого одноименная папка проекта djangochat, внутри которого находится файл wsgi, в котором и есть приложение application. Звучит длинно и непонятно для новичков. С помощью полного абсолютного пути к wsgi и параметра --env для указания файла settings.py мне удалось запустить приложение. Но оно тут же упало на следующем шаге, когда из settings.py попыталось обратиться к приложению chat:

Теперь падаем на следующем шаге
Теперь падаем на следующем шаге

Тут я понял, что эта игра может продолжаться вечно, даже если я внесу какие-то правки в settings.py. Я что-то делаю в корне неправильно.

И тут я вспоминаю, что для локального запуска django приложения мы должны сначала спуститься на один уровень вниз с помощью команады cd имя_папки_проекта.

Тоже самое нужно сделать и при запуске gunicrom: когда мы запускаем gunicorn, то он запускается на самом верхнем уровнее проекта. Чтобы он смог найти файл по штатному пути djangochat/wsgi.py, нам нужно выполнить команду cd djangochat. Напомню структуру проекта:

djangochat/
├── djangochat/
│ ├── init.py
│ ├── settings.py
│ ├── wsgi.py
│ └── ...
├── manage.py
└── .

Вспоминаем, что в Linux мы можем объединять команды с помощью &&. Поэтому команда запуска будет такой:

Скриншот комита
Скриншот комита

После этого приложение смогло запуститься и проблемы с поиском settings.py и приложения chat.py пропали. Да и указывать параметр --env уже не требуется. Когда пройдешь весь путь, то понимаешь, что всё логично и просто.

Не помню, выдавало в браузере по адресу проекта https://django-chat-james.amvera.io/, но это и не важно уже. Минут через 20 все же догадался опять обратиться у ментору, который никогда не спит и знает почти всё:

Обращаемся к ChatGPT
Обращаемся к ChatGPT

Вносим правки и запускаем, почти получилось

Нельзя просто так взять и запустить без указания ALLOWED_HOSTS
Нельзя просто так взять и запустить без указания ALLOWED_HOSTS

Вносим правки в settings.py и Аллилуйя!

Приложение запущено
Приложение запущено

Но не всё так просто

Если попыться зайти в комнату, то опять падаем.

403 при входе в чат-комнату
403 при входе в чат-комнату

На самом деле здесь оказалась все проще, нужно добавить в settings еще параметр CSRF_TRUSTED_ORIGINS, и на этом проблемы конкретно с этим приложением завершились.

После этой правки приложение запустилось и стало работать. А всего мне понадобилось 38 комитов, чтобы запустить это приложение в amvera. Я не мог и предположить, что убью столько времени на первый деплой простого чат-приложения.

Рекомендации по деплою Django приложения:

  1. Пишите вручную зависимости в requiremants.txt, указывайте конкретные имена пакетов/библиотек, необходых для работы. Не используйте для этого pip freeze > requirements.txt

  2. Для запуска в синхронном режиме укажите в requirements.txt пакет gunicorn.

  3. Если какой-то пакет при сборе дает оишбку, попробуйте отключить его через комментирование.

  4. Для запроса лога сборки используйте кнопку внизу (я не догадался, пришлось общаться с техподдержкой)

    Иконка для запроса логов
    Иконка для запроса логов
  5. Для запуска сервера gunicorn необходимо спуститься внутрь проекта командой cd имя_проекта. Конфигурационный файл amvera.yml выглядит примерно так:
    meta:
    environment: python
    toolchain:
    name: pip
    version: 3.9
    build:
    requirementsPath: requirements.txt
    run:
    command: cd <имя_папки_проекта> && ...
    persistenceMount: /data
    containerPort: 80

  6. Для запуска чата (или вообще для работы в асинхронном режиме) вместо wsgi используется asgi. Если вы запускали django-channels локально, то видели в логах примено такой вывод 'Starting ASGI/Daphne':
    System check identified 1 issue (0 silenced).
    April 09, 2023 - 13:33:36
    Django version 4.2, using settings 'chatapp.settings'
    Starting ASGI/Daphne version 4.0.0 development server at http://127.0.0.1:8000/
    Quit the server with CTRL-BREAK.


    Приведу просто ответ ChatGPT

    Как запустить ASGI
    Как запустить ASGI
  7. При удачном запуске проекта вы увидите логи приложения:

  8. Для полноценной работы проекта необходимо в settings.py прописать страницы, с которых будут идти обращения к вашему приложению. Они пишутся в переменные ALLOWED_HOSTS и CSRF_TRUSTED_ORIGINS.

    Настройки ALLOWED_HOSTS и CSRF_TRUSTED_ORIGINS
    Настройки ALLOWED_HOSTS и CSRF_TRUSTED_ORIGINS

Данное приложение не использует css-файлов и js-скриптов, поэтому больше никаких требований для запуска нет. Лучше всего для изучения пройти весь этот путь самостоятельно, склонировав проект из https://github.com/tomitokko/django-chat-app. Мой вариант этого проекта будет доступен какое-то время по адресу https://django-chat-james.amvera.io/.

Выводы по работе с сервисом:

  1. Сервис простой и понятный когда набьешь шишки

  2. Если все сделал правильно, но приложение так и не запускается — просто удалите проект в amvera и создайте заново. Проверено — помогает. Техподдержка в курсе, но пока не может победить такие моменты.

  3. Читайте логи сборки и запуска приложения — если там ошибки, покажите боту ChatGPT. В 99% случаев он знает причину и вам даже не нужно ничего объяснять ему. В некоторых случаях придется гуглить, но к этому моменту вы уже будете примерно знать возможные причины и получите от ChatGPT варианты запросов к поисковикам. Можете даже спросить у ChatGPT как сформулировать на английском запрос для поисковика по данной ошибке. Не пробовал, но уверен на 100%, то он поможет.

Поднимаем Django проект посложнее: 2-ые выходные

Вдохновившись успехом, решаю развернуть другое приложение — это финальный проект курса, причем он проще промежуточного. Так бывает :)

Он не использует асинхронных запросов или вебсокетов, это просто сервисная книжка со справочниками и журналами работ и поломок. Сразу приведу содержимое файла amvera.jml, видно что оно отличается только командой запуска:

meta:
environment: python
toolchain:
name: pip
version: 3.9
build:
requirementsPath: requirements.txt
run:
command: cd project && gunicorn project.wsgi:application --bind 0.0.0.0:80
persistenceMount: /data
containerPort: 80

Параметр persistenceMount в обоих случах мне не нужен был, но я указываю его, чтобы вы знали, где должны быть ваша база данных, если вам нужно сохранять её между перезапусками проекта в Amvera/

Данный проект я развернул по адресу https://silant-2-james.amvera.io/. Первая версия была в https://silant-james.amvera.io/, но я не смог добиться ее запуска, хоть и убил вторые выходные. Никто пока так и не знает, как ее заставить работать, хотя техподдежка проверила на своей стороне и подтвердила, что у меня в проекте ошибок нет. Просто я в процессе многочисленных экспериментов внёс какие-то изменения в проекте на стороне amvera и никакие чистки и пересборки не помогают.

Так вот, второе приложение я запустил со второй попытки уже без моих ошибок в amvera.jml и оно сразу поднялось. Но выглядело странно

Приложение запустилось без стилей
Приложение запустилось без стилей

Сразу стало понятно по логам и по F12, что не заружаются файлы стилей. Сначала я пробовал решить вопрос через настройку STATIC_ROOT и STATICFILES_STORAGE, даже выполнил команду python manage.py collectstatic и отправил пуш с папкой собранных файлов в в репозиторий amvera. После этого первая версия проекта Silant больше никогда не запустилась. В итоге пришлось создавать второй.

В конечном итоге я понял, что все это не работает и опять задал вопрос:

Правильный ответ от ChatGPT
Правильный ответ от ChatGPT

И это помогло - проект запустился по адресу https://silant-2-james.amvera.io/ (будет скоро потушен).

Выводы:

  1. В каждом проекте Django могут возникнуть новые ошибки, ответы на которые новички не знают. Может потребоваться некоторое время, чтобы попробовав разные варианты, найти решения.

  2. Если проект упорно не запускается — убейте его и создайте заново. При этом имя проекта лучше изменить — первый проект назывался silant, а второй уже silant2. Почему нельзя сохранять старое имя? Попробуйте, будут новые интересные моменты. Хотя, если время между удалением старого и созданием нового проекта с одинаковым именем пройдет достаточно много времени, то возможно, никаких проблем не будет. Но я не хочу это проверять.

  3. Перед удалением проекта в amvera не забудьте отвязать свой локальный репозиторий от удаленного репозитория в amvera. Может ничего страшного и не случится, но я бы не проверял на себе. Хотя, может вам будет интересно.

Третье и последнее приложение

Изначально у меня была цель запустить два разных учебных проекта — финальный проект курса, который я описал только что и приложение для общения в чат-комнатах.

Итоговое задание по одному из модулей
Итоговое задание по одному из модулей

Теперь я знаю, как запускать ASGI, как обеспечить загрузку стилей и картинок, как прописать в settings.py страницы. Проблем быть не должно. Но все приложения в Django имеют особенности. То что работало локально через python manage.py runserver, вдруг отказывается запускаться на проде. Вот финальное содержимое amvera.yml :

meta:
environment: python
toolchain:
name: pip
version: 3.9
build:
requirementsPath: djangochat/requirements2.txt
run:
command: cd djangochat && gunicorn -k uvicorn.workers.UvicornWorker django_chat.asgi:application --bind 0.0.0.0:80
persistenceMount: /data
containerPort: 80

Можно увидеть из файла, что я даже использовал опцию preload, но она не помогла. Хотя так и осталась в конфигурации запуска.

Совет от ChatGPT
Совет от ChatGPT

После долгих пыток ChatGPT была найдена причина

Найдена причина ошибки при запуске приложения
Найдена причина ошибки при запуске приложения

То есть ChatGPT говорит, что импорт нужно перенесте непосредственно в те функции, где они используются. Это обеспечит загрузку нужных модулей до их использования. Догадаться самому новичку практически невозможно. Вот такую небольшуюя сделал и приложение наконец-то запустилось.

Изменение в коде consumers.py
Изменение в коде consumers.py

В итоге я опубликовал третье и последнее приложение https://djangochat-e6-james.amvera.io/ (будет доступно какое-то время после публикации статьи).

Выводы по работе с Django

Всего 3 простых приложения на django, а сколько при этом нового узнал. Уверен, что практически для каждого нового проекта на django будут вылезать свои ошибки и нюансы. И мой опыт показывает, что все эти вопросы решаются даже без обращения к опытным разработчикам.

Если бы не было ChatGPT, то, возможно, я бы никогда не добрался до конца. Потому что пинг до ментора занимает от нескольких часов до нескольких дней. И если на каждом вопросе будут такие задержки, то проще всё бросить, чем биться об стену.

Я не привожу здесь конкретных блоко кода и не даю ссылки на свои репозитории, так как цель статьи в том, чтобы показать новичку, как решать возникающие вопросы.

Я потратил на свои опыты примерно 2 недели, по моим следам вы сможет повторить всё в течение одного выходного дня. Удачи!

Статьи, которые могут быть полезными. Порядок отражает мое личное предпочтение:

Еще варианты в статье 10 Лучших Альтернатив Heroku в 2023 Году

Теги:
Хабы:
+5
Комментарии13

Публикации

Изменить настройки темы

Истории

Работа

Python разработчик
132 вакансии
Data Scientist
60 вакансий

Ближайшие события

Weekend Offer в AliExpress
Дата20 – 21 апреля
Время10:00 – 20:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн