FARA CRM. Как мы работаем с файлами

от автора

CRM с файлами в облаке: как мы перестали хранить вложения у себя — и что из этого получилось

Статья о том, как мы прикрутили Google Drive и Яндекс.Диск к CRM-системе FARA так, чтобы пользователь не замечал, в каком хранилище лежит файл, а вся команда могла работать с документами и через CRM, и напрямую через облако.

Канбан файлов, облачные файлы доступны для редактирования из CRM

Канбан файлов, облачные файлы доступны для редактирования из 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/. Это то, что снаружи выглядит как магия, а на самом деле — продуманные паттерны имён папок и пара хуков.

Вокруг этих четырёх идей и крутится вся остальная статья.

Стадии сделок на kanban-доске. Перетаскивание карточки запускает перенос файлов в облаке.

Стадии сделок на kanban-доске. Перетаскивание карточки запускает перенос файлов в облаке.

Архитектурно: 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-Иванов/, а не в плоскую кучу. Про маршруты — отдельный раздел чуть ниже.

Эти две сущности живут отдельно. Стратегию пишут один раз под облако и забывают. Маршруты настраивает админ кликами в интерфейсе.

Связка Strategy + Route: что куда складывать и как именно класть.

Связка Strategy + Route: что куда складывать и как именно класть.

Модули хранилищ: что внутри 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, пользователь ничего не замечает.

 Форма настройки хранилища Google Drive в FARA CRM.

Форма настройки хранилища Google Drive в FARA CRM.
Форма настройки хранилища Яндекс.Диск.

Форма настройки хранилища Яндекс.Диск.

Что важно: пользователь 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.

Настройка маршрута: какие сущности и как раскладывать в облаке.

Настройка маршрута: какие сущности и как раскладывать в облаке.
Структура папок в Google Drive, собранная по маршруту.

Структура папок в Google Drive, собранная по маршруту.

Приоритеты, фильтры и 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/