diff --git a/app.py b/app.py index 111044f..24c9e93 100644 --- a/app.py +++ b/app.py @@ -28,6 +28,11 @@ AUTO_REPLY_SETTING_KEY = "auto_reply_enabled" AUTO_REPLY_LAST_RUN_KEY = "auto_reply_last_run_ts" AUTO_REPLY_POOL_5_KEY = "auto_reply_pool_5" AUTO_REPLY_POOL_4_KEY = "auto_reply_pool_4" +AUTO_REPLY_POOL_3_KEY = "auto_reply_pool_3" +AUTO_REPLY_POOL_2_KEY = "auto_reply_pool_2" +AUTO_REPLY_POOL_1_KEY = "auto_reply_pool_1" +AUTO_REPLY_STARS_KEY = "auto_reply_stars_json" +AUTO_REPLY_FILTER_KEY = "auto_reply_filter" AUTO_REPLY_QUEUE_KEY = "auto_reply_queue_json" AUTO_REPLY_LAST_FETCH_KEY = "auto_reply_last_fetch_ts" AUTO_REPLY_INTERVAL_MINUTES = int(os.getenv("AUTO_REPLY_INTERVAL_MINUTES", "10")) @@ -815,8 +820,9 @@ def _save_auto_reply_queue(queue: List[dict]) -> None: def _build_auto_reply_queue(reviews: List[Review]) -> List[dict]: queue: List[dict] = [] + filter_mode = _load_filter_mode() for review in reviews: - if _has_review_content(review): + if _has_review_content(review, filter_mode): logger.info("Auto-reply skip review_id=%s reason=has_review_text", review.id) db.add_auto_reply_log( review_id=review.id, @@ -863,9 +869,32 @@ def _load_reply_pool(star: int) -> List[str]: if star == 4: db_value = db.get_setting(AUTO_REPLY_POOL_4_KEY) return _parse_pool(db_value or ENV_REPLY_POOL_4, DEFAULT_REPLY_POOL_4) + if star in (1, 2, 3): + return _parse_pool(db.get_setting(f"auto_reply_pool_{star}") or "", []) return [] +def _load_enabled_stars() -> Set[int]: + raw = db.get_setting(AUTO_REPLY_STARS_KEY) + if raw: + try: + parsed = json.loads(raw) + if isinstance(parsed, list): + result = {int(s) for s in parsed if isinstance(s, (int, float, str)) and str(s).isdigit() and 1 <= int(s) <= 5} + if result: + return result + except (TypeError, ValueError): + pass + return AUTO_REPLY_STARS + + +def _load_filter_mode() -> str: + raw = db.get_setting(AUTO_REPLY_FILTER_KEY) + if raw in ("no_text", "empty", "all"): + return raw + return "no_text" + + def _pool_to_multiline_text(pool: List[str]) -> str: return "\n".join(pool) @@ -877,8 +906,17 @@ def _pick_auto_reply(star: int) -> Optional[str]: return None -def _has_review_content(review: Review) -> bool: - return bool((review.text or "").strip()) +def _has_review_content(review: Review, filter_mode: str = "no_text") -> bool: + if filter_mode == "no_text": + return bool((review.text or "").strip()) + if filter_mode == "empty": + return bool( + (review.text or "").strip() + or (review.pros or "").strip() + or (review.cons or "").strip() + ) + # filter_mode == "all": reply to everything, treat as no content to skip + return False def process_auto_replies() -> int: @@ -906,7 +944,7 @@ def process_auto_replies() -> int: reviews = client.fetch_reviews( limit=100, unanswered_only=True, - allowed_ratings=AUTO_REPLY_STARS, + allowed_ratings=_load_enabled_stars(), ) except FeedbackApiError: db.set_setting(AUTO_REPLY_LAST_FETCH_KEY, str(time.time())) @@ -1265,8 +1303,13 @@ def index(): next_auto_reply_at=next_auto_reply_at, next_auto_reply_in_seconds=next_auto_reply_in_seconds, api_cooldown_seconds_left=api_cooldown_seconds_left, + enabled_stars=list(_load_enabled_stars()), + filter_mode=_load_filter_mode(), reply_pool_5_text=_pool_to_multiline_text(_load_reply_pool(5)), reply_pool_4_text=_pool_to_multiline_text(_load_reply_pool(4)), + reply_pool_3_text=_pool_to_multiline_text(_load_reply_pool(3)), + reply_pool_2_text=_pool_to_multiline_text(_load_reply_pool(2)), + reply_pool_1_text=_pool_to_multiline_text(_load_reply_pool(1)), reply_pool_5_list=_load_reply_pool(5), reply_pool_4_list=_load_reply_pool(4), auto_reply_queue=_load_auto_reply_queue(), @@ -1322,6 +1365,23 @@ def auto_reply_pools(): return redirect(url_for("index", status="pools_saved", action="unanswered", stars=[5, 4])) +@app.route("/auto-reply-settings", methods=["POST"]) +@login_required +def auto_reply_settings(): + stars = [int(s) for s in request.form.getlist("stars") if s.isdigit() and 1 <= int(s) <= 5] + filter_mode = request.form.get("filter_mode", "no_text") + if filter_mode not in ("no_text", "empty", "all"): + filter_mode = "no_text" + db.set_setting(AUTO_REPLY_STARS_KEY, json.dumps(stars)) + db.set_setting(AUTO_REPLY_FILTER_KEY, filter_mode) + # Save pools for each enabled star + for star in range(1, 6): + items = [i.strip() for i in request.form.getlist(f"pool_{star}_item") if i.strip()] + if items: + db.set_setting(f"auto_reply_pool_{star}", _pool_to_multiline_text(items)) + return redirect(url_for("index", status="pools_saved")) + + @app.route("/reply", methods=["POST"]) @login_required def reply_all(): diff --git a/static/styles.css b/static/styles.css index 8783034..b430407 100644 --- a/static/styles.css +++ b/static/styles.css @@ -897,7 +897,7 @@ textarea:focus { } .login-promo { - flex: 1 1 55%; + flex: 0 0 300px; background: linear-gradient(145deg, #3D0066 0%, #6B0FA8 45%, #CB11AB 100%); display: flex; align-items: center; @@ -930,8 +930,8 @@ textarea:focus { } .login-promo__logo { - width: 96px; - height: 96px; + width: 140px; + height: 140px; object-fit: contain; border-radius: 20px; background: white; @@ -1443,3 +1443,85 @@ textarea:focus { color: #6B7280; white-space: nowrap; } + +/* ─── Auto-reply settings ────────────────────────────────────────── */ +.settings-section { + margin-bottom: 24px; +} +.settings-label { + font-size: 13px; + font-weight: 600; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 12px; +} +.star-toggles { + display: flex; + gap: 10px; + flex-wrap: wrap; +} +.star-toggle { + cursor: pointer; + user-select: none; +} +.star-toggle input { display: none; } +.star-toggle span { + display: inline-flex; + align-items: center; + padding: 8px 18px; + border-radius: 99px; + font-weight: 700; + font-size: 15px; + border: 2px solid currentColor; + opacity: 0.35; + transition: opacity .15s, transform .15s; +} +.star-toggle input:checked + span { opacity: 1; transform: scale(1.05); } +.star-toggle--1 span { color: #EF4444; } +.star-toggle--2 span { color: #F97316; } +.star-toggle--3 span { color: #EAB308; } +.star-toggle--4 span { color: #3B82F6; } +.star-toggle--5 span { color: #22C55E; } + +.filter-options { + display: flex; + flex-direction: column; + gap: 10px; +} +.filter-option { + display: flex; + align-items: flex-start; + gap: 10px; + cursor: pointer; + font-size: 14px; +} +.filter-option input { margin-top: 3px; } +.filter-option small { display: block; color: var(--muted); font-size: 12px; } + +.pool-columns { + display: flex; + gap: 16px; + flex-wrap: wrap; +} +.pool-col { + flex: 1 1 180px; + min-width: 160px; + border-radius: var(--r); + padding: 16px; + border: 2px solid; +} +.pool-col--1 { border-color: #FEE2E2; background: #FFF5F5; } +.pool-col--2 { border-color: #FFEDD5; background: #FFFAF5; } +.pool-col--3 { border-color: #FEF9C3; background: #FFFEF0; } +.pool-col--4 { border-color: #DBEAFE; background: #F0F7FF; } +.pool-col--5 { border-color: #DCFCE7; background: #F0FFF4; } +.pool-col__header { + margin-bottom: 12px; + font-weight: 700; +} + +/* star-chip colors 1-3 */ +.star-chip--1 { background: #FEE2E2; color: #EF4444; } +.star-chip--2 { background: #FFEDD5; color: #F97316; } +.star-chip--3 { background: #FEF9C3; color: #CA8A04; } diff --git a/templates/index.html b/templates/index.html index 8bf9d73..f4999c3 100644 --- a/templates/index.html +++ b/templates/index.html @@ -40,29 +40,6 @@
{{ success_message }}
{% endif %} - -
-
-
-
- Оценки - {% for star in [5,4,3,2,1] %} - - - - - {% endfor %} -
-
- - - - -
-
-
-
-
@@ -92,33 +69,66 @@
- +
-

Пулы автоответов

+

Настройки автоответа

-

Для каждого нового отзыва текст выбирается случайно из пула.

-
- - -
-
-
- Ответы для 5★ -
-
- -
-
-
- Ответы для 4★ -
-
- + + + +
+
На какие оценки отвечать
+
+ {% for star in [1,2,3,4,5] %} + + {% endfor %}
-
- + + +
+
Отвечать на отзывы
+
+ + + +
+
+ + +
+
Шаблоны ответов
+
+ {% for star in [1,2,3,4,5] %} +
+
+ {{ star }}★ +
+
+ + +
+ {% endfor %} +
+
+ +
+
@@ -225,126 +235,12 @@ {% endif %}
- - {% if reviews %} - {% set unanswered_count = reviews | rejectattr('answer') | list | length %} - {% set answered_count = reviews | selectattr('answer') | list | length %} - -
-

{{ "Неотвеченные отзывы" if current_filter == 'unanswered' else "Все отзывы" }}

-

Оценки: {{ selected_stars_display|join(', ') }}★.

-
- - -
-
- {{ reviews|length }} - отзывов загружено -
- {% if unanswered_count > 0 %} -
- {{ unanswered_count }} - без ответа -
- {% endif %} - {% if answered_count > 0 %} -
- {{ answered_count }} - отвечено -
- {% endif %} - {% for star in [5,4,3,2,1] %} - {% set cnt = reviews | selectattr('rating', 'equalto', star) | list | length %} - {% if cnt > 0 %} -
- {{ cnt }} - {{ star }}★ -
- {% endif %} - {% endfor %} -
- - {% if current_filter == 'unanswered' and has_token %} -
- - - {% for star in selected_stars_display %} - - {% endfor %} - {% for review in reviews %} - - {% endfor %} -
- Допустимая длина: 2–5000 символов. - -
-
- {% endif %} - -
- {% for review in reviews %} -
-
-
- {{ review.product_name or 'Без названия' }} - ★ {{ review.rating }} -
-
- {{ review.user_name or 'Покупатель' }} - {{ review.created_at|format_datetime }} -
-
- - {% if review.text %} -

{{ review.text }}

- {% endif %} - -
- {% if review.pros %} -
Достоинства
-
{{ review.pros }}
- {% endif %} - {% if review.cons %} -
Недостатки
-
{{ review.cons }}
- {% endif %} -
- - {% if review.answer %} -

Ответ: {{ review.answer }}

- {% else %} -

Без ответа

- {% endif %} - - {% if has_token %} -
- Ответить на отзыв -
- - {% for star in selected_stars_display %} - - {% endfor %} - - -
-
- {% endif %} -
- {% endfor %} -
- - {% elif current_filter %} -
Отзывы с выбранными оценками ({{ selected_stars_display|join(', ') }}★) не найдены.
- {% endif %}