feat: Evotor + VK catalog sync, connections, and store/group filters

- Evotor catalog: background Celery task syncing stores/groups/products
  from Evotor API; UI pages with per-store and per-group sync toggles
- VK connection: manual token + group ID entry with inline test button
- Evotor connection: inline test button (calls /stores)
- VK catalog: background task syncing VK Market albums and products;
  separate catalog UI at /vk-catalog/albums
- SyncFilter extended to support entity_type=group with parent_entity_id
- Migration 0004: vk_cached_albums + vk_cached_products tables
- Beat schedule updated to run both refresh_catalog and refresh_vk_catalog
- README updated with new schema, routes, tasks, and config

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mguschin
2026-05-01 18:09:11 +03:00
parent 7a06045bef
commit 796cf49ff9
18 changed files with 1716 additions and 47 deletions

View File

@@ -17,7 +17,8 @@
<ul class="nav-links">
{% if user %}
<li><a href="/connections">Подключения</a></li>
<li><a href="/catalog">Каталог</a></li>
<li><a href="/catalog">Каталог Эвотор</a></li>
<li><a href="/vk-catalog/albums">Каталог ВК</a></li>
<li><a href="/sync">Синхронизация</a></li>
{% if user.role in ('admin', 'system') %}
<li><a href="/admin/users"><i class="bi bi-shield-lock"></i> Админ</a></li>
@@ -34,7 +35,8 @@
<summary role="button" class="outline secondary icon-btn"><i class="bi bi-list"></i></summary>
<ul>
<li><a href="/connections">Подключения</a></li>
<li><a href="/catalog">Каталог</a></li>
<li><a href="/catalog">Каталог Эвотор</a></li>
<li><a href="/vk-catalog/albums">Каталог ВК</a></li>
<li><a href="/sync">Синхронизация</a></li>
{% if user.role in ('admin', 'system') %}
<li><a href="/admin/users">Админ</a></li>

View File

@@ -0,0 +1,69 @@
{% extends "base.html" %}
{% block title %}Группы — {{ store.name }} — ЭВОСИНК{% endblock %}
{% block content %}
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li><a href="/catalog/stores">Магазины</a></li>
<li>{{ store.name }}</li>
<li>Группы</li>
</ol>
</nav>
<div class="d-flex justify-between align-center mb-3">
<h1 style="font-size:1.3rem; margin:0;"><i class="bi bi-folder me-2"></i>Группы товаров — {{ store.name }}</h1>
<span class="text-muted small">Всего: {{ groups | length }}</span>
</div>
<article class="card">
{% if groups %}
<div class="table-scroll">
<table class="align-middle">
<thead>
<tr>
<th>Синхронизация</th>
<th>Название</th>
<th>ID</th>
<th>Обновлено</th>
<th></th>
</tr>
</thead>
<tbody>
{% for g in groups %}
{% set is_enabled = (enabled_ids is none) or (g.evotor_id in enabled_ids) %}
<tr class="{% if not is_enabled %}text-muted{% endif %}">
<td>
<form method="post" action="/catalog/stores/{{ store.evotor_id }}/groups/{{ g.evotor_id }}/toggle" style="margin:0;">
<button type="submit"
class="outline sm {% if is_enabled %}success{% else %}secondary{% endif %}"
title="{% if is_enabled %}Отключить синхронизацию{% else %}Включить синхронизацию{% endif %}"
style="padding:0.2rem 0.6rem;">
{% if is_enabled %}
<i class="bi bi-toggle-on"></i>
{% else %}
<i class="bi bi-toggle-off"></i>
{% endif %}
</button>
</form>
</td>
<td><i class="bi bi-folder2 me-1 text-muted"></i> <strong>{{ g.name }}</strong></td>
<td class="text-muted small">{{ g.evotor_id }}</td>
<td class="text-muted small">{{ g.fetched_at | datefmt }}</td>
<td>
<a href="/catalog/stores/{{ store.evotor_id }}/products?group={{ g.evotor_id }}" role="button" class="outline sm">
<i class="bi bi-box-seam"></i> Товары
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5 text-muted">
<i class="bi bi-folder" style="font-size:2rem;"></i>
<p class="mt-2">Группы для этого магазина ещё не загружены.</p>
</div>
{% endif %}
</article>
{% endblock %}

View File

@@ -0,0 +1,81 @@
{% extends "base.html" %}
{% block title %}Товары — {{ store.name }} — ЭВОСИНК{% endblock %}
{% block content %}
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li><a href="/catalog/stores">Магазины</a></li>
<li>{{ store.name }}</li>
<li>Товары</li>
</ol>
</nav>
<div class="d-flex justify-between align-center mb-3">
<h1 style="font-size:1.3rem; margin:0;"><i class="bi bi-box-seam me-2"></i>Товары — {{ store.name }}</h1>
<span class="text-muted small">Всего: {{ products | length }}</span>
</div>
{% if groups %}
<article class="card mb-3">
<div class="card-body">
<form method="get" class="d-flex gap-2 align-center flex-wrap">
<select name="group" style="width:auto; margin:0;" onchange="this.form.submit()">
<option value="">Все группы</option>
{% for g in groups %}
<option value="{{ g.evotor_id }}" {% if group_id == g.evotor_id %}selected{% endif %}>{{ g.name }}</option>
{% endfor %}
</select>
{% if group_id %}
<a href="/catalog/stores/{{ store.evotor_id }}/products" role="button" class="outline secondary sm">Сбросить</a>
{% endif %}
</form>
</div>
</article>
{% endif %}
<article class="card">
{% if products %}
<div class="table-scroll">
<table class="align-middle">
<thead>
<tr>
<th>Название</th>
<th>Артикул</th>
<th>Цена</th>
<th>Остаток</th>
<th>Ед.</th>
<th>Продаётся</th>
<th>Обновлено</th>
</tr>
</thead>
<tbody>
{% for p in products %}
<tr>
<td>{{ p.name }}</td>
<td class="text-muted small">{{ p.article_number or '—' }}</td>
<td>{% if p.price is not none %}{{ p.price | price }}{% else %}—{% endif %}</td>
<td>{% if p.quantity is not none %}{{ p.quantity }}{% else %}—{% endif %}</td>
<td class="text-muted small">{{ p.measure_name or '—' }}</td>
<td>
{% if p.allow_to_sell %}
<i class="bi bi-check-circle text-success"></i>
{% elif p.allow_to_sell == false %}
<i class="bi bi-x-circle text-danger"></i>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td class="text-muted small">{{ p.fetched_at | datefmt }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5 text-muted">
<i class="bi bi-box-seam" style="font-size:2rem;"></i>
<p class="mt-2">Товары не найдены.</p>
</div>
{% endif %}
</article>
{% endblock %}

View File

@@ -0,0 +1,66 @@
{% extends "base.html" %}
{% block title %}Магазины — ЭВОСИНК{% endblock %}
{% block content %}
<div class="d-flex justify-between align-center mb-3">
<h1 style="font-size:1.3rem; margin:0;"><i class="bi bi-shop me-2"></i>Магазины Эвотор</h1>
<span class="text-muted small">Всего: {{ stores | length }}</span>
</div>
<article class="card">
{% if stores %}
<div class="table-scroll">
<table class="align-middle">
<thead>
<tr>
<th>Синхронизация</th>
<th>Название</th>
<th>Адрес</th>
<th>ID</th>
<th>Обновлено</th>
<th></th>
</tr>
</thead>
<tbody>
{% for s in stores %}
{% set is_enabled = (enabled_ids is none) or (s.evotor_id in enabled_ids) %}
<tr class="{% if not is_enabled %}text-muted{% endif %}">
<td>
<form method="post" action="/catalog/stores/{{ s.evotor_id }}/toggle" style="margin:0;">
<button type="submit"
class="outline sm {% if is_enabled %}success{% else %}secondary{% endif %}"
title="{% if is_enabled %}Отключить синхронизацию{% else %}Включить синхронизацию{% endif %}"
style="padding:0.2rem 0.6rem;">
{% if is_enabled %}
<i class="bi bi-toggle-on"></i>
{% else %}
<i class="bi bi-toggle-off"></i>
{% endif %}
</button>
</form>
</td>
<td><strong>{{ s.name }}</strong></td>
<td class="text-muted">{{ s.address or '—' }}</td>
<td class="text-muted small">{{ s.evotor_id }}</td>
<td class="text-muted small">{{ s.fetched_at | datefmt }}</td>
<td>
<a href="/catalog/stores/{{ s.evotor_id }}/products" role="button" class="outline sm" title="Товары">
<i class="bi bi-box-seam"></i> Товары
</a>
<a href="/catalog/stores/{{ s.evotor_id }}/groups" role="button" class="outline secondary sm" title="Группы">
<i class="bi bi-folder"></i> Группы
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5 text-muted">
<i class="bi bi-shop" style="font-size:2rem;"></i>
<p class="mt-2">Магазины ещё не загружены.<br>Синхронизация выполняется каждые {{ refresh_interval }} сек. автоматически.</p>
</div>
{% endif %}
</article>
{% endblock %}

View File

@@ -0,0 +1,209 @@
{% extends "base.html" %}
{% block title %}Подключения — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-center">
<div class="col-sm-10 col-md-8 col-lg-6">
<h1 style="font-size:1.3rem; margin-bottom:1.5rem;">
<i class="bi bi-plug me-2"></i>Подключения
</h1>
{% if request.query_params.get('success') %}
<div role="alert" class="alert alert-success mb-3">
<p>Подключение сохранено.</p>
</div>
{% endif %}
{# ── Evotor ── #}
<article class="card mb-4">
<header class="d-flex align-center justify-between">
<span><i class="bi bi-cpu me-2"></i><strong>Эвотор</strong></span>
{% if evotor %}
<span class="badge badge-success"><i class="bi bi-check-circle me-1"></i>Подключено</span>
{% else %}
<span class="badge badge-secondary">Не подключено</span>
{% endif %}
</header>
{% if evotor %}
<ul class="list-group mb-3">
<li class="list-group-item">
<span class="text-muted small">Токен</span>
<span class="font-monospace small">{{ evotor.access_token[:8] }}••••••••</span>
</li>
{% if evotor.evotor_user_id %}
<li class="list-group-item">
<span class="text-muted small">Evotor User ID</span>
<span class="font-monospace small">{{ evotor.evotor_user_id }}</span>
</li>
{% endif %}
<li class="list-group-item">
<span class="text-muted small">Подключено</span>
<span>{{ evotor.connected_at | datefmt }}</span>
</li>
<li class="list-group-item">
<span class="text-muted small">Обновлено</span>
<span>{{ evotor.updated_at | datefmt }}</span>
</li>
</ul>
{% endif %}
<div class="card-body">
<details {% if not evotor %}open{% endif %}>
<summary>
{% if evotor %}Обновить токен{% else %}Ввести API-токен{% endif %}
</summary>
<form method="post" action="/connections/evotor" class="mt-3">
<label>
API-токен Эвотор
<input type="text" name="access_token"
placeholder="Вставьте токен из личного кабинета Эвотор"
value="{{ evotor.access_token if evotor else '' }}"
required autocomplete="off">
</label>
<label>
Evotor User ID <span class="text-muted small">(необязательно)</span>
<input type="text" name="evotor_user_id"
placeholder="Например: 01234567-89ab-cdef-0123-456789abcdef"
value="{{ evotor.evotor_user_id if evotor and evotor.evotor_user_id else '' }}"
autocomplete="off">
</label>
<button type="submit">
<i class="bi bi-save me-1"></i>Сохранить
</button>
</form>
</details>
{% if evotor %}
<div class="d-flex gap-2 mt-3" style="flex-wrap:wrap; align-items:center;">
<button type="button" class="outline sm" onclick="testConnection('evotor', this)">
<i class="bi bi-wifi me-1"></i>Проверить соединение
</button>
<span id="evotor-test-result" class="small"></span>
</div>
<form method="post" action="/connections/evotor/disconnect"
class="mt-2"
onsubmit="return confirm('Отключить Эвотор? Кешированные данные каталога останутся.')">
<button type="submit" class="outline danger sm">
<i class="bi bi-plug me-1"></i>Отключить
</button>
</form>
{% endif %}
</div>
</article>
{# ── VK ── #}
<article class="card mb-4">
<header class="d-flex align-center justify-between">
<span><i class="bi bi-badge-vr me-2"></i><strong>ВКонтакте (Маркет)</strong></span>
{% if vk %}
<span class="badge badge-success"><i class="bi bi-check-circle me-1"></i>Подключено</span>
{% else %}
<span class="badge badge-secondary">Не подключено</span>
{% endif %}
</header>
{% if vk %}
<ul class="list-group mb-3">
<li class="list-group-item">
<span class="text-muted small">Токен</span>
<span class="font-monospace small">{{ vk.access_token[:8] }}••••••••</span>
</li>
{% if vk.vk_user_id %}
<li class="list-group-item">
<span class="text-muted small">ID сообщества</span>
<span class="font-monospace small">{{ vk.vk_user_id }}</span>
</li>
{% endif %}
{% if vk.first_name or vk.last_name %}
<li class="list-group-item">
<span class="text-muted small">Аккаунт</span>
<span>{{ vk.first_name }} {{ vk.last_name }}</span>
</li>
{% endif %}
<li class="list-group-item">
<span class="text-muted small">Подключено</span>
<span>{{ vk.connected_at | datefmt }}</span>
</li>
<li class="list-group-item">
<span class="text-muted small">Обновлено</span>
<span>{{ vk.updated_at | datefmt }}</span>
</li>
</ul>
{% endif %}
<div class="card-body">
<details {% if not vk %}open{% endif %}>
<summary>
{% if vk %}Обновить подключение{% else %}Подключить ВКонтакте{% endif %}
</summary>
<p class="text-muted small mt-2">
Укажите токен пользователя VK с правами <code>market,photos,groups</code>
и ID сообщества, в котором включён Маркет.
</p>
<form method="post" action="/connections/vk" class="mt-2">
<label>
Токен доступа VK
<input type="text" name="access_token"
placeholder="vk1.a.xxxxxxxxxxxxxxxx…"
value="{{ vk.access_token if vk else '' }}"
required autocomplete="off">
</label>
<label>
ID сообщества ВКонтакте
<input type="text" name="vk_group_id"
placeholder="Например: 229744980"
value="{{ vk.vk_user_id if vk and vk.vk_user_id else '' }}"
autocomplete="off">
<small class="text-muted">Числовой ID группы/паблика с включённым Маркетом (без минуса)</small>
</label>
<button type="submit">
<i class="bi bi-save me-1"></i>Сохранить
</button>
</form>
</details>
{% if vk %}
<div class="d-flex gap-2 mt-3" style="flex-wrap:wrap; align-items:center;">
<button type="button" class="outline sm" onclick="testConnection('vk', this)">
<i class="bi bi-wifi me-1"></i>Проверить соединение
</button>
<span id="vk-test-result" class="small"></span>
</div>
<form method="post" action="/connections/vk/disconnect"
class="mt-2"
onsubmit="return confirm('Отключить ВКонтакте?')">
<button type="submit" class="outline danger sm">
<i class="bi bi-plug me-1"></i>Отключить
</button>
</form>
{% endif %}
</div>
</article>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
async function testConnection(provider, btn) {
const resultEl = document.getElementById(provider + '-test-result');
btn.disabled = true;
resultEl.textContent = 'Проверяем…';
resultEl.style.color = '';
try {
const resp = await fetch('/connections/' + provider + '/test', {method: 'POST'});
const data = await resp.json();
resultEl.textContent = data.message;
resultEl.style.color = data.ok ? 'var(--pico-color-green-500, #2d8a4e)' : 'var(--pico-color-red-500, #c0392b)';
} catch (e) {
resultEl.textContent = 'Ошибка сети';
resultEl.style.color = 'var(--pico-color-red-500, #c0392b)';
} finally {
btn.disabled = false;
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,52 @@
{% extends "base.html" %}
{% block title %}Альбомы ВК — ЭВОСИНК{% endblock %}
{% block content %}
<div class="d-flex justify-between align-center mb-3">
<h1 style="font-size:1.3rem; margin:0;"><i class="bi bi-badge-vr me-2"></i>Каталог ВКонтакте — Альбомы</h1>
<span class="text-muted small">Всего: {{ albums | length }}</span>
</div>
<article class="card">
{% if not vk_conn %}
<div class="text-center py-5 text-muted">
<i class="bi bi-plug" style="font-size:2rem;"></i>
<p class="mt-2">ВКонтакте не подключён.<br><a href="/connections">Перейти к подключениям</a></p>
</div>
{% elif albums %}
<div class="table-scroll">
<table class="align-middle">
<thead>
<tr>
<th>Название</th>
<th>Товаров</th>
<th>ID</th>
<th>Обновлено</th>
<th></th>
</tr>
</thead>
<tbody>
{% for a in albums %}
<tr>
<td><i class="bi bi-collection me-1 text-muted"></i> <strong>{{ a.title }}</strong></td>
<td class="text-muted">{{ a.count if a.count is not none else '—' }}</td>
<td class="text-muted small">{{ a.album_id }}</td>
<td class="text-muted small">{{ a.fetched_at | datefmt }}</td>
<td>
<a href="/vk-catalog/albums/{{ a.album_id }}/products" role="button" class="outline sm">
<i class="bi bi-box-seam"></i> Товары
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5 text-muted">
<i class="bi bi-collection" style="font-size:2rem;"></i>
<p class="mt-2">Альбомы ещё не загружены.<br>Синхронизация выполняется каждые {{ refresh_interval }} сек. автоматически.</p>
</div>
{% endif %}
</article>
{% endblock %}

View File

@@ -0,0 +1,75 @@
{% extends "base.html" %}
{% block title %}Товары ВК — {{ album.title }} — ЭВОСИНК{% endblock %}
{% block content %}
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li><a href="/vk-catalog/albums">Альбомы ВК</a></li>
<li>{{ album.title }}</li>
</ol>
</nav>
<div class="d-flex justify-between align-center mb-3">
<h1 style="font-size:1.3rem; margin:0;"><i class="bi bi-box-seam me-2"></i>{{ album.title }}</h1>
<span class="text-muted small">Всего: {{ products | length }}</span>
</div>
<article class="card">
{% if products %}
<div class="table-scroll">
<table class="align-middle">
<thead>
<tr>
<th></th>
<th>Название</th>
<th>Цена</th>
<th>Статус</th>
<th>ID</th>
<th>Обновлено</th>
</tr>
</thead>
<tbody>
{% for p in products %}
<tr>
<td style="width:48px;">
{% if p.thumb_url %}
<img src="{{ p.thumb_url }}" alt="" style="width:40px;height:40px;object-fit:cover;border-radius:4px;">
{% else %}
<span class="text-muted"><i class="bi bi-image" style="font-size:1.5rem;"></i></span>
{% endif %}
</td>
<td>
<strong>{{ p.name }}</strong>
{% if p.description %}
<br><span class="text-muted small">{{ p.description[:80] }}{% if p.description|length > 80 %}…{% endif %}</span>
{% endif %}
</td>
<td class="text-muted">
{% if p.price is not none %}{{ p.price | price }}{% else %}—{% endif %}
</td>
<td>
{% if p.availability == 0 %}
<span class="badge badge-success">В наличии</span>
{% elif p.availability == 1 %}
<span class="badge badge-secondary">Удалён</span>
{% elif p.availability == 2 %}
<span class="badge badge-warning">Недоступен</span>
{% else %}
<span class="text-muted small"></span>
{% endif %}
</td>
<td class="text-muted small">{{ p.vk_product_id }}</td>
<td class="text-muted small">{{ p.fetched_at | datefmt }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5 text-muted">
<i class="bi bi-box-seam" style="font-size:2rem;"></i>
<p class="mt-2">Товары в этом альбоме не найдены.</p>
</div>
{% endif %}
</article>
{% endblock %}