CRM с файлами в облаке: как мы перестали хранить вложения у себя — и что из этого получилось
Статья о том, как мы прикрутили Google Drive и Яндекс.Диск к CRM-системе FARA так, чтобы пользователь не замечал, в каком хранилище лежит файл, а вся команда могла работать с документами и через CRM, и напрямую через облако.
Зачем вообще выносить файлы из CRM
Если коротко — из-за того, что CRM перестала быть единственным местом работы с документом. Раньше «прикрепить файл к сделке» означало «загрузить копию на сервер CRM, а если кому-то нужно отредактировать — скачать, поправить, загрузить заново». Сейчас от документа ждут другого:
-
его открывают в облаке и редактируют вдвоём с менеджером прямо во время звонка с клиентом;
-
бухгалтер вообще не заходит в CRM — у неё открыт Drive, и она видит все договоры по сделкам в одной папке;
-
скан акта прилетает на корпоративный диск через мобильное приложение — и должен сам появиться в карточке сделки, без ручного «прикрепить».
«Не занимать место на диске нашего сервера» — это самая скучная причина из всех, она просто прилагается. Главное — то, что облако становится частью рабочей среды команды, а CRM — одним из окон в эту среду. Не единственным.
Дальше расскажу, как это устроено в FARA CRM на уровне кода и интерфейса. Модули attachments_google и attachments_yandex — открытые и бесплатные, и архитектурно сделаны так, что добавить, скажем, OneDrive или Dropbox — это написать ещё одну стратегию по тому же контракту.
Четыре идеи, которые двигают всё остальное
Прежде чем нырять в код, обозначу, что именно мы хотели получить. Это четыре сценария, каждый из которых раньше упирался в «ну, у нас файлы внутри CRM, поэтому никак».
1. Файл редактируется и просматривается онлайн средствами облака. Договор лежит в Google Drive — значит, его открывает Google Docs, не наш просмотрщик. Презентация лежит в Яндекс.Диске — её открывает онлайн-редактор Яндекса. CRM не пытается быть Office. Она пишет правильную ссылку и доверяет облаку всё остальное.
2. Часть команды работает напрямую с диском. У бухгалтера, юриста, аудитора — может вообще не быть учётки в CRM. Им открыли доступ к папке Sales/ на Google Drive — и всё, они видят актуальную структуру по сделкам. Никто им не пересылает документы.
3. Обратная синхронизация — файл, попавший в облако, появляется в CRM. Менеджер кинул скан в папку сделки прямо с телефона. Через пару минут он увидит этот файл в карточке в CRM, без ручных действий. По такому же принципу синхронизируются переименования и удаления (если включить это поведение).
4. Динамическая раскладка по стадиям. У сделки есть стадия (новая → согласование → оплачена → завершена). Файлы должны жить в подпапке, соответствующей стадии. Перетащил карточку в kanban на «Оплачена» — и все её файлы автоматически переехали в папку Оплаченные/SO-0000123/. Это то, что снаружи выглядит как магия, а на самом деле — продуманные паттерны имён папок и пара хуков.
Вокруг этих четырёх идей и крутится вся остальная статья.
Архитектурно: Strategy + Route
Чтобы CRM не превратилась в спагетти из «если Google — то так, если локально — то иначе, если Яндекс — то ещё как-то», у нас два уровня абстракции.
Strategy — это как именно класть файл. То есть конкретные операции CRUD над файлом в облаке: создать, прочитать, обновить, удалить, плюс создать папку, плюс получить URL для просмотра. У каждого хранилища своя стратегия:
class StorageStrategyBase(ABC): strategy_type: str = "" @abstractmethod async def create_file(self, storage, attachment, content, filename, ...): ... @abstractmethod async def read_file(self, storage, attachment) -> bytes | None: ... @abstractmethod async def update_file(self, storage, attachment, content=None, ...): ... @abstractmethod async def delete_file(self, storage, attachment) -> bool: ... @abstractmethod async def create_folder(self, storage, folder_name, parent_id=None, ...): ... @abstractmethod async def validate_connection(self, storage) -> bool: ... @abstractmethod async def get_file_url(self, storage, attachment) -> str | None: ...
Стратегий уже три: FileStoreStrategy (локальная папка на сервере, дефолт), GoogleDriveStrategy и YandexDiskStrategy. Регистрация — одна строчка в app.py модуля:
register_strategy(GoogleDriveStrategy)
Route — это куда именно класть файл и под каким именем. Маршрут не знает, как делается create_file, ему всё равно. Он отвечает за то, чтобы для сделки Sale#123 файлы попали именно в Sales Orders/SO-0000123-Иванов/, а не в плоскую кучу. Про маршруты — отдельный раздел чуть ниже.
Эти две сущности живут отдельно. Стратегию пишут один раз под облако и забывают. Маршруты настраивает админ кликами в интерфейсе.
Модули хранилищ: что внутри Google и Яндекса
Снаружи это просто пункт в выпадающем «Тип хранилища»: FileStore, Google Drive, Yandex Disk. Под капотом — отдельные модули attachments_google и attachments_yandex.
Каждый модуль состоит из трёх частей:
-
Mixin — расширяет модель
AttachmentStorage, добавляя свои поля (google_credentials,google_team_id,yandex_access_token,yandex_refresh_tokenи т. д.). Делается через декоратор@extend, без правок ядра. -
Strategy — реализация интерфейса выше для конкретного API.
-
OAuth-роутер — два эндпоинта:
/{provider}/auth/{storage_id}(стартует авторизацию, возвращаетauthorization_url) и/{provider}/callback(обменивает код на токены, сохраняет в storage).
В коде это лаконично и зеркально между провайдерами. Например, обновление access-токена в Яндексе:
async def _refresh_access_token(self, storage): async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client: response = await client.post(OAUTH_TOKEN_URL, data={ "grant_type": "refresh_token", "refresh_token": storage.yandex_refresh_token, "client_id": storage.yandex_client_id, "client_secret": storage.yandex_client_secret, }) # ...сохраняем новые токены в storage
В Google логика обновления делегирована официальной либе google-auth, но сценарий тот же: при истечении токен молча обновляется, в БД пишутся свежие credentials, пользователь ничего не замечает.
Что важно: пользователь FARA, открывая карточку сделки, визуально не видит, где лежит файл. Превью открывается, файл скачивается, ссылка работает — а внутри CRM, по локальному пути или из облака, для него не имеет значения. Этот принцип нам был принципиален: облако — это инфраструктура, а не отдельный «второй интерфейс для облачных вложений».
Маршруты — главная фича, ради которой стоит читать дальше
Маршрут (AttachmentRoute) — это правило, которое отвечает на два вопроса для каждой сущности CRM: как назвать корневую папку этой сущности в облаке и как назвать папку конкретной записи внутри неё.
Модель упрощённо:
class AttachmentRoute(DotModel): name: str # человекочитаемое имя model: str | None # 'sale', 'lead', None для fallback-маршрута priority: int # выше число — выше приоритет pattern_root: str # шаблон корневой папки модели pattern_record: str # шаблон папки записи flat: bool # без подпапок на запись? filter: list | None # фильтр записей (DSL CRM) storage_id: M2O # к какому хранилищу относится active: bool
В pattern_root и pattern_record можно использовать переменные: имя модели, ID, любые поля записи, плюс хелпер zfill (дополнение нулями до семи знаков). Простейший пример:
pattern_root = "Sales Orders"pattern_record = "SO-{zfill(id)}-{name}"
Получаем структуру:
Drive/└── Sales Orders/ ├── SO-0000123-Иванов/ │ ├── contract.pdf │ └── invoice.pdf └── SO-0000124-Петров/ └── proposal.docx
Это уже папка, в которую можно дать прямой доступ бухгалтеру — и она будет осмысленно выглядеть для человека, не знающего CRM. Не attachments_42_storage_3_file_uuid.bin, а SO-0000123-Иванов/contract.pdf.
Приоритеты, фильтры и fallback
Маршрутов на одну модель может быть несколько. Например:
-
маршрут «VIP-клиенты» с фильтром
[("client_tier", "=", "vip")], приоритет 100, паттернVIP/{name}; -
обычный маршрут на модель
sale, приоритет 10; -
fallback-маршрут (
model = None) — для всего, что не попало ни в один специфичный.
Логика выбора маршрута:
@classmethodasync def get_route_for_attachment(cls, res_model, res_id): if res_model and res_id: # 1) специфичные по модели — по приоритету specific = await cls.search(filter=[ ("active", "=", True), ("model", "=", res_model), ], sort="priority", order="DESC") for route in specific: if await route._check_record_in_filter(res_id): return route # 2) fallback (model=None) — по приоритету fallback = await cls.search(filter=[ ("active", "=", True), ("model", "=", None), ], sort="priority", order="DESC") return fallback[0] if fallback else None
То есть «специфичный маршрут» всегда побеждает fallback — даже если у fallback приоритет выше. Это важно: один админ настраивает общий fallback на всю CRM, другой — кастомные маршруты под конкретные кейсы, и они не дерутся.
Три типа синхронизации
В настройках хранилища есть три флага, которые описывают, как именно CRM и облако обмениваются файлами:
enable_realtime = Boolean(default=False, ...) # сразу при созданииenable_one_way_cron = Boolean(default=False, ...) # CRM → облако, по расписаниюenable_two_way_cron = Boolean(default=False, ...) # CRM ↔ облако, по расписанию
И отдельно — как реагировать на расхождения:
file_missing_cloud # что делать, если файл есть в CRM, но нет в облакеfile_missing_local # что делать, если файл есть в облаке, но нет в CRM
Realtime (онлайн). Файл загружен в карточку сделки → стратегия немедленно отправляет его в облако. Это обычное поведение, когда облако — основное хранилище, а CRM — клиент.
One-way cron. По расписанию (например, раз в час) выполняется задача-синхронизатор: всё, что было загружено локально и ещё не оказалось в облаке, доезжает до него батчем. Полезно, если синхронные загрузки в облако нежелательны — например, чтобы UI не зависал на больших файлах.
Two-way cron — обратная синхронизация. Тот самый сценарий «менеджер кинул скан в папку сделки прямо с телефона». Cron обходит папки облака, по custom_properties (route_id, res_model, res_id) понимает, к какой записи относится файл, и создаёт Attachment в CRM с пустым content, но заполненным storage_file_id. Дальше это нормальное вложение — открывается, скачивается, удаляется как любое другое.
Google Shared Drive (Team)
Отдельная неочевидная фича — поддержка Shared Drive в Google. Это командные диски, где владельцем выступает не отдельный сотрудник, а организация. Если менеджер увольняется — его личный Drive уходит вместе с ним, и все файлы по сделкам приходится переподключать. Если файлы лежат на Shared Drive, ничего такого не случится.
В модуле attachments_google это два дополнительных поля:
google_team_enabled: bool = Boolean(default=False, string="Use Shared Drive")google_team_id: str = Char(string="Shared Drive ID")
И в стратегии — корректные supportsAllDrives=True / supportsTeamDrives=True в каждом вызове Drive API. Снаружи — просто чекбокс в форме хранилища.
Для команд это критично: подключаться к корпоративным дискам, а не к личным. Многие интеграции этого не умеют — а у нас это две строчки в форме.
Безопасность OAuth: что мы храним и как
Тема, которую обычно не любят показывать в статьях, потому что «и так понятно». Покажу, как именно у нас.
Логины и пароли пользователя облака мы не храним. Только токены OAuth2, выданные нам с явного согласия пользователя на конкретные scope-ы.
Для Google:
-
google_json_credentials— содержимоеcredentials.json(client_id / client_secret приложения), загружается админом один раз. Это credentials самого приложения, а не пользователя. -
google_credentials— сериализованные OAuth2 credentials с access/refresh токенами. Обновляются автоматически при истечении. -
google_refresh_token— чтобы можно было обновлять access-токен молча, без переавторизации. -
Scope-ы:
driveиdrive.file. Ничего лишнего.
Для Яндекса:
-
yandex_client_id/yandex_client_secret— параметры зарегистрированного OAuth-приложения. -
yandex_access_token/yandex_refresh_token/yandex_token_expires_at. -
Scope-ы:
cloud_api:disk.readиcloud_api:disk.write.
OAuth-флоу — стандартный authorization code: пользователь жмёт «Авторизовать» в форме хранилища, ему открывается окно провайдера, после согласия провайдер редиректит на наш endpoint /{provider}/callback?code=...&state=.... Параметр state мы заранее сохраняем в storage (*_verify_code) и проверяем при возврате — это защита от CSRF на колбэке. Использованный state обнуляется сразу после обмена.
Один важный нюанс реализации, которым легко пренебречь: при скачивании файла Яндекс отвечает 302 на CDN, и httpx по умолчанию редиректы не следует. Поэтому в стратегии явно ставим follow_redirects=True — но только для анонимных запросов на upload/download URL, не для API-запросов с Authorization: OAuth …. Иначе при каком-нибудь экзотическом редиректе токен может уехать на чужой хост.
# OK — анонимный download URL, можно идти за редиректомasync with httpx.AsyncClient(timeout=HTTP_TIMEOUT, follow_redirects=True) as c: file_resp = await c.get(href)# НЕ OK — авторизованные запросы с OAuth-заголовком, follow_redirects не нуженasync with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as c: response = await c.request(method, url, headers={"Authorization": f"OAuth {token}"}, ...)
Это та самая граница, на которой случаются громкие истории с утечкой токенов в логи или прокси.
Что получили в итоге
Если собрать в одно предложение: CRM перестала быть единственным интерфейсом к файлам — и от этого выиграли все.
Менеджеру всё равно, где лежит файл: он открывает карточку сделки, нажимает на вложение, получает превью или редактор. Бухгалтеру не нужна учётка в CRM — у него есть папка Sales Orders на корпоративном Google Drive, и он там полноправный житель. Админ настраивает раскладку под бизнес-процесс кликами в интерфейсе, а не правкой кода. Разработчик добавляет новое облако одной стратегией.
Модули attachments_google и attachments_yandex в FARA CRM — бесплатные, открытые, и устроены симметрично, чтобы их легко было читать рядом и понимать, чем именно отличаются провайдеры (а отличаются они меньше, чем кажется).
Если у вас уже есть CRM, в которой файлы — это до сих пор «загрузил локально, скачал, переслал» — самое время посмотреть, как этот сценарий выглядит, когда CRM и облако работают вместе.
P. S. Если интересно посмотреть, как именно устроен модуль интеграции — в ближайшее время выложим разбор attachments_yandex отдельной статьёй: что внутри стратегии, как сделан OAuth, как обходим квоты Яндекса. Подписывайтесь.
ссылка на оригинал статьи https://habr.com/ru/articles/1029850/