Sync project structure and apply feature updates
- Move files from remote_copy/ to root (proper project structure) - Add Docker setup: Dockerfile, docker-compose.yml with volume mounts - Auto-reply: fix skip logic (only text field, not pros/cons) - Auto-reply: fix _paginate to handle cooldown gracefully mid-pagination - Auto-reply: fix fetch timestamp not saved on API error (infinite retry loop) - Auto-reply: reduce EMPTY_REMAINING_FALLBACK from 600s to 150s default - UI: add auto-reply queue display with article number (nm_id) - UI: replace button with CSS tumbler toggle for auto-reply - UI: add review_created_at and review_id columns to journal - UI: add skipped/sent/error status colors to journal Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+14
@@ -0,0 +1,14 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
FLASK_PORT=5000
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
CMD ["python", "app.py"]
|
||||||
+583
-39
@@ -1,17 +1,21 @@
|
|||||||
|
import logging
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Optional, Set, Tuple
|
from typing import List, Optional, Set, Tuple
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from flask import Flask, g, redirect, render_template, request, session, url_for
|
from flask import Flask, g, jsonify, redirect, render_template, request, session, url_for
|
||||||
from werkzeug.security import check_password_hash, generate_password_hash
|
from werkzeug.security import check_password_hash, generate_password_hash
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
@@ -24,7 +28,28 @@ AUTO_REPLY_SETTING_KEY = "auto_reply_enabled"
|
|||||||
AUTO_REPLY_LAST_RUN_KEY = "auto_reply_last_run_ts"
|
AUTO_REPLY_LAST_RUN_KEY = "auto_reply_last_run_ts"
|
||||||
AUTO_REPLY_POOL_5_KEY = "auto_reply_pool_5"
|
AUTO_REPLY_POOL_5_KEY = "auto_reply_pool_5"
|
||||||
AUTO_REPLY_POOL_4_KEY = "auto_reply_pool_4"
|
AUTO_REPLY_POOL_4_KEY = "auto_reply_pool_4"
|
||||||
|
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"))
|
AUTO_REPLY_INTERVAL_MINUTES = int(os.getenv("AUTO_REPLY_INTERVAL_MINUTES", "10"))
|
||||||
|
AUTO_REPLY_INTERVAL_SECONDS = int(
|
||||||
|
os.getenv("AUTO_REPLY_INTERVAL_SECONDS", str(AUTO_REPLY_INTERVAL_MINUTES * 60))
|
||||||
|
)
|
||||||
|
AUTO_REPLY_FETCH_INTERVAL_SECONDS = int(
|
||||||
|
os.getenv("AUTO_REPLY_FETCH_INTERVAL_SECONDS", "300")
|
||||||
|
)
|
||||||
|
AUTO_REPLY_BATCH_LIMIT = max(1, int(os.getenv("AUTO_REPLY_BATCH_LIMIT", "25")))
|
||||||
|
API_GLOBAL_COOLDOWN_UNTIL_KEY = "api_global_cooldown_until_ts"
|
||||||
|
WB_REQUESTS_PER_SECOND = float(os.getenv("WB_REQUESTS_PER_SECOND", "0.8"))
|
||||||
|
WB_MIN_REQUEST_INTERVAL_SECONDS = max(1.1, 1.0 / max(0.1, WB_REQUESTS_PER_SECOND))
|
||||||
|
APP_TIMEZONE = ZoneInfo(os.getenv("APP_TIMEZONE", "Europe/Moscow"))
|
||||||
|
API_LOG_BODY_MAX = max(200, int(os.getenv("API_LOG_BODY_MAX", "1200")))
|
||||||
|
APP_LOG_FILE = os.getenv("APP_LOG_FILE", str(BASE_DIR / "app.log"))
|
||||||
|
APP_LOG_LEVEL = os.getenv("APP_LOG_LEVEL", "INFO").upper()
|
||||||
|
UI_FETCH_WHEN_AUTO_ENABLED = os.getenv("UI_FETCH_WHEN_AUTO_ENABLED", "0") == "1"
|
||||||
|
EMPTY_REMAINING_FALLBACK_COOLDOWN_SECONDS = int(
|
||||||
|
os.getenv("EMPTY_REMAINING_FALLBACK_COOLDOWN_SECONDS", "60")
|
||||||
|
)
|
||||||
|
AUTO_REPLY_LOOP_SLEEP_SECONDS = max(1, int(os.getenv("AUTO_REPLY_LOOP_SLEEP_SECONDS", "1")))
|
||||||
DEFAULT_REPLY_POOL_5 = [
|
DEFAULT_REPLY_POOL_5 = [
|
||||||
"Спасибо за высокую оценку! Нам очень приятно.",
|
"Спасибо за высокую оценку! Нам очень приятно.",
|
||||||
"Благодарим за отзыв и доверие к нашему магазину!",
|
"Благодарим за отзыв и доверие к нашему магазину!",
|
||||||
@@ -37,6 +62,44 @@ DEFAULT_REPLY_POOL_4 = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_logger() -> logging.Logger:
|
||||||
|
logger = logging.getLogger("wildberries-app")
|
||||||
|
if logger.handlers:
|
||||||
|
return logger
|
||||||
|
level = getattr(logging, APP_LOG_LEVEL, logging.INFO)
|
||||||
|
logger.setLevel(level)
|
||||||
|
formatter = logging.Formatter(
|
||||||
|
"%(asctime)s %(levelname)s %(name)s %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
)
|
||||||
|
file_handler = RotatingFileHandler(APP_LOG_FILE, maxBytes=5_000_000, backupCount=5)
|
||||||
|
file_handler.setFormatter(formatter)
|
||||||
|
stream_handler = logging.StreamHandler()
|
||||||
|
stream_handler.setFormatter(formatter)
|
||||||
|
logger.addHandler(file_handler)
|
||||||
|
logger.addHandler(stream_handler)
|
||||||
|
logger.propagate = False
|
||||||
|
return logger
|
||||||
|
|
||||||
|
|
||||||
|
logger = _setup_logger()
|
||||||
|
|
||||||
|
|
||||||
|
def _short_text(value: Optional[str], max_len: int = API_LOG_BODY_MAX) -> str:
|
||||||
|
text = value or ""
|
||||||
|
if len(text) <= max_len:
|
||||||
|
return text
|
||||||
|
return f"{text[:max_len]}...<trimmed {len(text) - max_len} chars>"
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_headers(headers: dict) -> dict:
|
||||||
|
safe = dict(headers)
|
||||||
|
auth = safe.get("Authorization")
|
||||||
|
if auth:
|
||||||
|
safe["Authorization"] = f"***{auth[-6:]}" if len(auth) >= 6 else "***"
|
||||||
|
return safe
|
||||||
|
|
||||||
|
|
||||||
class FeedbackApiError(RuntimeError):
|
class FeedbackApiError(RuntimeError):
|
||||||
"""Raised when the Wildberries Feedback API returns an error."""
|
"""Raised when the Wildberries Feedback API returns an error."""
|
||||||
|
|
||||||
@@ -50,6 +113,7 @@ class Review:
|
|||||||
rating: int
|
rating: int
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
product_name: str
|
product_name: str
|
||||||
|
nm_id: int
|
||||||
answer: Optional[str]
|
answer: Optional[str]
|
||||||
is_answered: bool
|
is_answered: bool
|
||||||
user_name: str
|
user_name: str
|
||||||
@@ -74,6 +138,7 @@ class Review:
|
|||||||
rating=payload.get("productValuation") or 0,
|
rating=payload.get("productValuation") or 0,
|
||||||
created_at=created_dt,
|
created_at=created_dt,
|
||||||
product_name=product.get("productName") or "",
|
product_name=product.get("productName") or "",
|
||||||
|
nm_id=int(product.get("nmId") or 0),
|
||||||
answer=answer_value,
|
answer=answer_value,
|
||||||
is_answered=bool(answer_value),
|
is_answered=bool(answer_value),
|
||||||
user_name=payload.get("userName") or "",
|
user_name=payload.get("userName") or "",
|
||||||
@@ -133,6 +198,7 @@ class Database:
|
|||||||
rating INTEGER NOT NULL,
|
rating INTEGER NOT NULL,
|
||||||
product_name TEXT,
|
product_name TEXT,
|
||||||
user_name TEXT,
|
user_name TEXT,
|
||||||
|
review_text TEXT,
|
||||||
reply_text TEXT NOT NULL,
|
reply_text TEXT NOT NULL,
|
||||||
status TEXT NOT NULL,
|
status TEXT NOT NULL,
|
||||||
error_text TEXT
|
error_text TEXT
|
||||||
@@ -141,6 +207,7 @@ class Database:
|
|||||||
)
|
)
|
||||||
self._ensure_admin(conn)
|
self._ensure_admin(conn)
|
||||||
self._ensure_token_user_column(conn)
|
self._ensure_token_user_column(conn)
|
||||||
|
self._ensure_auto_reply_log_review_text(conn)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -167,6 +234,16 @@ class Database:
|
|||||||
(admin_id,),
|
(admin_id,),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _ensure_auto_reply_log_review_text(self, conn: sqlite3.Connection) -> None:
|
||||||
|
info = conn.execute("PRAGMA table_info(auto_reply_logs)").fetchall()
|
||||||
|
columns = {row["name"] for row in info}
|
||||||
|
if "review_text" not in columns:
|
||||||
|
conn.execute("ALTER TABLE auto_reply_logs ADD COLUMN review_text TEXT")
|
||||||
|
if "nm_id" not in columns:
|
||||||
|
conn.execute("ALTER TABLE auto_reply_logs ADD COLUMN nm_id INTEGER")
|
||||||
|
if "review_created_at" not in columns:
|
||||||
|
conn.execute("ALTER TABLE auto_reply_logs ADD COLUMN review_created_at TEXT")
|
||||||
|
|
||||||
# User helpers
|
# User helpers
|
||||||
def create_user(self, username: str, password_hash: str) -> None:
|
def create_user(self, username: str, password_hash: str) -> None:
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
@@ -222,6 +299,17 @@ class Database:
|
|||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
def update_token(self, token_id: int, name: str, token: str) -> None:
|
||||||
|
conn = self._connect()
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE tokens SET name = ?, token = ? WHERE id = ?",
|
||||||
|
(name.strip(), token.strip(), token_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
def fetch_tokens_for_user(self, user_id: int, is_admin: bool) -> List[sqlite3.Row]:
|
def fetch_tokens_for_user(self, user_id: int, is_admin: bool) -> List[sqlite3.Row]:
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
try:
|
try:
|
||||||
@@ -296,7 +384,10 @@ class Database:
|
|||||||
review_id: str,
|
review_id: str,
|
||||||
rating: int,
|
rating: int,
|
||||||
product_name: str,
|
product_name: str,
|
||||||
|
nm_id: int = 0,
|
||||||
user_name: str,
|
user_name: str,
|
||||||
|
review_text: str,
|
||||||
|
review_created_at: str = "",
|
||||||
reply_text: str,
|
reply_text: str,
|
||||||
status: str,
|
status: str,
|
||||||
error_text: Optional[str] = None,
|
error_text: Optional[str] = None,
|
||||||
@@ -306,15 +397,18 @@ class Database:
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO auto_reply_logs(
|
INSERT INTO auto_reply_logs(
|
||||||
created_at, review_id, rating, product_name, user_name, reply_text, status, error_text
|
created_at, review_id, rating, product_name, nm_id, user_name, review_text, review_created_at, reply_text, status, error_text
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
datetime.utcnow().isoformat(),
|
datetime.utcnow().replace(microsecond=0).isoformat(),
|
||||||
review_id,
|
review_id,
|
||||||
rating,
|
rating,
|
||||||
product_name,
|
product_name,
|
||||||
|
nm_id,
|
||||||
user_name,
|
user_name,
|
||||||
|
review_text,
|
||||||
|
review_created_at,
|
||||||
reply_text,
|
reply_text,
|
||||||
status,
|
status,
|
||||||
error_text,
|
error_text,
|
||||||
@@ -347,45 +441,148 @@ class FeedbackClient:
|
|||||||
self.token = token
|
self.token = token
|
||||||
self.page_size = page_size
|
self.page_size = page_size
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
|
self._last_request_ts = 0.0
|
||||||
|
|
||||||
def _get_headers(self) -> dict:
|
def _get_headers(self) -> dict:
|
||||||
if not self.token:
|
if not self.token:
|
||||||
raise FeedbackApiError(
|
raise FeedbackApiError(
|
||||||
"Токен Wildberries не найден. Добавьте токен в личном кабинете."
|
"Токен Wildberries не найден. Добавьте токен в личном кабинете."
|
||||||
)
|
)
|
||||||
return {"Authorization": f"Bearer {self.token}"}
|
return {"Authorization": self.token}
|
||||||
|
|
||||||
|
def _throttle(self) -> None:
|
||||||
|
now = time.time()
|
||||||
|
wait_for = WB_MIN_REQUEST_INTERVAL_SECONDS - (now - self._last_request_ts)
|
||||||
|
if wait_for > 0:
|
||||||
|
time.sleep(wait_for)
|
||||||
|
self._last_request_ts = time.time()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_retry_seconds(response: requests.Response) -> Optional[float]:
|
||||||
|
for header_name in ("X-Ratelimit-Retry", "Retry-After", "X-Ratelimit-Reset"):
|
||||||
|
raw = response.headers.get(header_name)
|
||||||
|
if not raw:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
value = float(raw)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if value >= 0:
|
||||||
|
return value
|
||||||
|
return None
|
||||||
|
|
||||||
def _request(self, *, is_answered: bool, skip: int, take: int) -> List[dict]:
|
def _request(self, *, is_answered: bool, skip: int, take: int) -> List[dict]:
|
||||||
|
_check_api_cooldown_or_raise()
|
||||||
params = {
|
params = {
|
||||||
"isAnswered": str(is_answered).lower(),
|
"isAnswered": str(is_answered).lower(),
|
||||||
"skip": skip,
|
"skip": skip,
|
||||||
"take": take,
|
"take": take,
|
||||||
}
|
}
|
||||||
headers = self._get_headers()
|
headers = self._get_headers()
|
||||||
|
safe_headers = _sanitize_headers(headers)
|
||||||
|
max_attempts = 4
|
||||||
|
response = None
|
||||||
|
for attempt in range(1, max_attempts + 1):
|
||||||
|
self._throttle()
|
||||||
|
logger.info(
|
||||||
|
"WB API request method=GET url=%s params=%s attempt=%s/%s headers=%s",
|
||||||
|
self.BASE_URL,
|
||||||
|
params,
|
||||||
|
attempt,
|
||||||
|
max_attempts,
|
||||||
|
safe_headers,
|
||||||
|
)
|
||||||
response = requests.get(self.BASE_URL, params=params, headers=headers, timeout=self.timeout)
|
response = requests.get(self.BASE_URL, params=params, headers=headers, timeout=self.timeout)
|
||||||
if not response.ok:
|
logger.info(
|
||||||
|
"WB API response method=GET url=%s status=%s headers=%s body=%s",
|
||||||
|
self.BASE_URL,
|
||||||
|
response.status_code,
|
||||||
|
dict(response.headers),
|
||||||
|
_short_text(response.text),
|
||||||
|
)
|
||||||
|
if response.ok:
|
||||||
|
remaining = response.headers.get("X-Ratelimit-Remaining")
|
||||||
|
reset_seconds = self._extract_retry_seconds(response)
|
||||||
|
if remaining == "0":
|
||||||
|
if reset_seconds is not None and reset_seconds > 0:
|
||||||
|
_set_api_cooldown(reset_seconds + 30)
|
||||||
|
else:
|
||||||
|
_set_api_cooldown(EMPTY_REMAINING_FALLBACK_COOLDOWN_SECONDS)
|
||||||
|
logger.warning(
|
||||||
|
"WB API remaining=0 without reset headers. Fallback cooldown=%s sec",
|
||||||
|
EMPTY_REMAINING_FALLBACK_COOLDOWN_SECONDS,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
if response.status_code == 429 and attempt < max_attempts:
|
||||||
|
retry_seconds = self._extract_retry_seconds(response)
|
||||||
|
if retry_seconds is not None and retry_seconds > 0:
|
||||||
|
_set_api_cooldown(retry_seconds + 30)
|
||||||
|
if retry_seconds is not None and retry_seconds > 30:
|
||||||
|
logger.warning(
|
||||||
|
"WB API long rate limit on GET. retry_after_seconds=%s. Stop retries for this call.",
|
||||||
|
retry_seconds,
|
||||||
|
)
|
||||||
|
raise FeedbackApiError(
|
||||||
|
f"Лимит WB API. Повторите позже (примерно через {int(retry_seconds)} сек)."
|
||||||
|
)
|
||||||
|
delay = retry_seconds if retry_seconds is not None else float(2 ** attempt)
|
||||||
|
logger.warning(
|
||||||
|
"WB API rate limit on GET. sleep_seconds=%s x_ratelimit_retry=%s x_ratelimit_reset=%s",
|
||||||
|
max(1.0, delay),
|
||||||
|
response.headers.get("X-Ratelimit-Retry"),
|
||||||
|
response.headers.get("X-Ratelimit-Reset"),
|
||||||
|
)
|
||||||
|
time.sleep(max(1.0, delay))
|
||||||
|
continue
|
||||||
|
if response.status_code == 429:
|
||||||
|
retry_seconds = self._extract_retry_seconds(response)
|
||||||
|
if retry_seconds is not None and retry_seconds > 0:
|
||||||
|
_set_api_cooldown(retry_seconds + 30)
|
||||||
raise FeedbackApiError(f"Ошибка запроса: {response.status_code} {response.text}")
|
raise FeedbackApiError(f"Ошибка запроса: {response.status_code} {response.text}")
|
||||||
|
if response is None:
|
||||||
|
raise FeedbackApiError("Не удалось получить ответ от API Wildberries.")
|
||||||
payload = response.json()
|
payload = response.json()
|
||||||
if payload.get("error"):
|
if payload.get("error"):
|
||||||
raise FeedbackApiError(payload.get("errorText") or "Не удалось получить отзывы.")
|
raise FeedbackApiError(payload.get("errorText") or "Не удалось получить отзывы.")
|
||||||
data = payload.get("data") or {}
|
data = payload.get("data") or {}
|
||||||
return data.get("feedbacks") or []
|
return data.get("feedbacks") or []
|
||||||
|
|
||||||
|
def _paginate(self, *, is_answered: bool, max_items: int = 500) -> List[dict]:
|
||||||
|
result: List[dict] = []
|
||||||
|
skip = 0
|
||||||
|
take = self.page_size
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
page = self._request(is_answered=is_answered, skip=skip, take=take)
|
||||||
|
except FeedbackApiError:
|
||||||
|
if not result:
|
||||||
|
raise
|
||||||
|
logger.info("_paginate stopping early due to API error, returning %s items collected", len(result))
|
||||||
|
break
|
||||||
|
if not page:
|
||||||
|
break
|
||||||
|
result.extend(page)
|
||||||
|
if len(page) < take:
|
||||||
|
break
|
||||||
|
if len(result) >= max_items:
|
||||||
|
break
|
||||||
|
skip += len(page)
|
||||||
|
return result
|
||||||
|
|
||||||
def fetch_reviews(
|
def fetch_reviews(
|
||||||
self,
|
self,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
unanswered_only: bool = False,
|
unanswered_only: bool = False,
|
||||||
allowed_ratings: Optional[Set[int]] = None,
|
allowed_ratings: Optional[Set[int]] = None,
|
||||||
) -> List[Review]:
|
) -> List[Review]:
|
||||||
reviews: List[Review] = []
|
|
||||||
if unanswered_only:
|
if unanswered_only:
|
||||||
raw_reviews = self._request(is_answered=False, skip=0, take=min(limit, self.page_size))
|
raw = self._paginate(is_answered=False, max_items=max(limit * 3, 300))
|
||||||
reviews = [Review.from_api(item) for item in raw_reviews]
|
reviews = [Review.from_api(item) for item in raw]
|
||||||
else:
|
else:
|
||||||
raw = []
|
raw_unanswered = self._paginate(is_answered=False, max_items=limit)
|
||||||
raw.extend(self._request(is_answered=False, skip=0, take=self.page_size))
|
raw_answered = self._paginate(is_answered=True, max_items=limit)
|
||||||
raw.extend(self._request(is_answered=True, skip=0, take=self.page_size))
|
raw = raw_unanswered + raw_answered
|
||||||
raw.sort(key=lambda r: r.get("createdDate"), reverse=True)
|
raw.sort(key=lambda r: r.get("createdDate") or "", reverse=True)
|
||||||
reviews = [Review.from_api(item) for item in raw]
|
reviews = [Review.from_api(item) for item in raw]
|
||||||
|
|
||||||
allowed = allowed_ratings or DEFAULT_STARS
|
allowed = allowed_ratings or DEFAULT_STARS
|
||||||
@@ -398,15 +595,74 @@ class FeedbackClient:
|
|||||||
self._request(is_answered=False, skip=0, take=1)
|
self._request(is_answered=False, skip=0, take=1)
|
||||||
|
|
||||||
def send_answer(self, review_id: str, text: str) -> None:
|
def send_answer(self, review_id: str, text: str) -> None:
|
||||||
|
_check_api_cooldown_or_raise()
|
||||||
payload = {"id": review_id, "text": text}
|
payload = {"id": review_id, "text": text}
|
||||||
|
max_attempts = 4
|
||||||
|
for attempt in range(1, max_attempts + 1):
|
||||||
|
self._throttle()
|
||||||
|
safe_headers = _sanitize_headers(self._get_headers())
|
||||||
|
logger.info(
|
||||||
|
"WB API request method=POST url=%s payload=%s attempt=%s/%s headers=%s",
|
||||||
|
self.ANSWER_URL,
|
||||||
|
payload,
|
||||||
|
attempt,
|
||||||
|
max_attempts,
|
||||||
|
safe_headers,
|
||||||
|
)
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
self.ANSWER_URL,
|
self.ANSWER_URL,
|
||||||
json=payload,
|
json=payload,
|
||||||
headers=self._get_headers(),
|
headers=self._get_headers(),
|
||||||
timeout=self.timeout,
|
timeout=self.timeout,
|
||||||
)
|
)
|
||||||
if not response.ok:
|
logger.info(
|
||||||
raise FeedbackApiError(f"Не удалось отправить ответ: {response.status_code} {response.text}")
|
"WB API response method=POST url=%s status=%s headers=%s body=%s",
|
||||||
|
self.ANSWER_URL,
|
||||||
|
response.status_code,
|
||||||
|
dict(response.headers),
|
||||||
|
_short_text(response.text),
|
||||||
|
)
|
||||||
|
if response.ok:
|
||||||
|
remaining = response.headers.get("X-Ratelimit-Remaining")
|
||||||
|
reset_seconds = self._extract_retry_seconds(response)
|
||||||
|
if remaining == "0":
|
||||||
|
if reset_seconds is not None and reset_seconds > 0:
|
||||||
|
_set_api_cooldown(reset_seconds + 30)
|
||||||
|
else:
|
||||||
|
_set_api_cooldown(EMPTY_REMAINING_FALLBACK_COOLDOWN_SECONDS)
|
||||||
|
logger.warning(
|
||||||
|
"WB API remaining=0 without reset headers. Fallback cooldown=%s sec",
|
||||||
|
EMPTY_REMAINING_FALLBACK_COOLDOWN_SECONDS,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if response.status_code == 429 and attempt < max_attempts:
|
||||||
|
retry_seconds = self._extract_retry_seconds(response)
|
||||||
|
if retry_seconds is not None and retry_seconds > 0:
|
||||||
|
_set_api_cooldown(retry_seconds + 30)
|
||||||
|
if retry_seconds is not None and retry_seconds > 30:
|
||||||
|
logger.warning(
|
||||||
|
"WB API long rate limit on POST. retry_after_seconds=%s. Stop retries for this call.",
|
||||||
|
retry_seconds,
|
||||||
|
)
|
||||||
|
raise FeedbackApiError(
|
||||||
|
f"Лимит WB API. Повторите позже (примерно через {int(retry_seconds)} сек)."
|
||||||
|
)
|
||||||
|
delay = retry_seconds if retry_seconds is not None else float(2 ** attempt)
|
||||||
|
logger.warning(
|
||||||
|
"WB API rate limit on POST. sleep_seconds=%s x_ratelimit_retry=%s x_ratelimit_reset=%s",
|
||||||
|
max(1.0, delay),
|
||||||
|
response.headers.get("X-Ratelimit-Retry"),
|
||||||
|
response.headers.get("X-Ratelimit-Reset"),
|
||||||
|
)
|
||||||
|
time.sleep(max(1.0, delay))
|
||||||
|
continue
|
||||||
|
if response.status_code == 429:
|
||||||
|
retry_seconds = self._extract_retry_seconds(response)
|
||||||
|
if retry_seconds is not None and retry_seconds > 0:
|
||||||
|
_set_api_cooldown(retry_seconds + 30)
|
||||||
|
raise FeedbackApiError(
|
||||||
|
f"Не удалось отправить ответ: {response.status_code} {response.text}"
|
||||||
|
)
|
||||||
|
|
||||||
def answer_many(self, review_ids: List[str], text: str) -> int:
|
def answer_many(self, review_ids: List[str], text: str) -> int:
|
||||||
sent = 0
|
sent = 0
|
||||||
@@ -505,6 +761,101 @@ def set_auto_reply_enabled(value: bool) -> None:
|
|||||||
db.set_setting(AUTO_REPLY_SETTING_KEY, "1" if value else "0")
|
db.set_setting(AUTO_REPLY_SETTING_KEY, "1" if value else "0")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_api_cooldown_seconds_left() -> int:
|
||||||
|
raw = db.get_setting(API_GLOBAL_COOLDOWN_UNTIL_KEY)
|
||||||
|
if not raw:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
until_ts = float(raw)
|
||||||
|
except ValueError:
|
||||||
|
return 0
|
||||||
|
return max(0, int(until_ts - time.time()))
|
||||||
|
|
||||||
|
|
||||||
|
def _set_api_cooldown(seconds: float) -> None:
|
||||||
|
seconds_int = max(0, int(seconds))
|
||||||
|
if seconds_int <= 0:
|
||||||
|
return
|
||||||
|
until_ts = time.time() + seconds_int
|
||||||
|
db.set_setting(API_GLOBAL_COOLDOWN_UNTIL_KEY, str(until_ts))
|
||||||
|
logger.warning("Global API cooldown set for %s seconds", seconds_int)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_api_cooldown_or_raise() -> None:
|
||||||
|
seconds_left = _get_api_cooldown_seconds_left()
|
||||||
|
if seconds_left > 0:
|
||||||
|
raise FeedbackApiError(
|
||||||
|
f"Лимит WB API активен. Повторите позже (примерно через {seconds_left} сек)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_auto_reply_queue() -> List[dict]:
|
||||||
|
raw = db.get_setting(AUTO_REPLY_QUEUE_KEY)
|
||||||
|
if not raw:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return []
|
||||||
|
if not isinstance(payload, list):
|
||||||
|
return []
|
||||||
|
queue = []
|
||||||
|
for item in payload:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
if not item.get("id"):
|
||||||
|
continue
|
||||||
|
queue.append(item)
|
||||||
|
return queue
|
||||||
|
|
||||||
|
|
||||||
|
def _save_auto_reply_queue(queue: List[dict]) -> None:
|
||||||
|
db.set_setting(AUTO_REPLY_QUEUE_KEY, json.dumps(queue, ensure_ascii=False))
|
||||||
|
|
||||||
|
|
||||||
|
def _build_auto_reply_queue(reviews: List[Review]) -> List[dict]:
|
||||||
|
queue: List[dict] = []
|
||||||
|
for review in reviews:
|
||||||
|
if _has_review_content(review):
|
||||||
|
logger.info("Auto-reply skip review_id=%s reason=has_review_text", review.id)
|
||||||
|
db.add_auto_reply_log(
|
||||||
|
review_id=review.id,
|
||||||
|
rating=review.rating,
|
||||||
|
product_name=review.product_name,
|
||||||
|
nm_id=review.nm_id,
|
||||||
|
user_name=review.user_name,
|
||||||
|
review_text=review.text,
|
||||||
|
review_created_at=review.created_at.isoformat() if review.created_at else "",
|
||||||
|
reply_text="",
|
||||||
|
status="skipped",
|
||||||
|
error_text="Пропущен: у отзыва есть текст/достоинства/недостатки.",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
queue.append(
|
||||||
|
{
|
||||||
|
"id": review.id,
|
||||||
|
"rating": review.rating,
|
||||||
|
"product_name": review.product_name,
|
||||||
|
"nm_id": review.nm_id,
|
||||||
|
"user_name": review.user_name,
|
||||||
|
"review_text": review.text,
|
||||||
|
"review_created_at": review.created_at.isoformat() if review.created_at else "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return queue
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_window_open() -> bool:
|
||||||
|
raw = db.get_setting(AUTO_REPLY_LAST_FETCH_KEY)
|
||||||
|
if not raw:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
last_fetch = float(raw)
|
||||||
|
except ValueError:
|
||||||
|
return True
|
||||||
|
return (time.time() - last_fetch) >= AUTO_REPLY_FETCH_INTERVAL_SECONDS
|
||||||
|
|
||||||
|
|
||||||
def _load_reply_pool(star: int) -> List[str]:
|
def _load_reply_pool(star: int) -> List[str]:
|
||||||
if star == 5:
|
if star == 5:
|
||||||
db_value = db.get_setting(AUTO_REPLY_POOL_5_KEY)
|
db_value = db.get_setting(AUTO_REPLY_POOL_5_KEY)
|
||||||
@@ -526,38 +877,116 @@ def _pick_auto_reply(star: int) -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _has_review_content(review: Review) -> bool:
|
||||||
|
return bool((review.text or "").strip())
|
||||||
|
|
||||||
|
|
||||||
def process_auto_replies() -> int:
|
def process_auto_replies() -> int:
|
||||||
|
if _get_api_cooldown_seconds_left() > 0:
|
||||||
|
logger.info(
|
||||||
|
"Auto-reply skipped: global cooldown active for %s seconds",
|
||||||
|
_get_api_cooldown_seconds_left(),
|
||||||
|
)
|
||||||
|
return 0
|
||||||
token_value = _get_background_token()
|
token_value = _get_background_token()
|
||||||
if not token_value:
|
if not token_value:
|
||||||
|
logger.info("Auto-reply skipped: background token is missing.")
|
||||||
return 0
|
return 0
|
||||||
client = FeedbackClient(token_value)
|
client = FeedbackClient(token_value)
|
||||||
reviews = client.fetch_reviews(limit=100, unanswered_only=True, allowed_ratings=AUTO_REPLY_STARS)
|
queue = _load_auto_reply_queue()
|
||||||
|
if not queue:
|
||||||
|
if not _fetch_window_open():
|
||||||
|
logger.info(
|
||||||
|
"Auto-reply queue empty, but fetch window is closed. fetch_interval_seconds=%s",
|
||||||
|
AUTO_REPLY_FETCH_INTERVAL_SECONDS,
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
logger.info("Auto-reply queue is empty. Fetch new reviews once.")
|
||||||
|
try:
|
||||||
|
reviews = client.fetch_reviews(
|
||||||
|
limit=100,
|
||||||
|
unanswered_only=True,
|
||||||
|
allowed_ratings=AUTO_REPLY_STARS,
|
||||||
|
)
|
||||||
|
except FeedbackApiError:
|
||||||
|
db.set_setting(AUTO_REPLY_LAST_FETCH_KEY, str(time.time()))
|
||||||
|
raise
|
||||||
|
db.set_setting(AUTO_REPLY_LAST_FETCH_KEY, str(time.time()))
|
||||||
|
queue = _build_auto_reply_queue(reviews)
|
||||||
|
_save_auto_reply_queue(queue)
|
||||||
|
logger.info("Auto-reply queue rebuilt size=%s", len(queue))
|
||||||
|
cooldown_after_fetch = _get_api_cooldown_seconds_left()
|
||||||
|
if cooldown_after_fetch > 0:
|
||||||
|
logger.info("Auto-reply stop after fetch: cooldown active for %s seconds", cooldown_after_fetch)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if not queue:
|
||||||
|
logger.info("Auto-reply queue has no eligible reviews.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Auto-reply cycle start queue_size=%s batch_limit=%s",
|
||||||
|
len(queue),
|
||||||
|
AUTO_REPLY_BATCH_LIMIT,
|
||||||
|
)
|
||||||
sent = 0
|
sent = 0
|
||||||
for review in reviews:
|
processed = 0
|
||||||
reply_text = _pick_auto_reply(review.rating)
|
while queue and processed < AUTO_REPLY_BATCH_LIMIT:
|
||||||
|
item = queue[0]
|
||||||
|
review_id = item.get("id", "")
|
||||||
|
rating = int(item.get("rating") or 0)
|
||||||
|
reply_text = _pick_auto_reply(rating)
|
||||||
if not reply_text:
|
if not reply_text:
|
||||||
|
logger.info("Auto-reply skip review_id=%s reason=no_pool_reply rating=%s", review_id, rating)
|
||||||
|
queue.pop(0)
|
||||||
|
processed += 1
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
client.send_answer(review.id, reply_text)
|
client.send_answer(review_id, reply_text)
|
||||||
|
logger.info("Auto-reply sent review_id=%s rating=%s", review_id, rating)
|
||||||
db.add_auto_reply_log(
|
db.add_auto_reply_log(
|
||||||
review_id=review.id,
|
review_id=review_id,
|
||||||
rating=review.rating,
|
rating=rating,
|
||||||
product_name=review.product_name,
|
product_name=item.get("product_name", ""),
|
||||||
user_name=review.user_name,
|
nm_id=int(item.get("nm_id") or 0),
|
||||||
|
review_created_at=item.get("review_created_at", ""),
|
||||||
|
user_name=item.get("user_name", ""),
|
||||||
|
review_text=item.get("review_text", ""),
|
||||||
reply_text=reply_text,
|
reply_text=reply_text,
|
||||||
status="sent",
|
status="sent",
|
||||||
)
|
)
|
||||||
sent += 1
|
sent += 1
|
||||||
|
queue.pop(0)
|
||||||
|
processed += 1
|
||||||
except FeedbackApiError as exc:
|
except FeedbackApiError as exc:
|
||||||
|
exc_str = str(exc)
|
||||||
|
is_rate_limit = (
|
||||||
|
"Лимит WB API" in exc_str
|
||||||
|
or "429" in exc_str
|
||||||
|
or "cooldown" in exc_str.lower()
|
||||||
|
or "rate limit" in exc_str.lower()
|
||||||
|
or "too many" in exc_str.lower()
|
||||||
|
)
|
||||||
|
if is_rate_limit:
|
||||||
|
logger.info("Auto-reply rate limit, keeping queue head review_id=%s", review_id)
|
||||||
|
break
|
||||||
|
logger.warning("Auto-reply failed review_id=%s rating=%s error=%s", review_id, rating, exc)
|
||||||
db.add_auto_reply_log(
|
db.add_auto_reply_log(
|
||||||
review_id=review.id,
|
review_id=review_id,
|
||||||
rating=review.rating,
|
rating=rating,
|
||||||
product_name=review.product_name,
|
product_name=item.get("product_name", ""),
|
||||||
user_name=review.user_name,
|
nm_id=int(item.get("nm_id") or 0),
|
||||||
|
review_created_at=item.get("review_created_at", ""),
|
||||||
|
user_name=item.get("user_name", ""),
|
||||||
|
review_text=item.get("review_text", ""),
|
||||||
reply_text=reply_text,
|
reply_text=reply_text,
|
||||||
status="failed",
|
status="failed",
|
||||||
error_text=str(exc),
|
error_text=str(exc),
|
||||||
)
|
)
|
||||||
|
queue.pop(0)
|
||||||
|
processed += 1
|
||||||
|
_save_auto_reply_queue(queue)
|
||||||
|
logger.info("Auto-reply cycle finished sent_count=%s", sent)
|
||||||
return sent
|
return sent
|
||||||
|
|
||||||
|
|
||||||
@@ -568,7 +997,21 @@ def _scheduler_should_run(last_run_raw: Optional[str], now_ts: float) -> bool:
|
|||||||
last_run = float(last_run_raw)
|
last_run = float(last_run_raw)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return True
|
return True
|
||||||
return now_ts - last_run >= AUTO_REPLY_INTERVAL_MINUTES * 60
|
return now_ts - last_run >= AUTO_REPLY_INTERVAL_SECONDS
|
||||||
|
|
||||||
|
|
||||||
|
def _next_auto_reply_meta() -> Tuple[Optional[datetime], Optional[int]]:
|
||||||
|
last_run_raw = db.get_setting(AUTO_REPLY_LAST_RUN_KEY)
|
||||||
|
if not last_run_raw:
|
||||||
|
return None, None
|
||||||
|
try:
|
||||||
|
last_run = float(last_run_raw)
|
||||||
|
except ValueError:
|
||||||
|
return None, None
|
||||||
|
next_ts = last_run + AUTO_REPLY_INTERVAL_SECONDS
|
||||||
|
seconds_left = max(0, int(next_ts - time.time()))
|
||||||
|
next_dt = datetime.fromtimestamp(next_ts, tz=timezone.utc).astimezone(APP_TIMEZONE)
|
||||||
|
return next_dt, seconds_left
|
||||||
|
|
||||||
|
|
||||||
def auto_reply_loop() -> None:
|
def auto_reply_loop() -> None:
|
||||||
@@ -578,18 +1021,39 @@ def auto_reply_loop() -> None:
|
|||||||
now_ts = time.time()
|
now_ts = time.time()
|
||||||
last_run = db.get_setting(AUTO_REPLY_LAST_RUN_KEY)
|
last_run = db.get_setting(AUTO_REPLY_LAST_RUN_KEY)
|
||||||
if _scheduler_should_run(last_run, now_ts):
|
if _scheduler_should_run(last_run, now_ts):
|
||||||
process_auto_replies()
|
logger.info("Auto-reply loop trigger start last_run=%s now_ts=%s", last_run, now_ts)
|
||||||
db.set_setting(AUTO_REPLY_LAST_RUN_KEY, str(now_ts))
|
db.set_setting(AUTO_REPLY_LAST_RUN_KEY, str(now_ts))
|
||||||
|
process_auto_replies()
|
||||||
|
else:
|
||||||
|
logger.info("Auto-reply loop skip schedule last_run=%s now_ts=%s", last_run, now_ts)
|
||||||
|
else:
|
||||||
|
logger.info("Auto-reply loop skip: disabled")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.exception("Auto-reply loop crashed")
|
||||||
time.sleep(60)
|
time.sleep(AUTO_REPLY_LOOP_SLEEP_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
@app.template_filter("format_datetime")
|
@app.template_filter("format_datetime")
|
||||||
def format_datetime(value: Optional[datetime]) -> str:
|
def format_datetime(value: Optional[datetime]) -> str:
|
||||||
if not value:
|
if not value:
|
||||||
return ""
|
return ""
|
||||||
return value.strftime("%d.%m.%Y %H:%M")
|
dt = value
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.astimezone(APP_TIMEZONE).strftime("%d.%m.%Y %H:%M")
|
||||||
|
|
||||||
|
|
||||||
|
@app.template_filter("format_log_datetime")
|
||||||
|
def format_log_datetime(value: Optional[str]) -> str:
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return value
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.astimezone(APP_TIMEZONE).strftime("%d.%m.%Y %H:%M")
|
||||||
|
|
||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
@@ -603,6 +1067,26 @@ def load_current_user() -> None:
|
|||||||
else:
|
else:
|
||||||
session.pop("user_id", None)
|
session.pop("user_id", None)
|
||||||
session.pop("token_id", None)
|
session.pop("token_id", None)
|
||||||
|
logger.info(
|
||||||
|
"HTTP request method=%s path=%s query=%s remote=%s user_id=%s",
|
||||||
|
request.method,
|
||||||
|
request.path,
|
||||||
|
request.query_string.decode("utf-8", errors="ignore"),
|
||||||
|
request.remote_addr,
|
||||||
|
g.user["id"] if g.user else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.after_request
|
||||||
|
def log_response(response):
|
||||||
|
logger.info(
|
||||||
|
"HTTP response method=%s path=%s status=%s length=%s",
|
||||||
|
request.method,
|
||||||
|
request.path,
|
||||||
|
response.status_code,
|
||||||
|
response.calculate_content_length(),
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
def login_required(view):
|
def login_required(view):
|
||||||
@@ -634,6 +1118,7 @@ def cabinet():
|
|||||||
status = request.args.get("status")
|
status = request.args.get("status")
|
||||||
status_map = {
|
status_map = {
|
||||||
"added": "Токен сохранён.",
|
"added": "Токен сохранён.",
|
||||||
|
"updated": "Токен обновлён.",
|
||||||
"selected": "Активирован выбранный магазин.",
|
"selected": "Активирован выбранный магазин.",
|
||||||
"checked": "Токен успешно проверен.",
|
"checked": "Токен успешно проверен.",
|
||||||
}
|
}
|
||||||
@@ -650,6 +1135,18 @@ def cabinet():
|
|||||||
else:
|
else:
|
||||||
db.add_token(user_id=user["id"], name=name, token=token_value)
|
db.add_token(user_id=user["id"], name=name, token=token_value)
|
||||||
return redirect(url_for("cabinet", status="added"))
|
return redirect(url_for("cabinet", status="added"))
|
||||||
|
elif action == "edit":
|
||||||
|
token_id = request.form.get("token_id")
|
||||||
|
token_row = db.get_token(int(token_id)) if token_id else None
|
||||||
|
name = (request.form.get("name") or "").strip()
|
||||||
|
token_value = (request.form.get("token") or "").strip()
|
||||||
|
if not token_row or not (user["is_admin"] or token_row["user_id"] == user["id"]):
|
||||||
|
error_message = "Недостаточно прав для изменения токена."
|
||||||
|
elif not name or not token_value:
|
||||||
|
error_message = "Введите название и токен."
|
||||||
|
else:
|
||||||
|
db.update_token(token_id=token_row["id"], name=name, token=token_value)
|
||||||
|
return redirect(url_for("cabinet", status="updated"))
|
||||||
elif action == "select":
|
elif action == "select":
|
||||||
token_id = request.form.get("token_id")
|
token_id = request.form.get("token_id")
|
||||||
if token_id:
|
if token_id:
|
||||||
@@ -671,7 +1168,13 @@ def cabinet():
|
|||||||
error_message = "Недостаточно прав для проверки токена."
|
error_message = "Недостаточно прав для проверки токена."
|
||||||
raw_tokens = db.fetch_tokens_for_user(user["id"], bool(user["is_admin"]))
|
raw_tokens = db.fetch_tokens_for_user(user["id"], bool(user["is_admin"]))
|
||||||
tokens = [
|
tokens = [
|
||||||
{"id": row["id"], "name": row["name"], "owner": row["owner"], "user_id": row["user_id"]}
|
{
|
||||||
|
"id": row["id"],
|
||||||
|
"name": row["name"],
|
||||||
|
"token": row["token"],
|
||||||
|
"owner": row["owner"],
|
||||||
|
"user_id": row["user_id"],
|
||||||
|
}
|
||||||
for row in raw_tokens
|
for row in raw_tokens
|
||||||
]
|
]
|
||||||
active_token_id = session.get("token_id")
|
active_token_id = session.get("token_id")
|
||||||
@@ -693,6 +1196,8 @@ def index():
|
|||||||
selected_stars_list = _parse_selected_stars(request.args.getlist("stars"))
|
selected_stars_list = _parse_selected_stars(request.args.getlist("stars"))
|
||||||
selected_stars = set(selected_stars_list)
|
selected_stars = set(selected_stars_list)
|
||||||
selected_stars_display = sorted(selected_stars, reverse=True)
|
selected_stars_display = sorted(selected_stars, reverse=True)
|
||||||
|
next_auto_reply_at, next_auto_reply_in_seconds = _next_auto_reply_meta()
|
||||||
|
api_cooldown_seconds_left = _get_api_cooldown_seconds_left()
|
||||||
active_token_value, active_token_name = _get_active_token()
|
active_token_value, active_token_name = _get_active_token()
|
||||||
client: Optional[FeedbackClient] = None
|
client: Optional[FeedbackClient] = None
|
||||||
client_error: Optional[str] = None
|
client_error: Optional[str] = None
|
||||||
@@ -706,7 +1211,12 @@ def index():
|
|||||||
success_message: Optional[str] = None
|
success_message: Optional[str] = None
|
||||||
|
|
||||||
if action in {"all", "unanswered"}:
|
if action in {"all", "unanswered"}:
|
||||||
if client:
|
if is_auto_reply_enabled() and not UI_FETCH_WHEN_AUTO_ENABLED:
|
||||||
|
error_message = (
|
||||||
|
"Выгрузка отзывов временно отключена при активном автоответе, "
|
||||||
|
"чтобы не расходовать лимит API. Отключите автоответ или включите UI_FETCH_WHEN_AUTO_ENABLED=1."
|
||||||
|
)
|
||||||
|
elif client:
|
||||||
try:
|
try:
|
||||||
if action == "all":
|
if action == "all":
|
||||||
reviews = client.fetch_reviews(
|
reviews = client.fetch_reviews(
|
||||||
@@ -751,13 +1261,34 @@ def index():
|
|||||||
current_action=action or "all",
|
current_action=action or "all",
|
||||||
auto_reply_enabled=is_auto_reply_enabled(),
|
auto_reply_enabled=is_auto_reply_enabled(),
|
||||||
auto_reply_interval_minutes=AUTO_REPLY_INTERVAL_MINUTES,
|
auto_reply_interval_minutes=AUTO_REPLY_INTERVAL_MINUTES,
|
||||||
|
auto_reply_interval_seconds=AUTO_REPLY_INTERVAL_SECONDS,
|
||||||
|
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,
|
||||||
reply_pool_5_text=_pool_to_multiline_text(_load_reply_pool(5)),
|
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_4_text=_pool_to_multiline_text(_load_reply_pool(4)),
|
||||||
|
reply_pool_5_list=_load_reply_pool(5),
|
||||||
|
reply_pool_4_list=_load_reply_pool(4),
|
||||||
|
auto_reply_queue=_load_auto_reply_queue(),
|
||||||
auto_reply_logs=db.list_auto_reply_logs(limit=100),
|
auto_reply_logs=db.list_auto_reply_logs(limit=100),
|
||||||
current_user=g.user,
|
current_user=g.user,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/yandex_b847b9b35f967fcc.html")
|
||||||
|
def yandex_verify():
|
||||||
|
return '<html><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"></head><body>Verification: b847b9b35f967fcc</body></html>', 200, {"Content-Type": "text/html; charset=UTF-8"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/status")
|
||||||
|
@login_required
|
||||||
|
def api_status():
|
||||||
|
logs = db.list_auto_reply_logs(limit=1)
|
||||||
|
last_id = logs[0]["id"] if logs else None
|
||||||
|
cooldown = _get_api_cooldown_seconds_left()
|
||||||
|
return jsonify({"last_log_id": last_id, "cooldown": cooldown})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/auto-reply-toggle", methods=["POST"])
|
@app.route("/auto-reply-toggle", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def auto_reply_toggle():
|
def auto_reply_toggle():
|
||||||
@@ -769,11 +1300,12 @@ def auto_reply_toggle():
|
|||||||
@app.route("/auto-reply-pools", methods=["POST"])
|
@app.route("/auto-reply-pools", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def auto_reply_pools():
|
def auto_reply_pools():
|
||||||
pool_5_raw = (request.form.get("pool_5") or "").strip()
|
pool_5 = [item.strip() for item in request.form.getlist("pool_5_item") if item.strip()]
|
||||||
pool_4_raw = (request.form.get("pool_4") or "").strip()
|
pool_4 = [item.strip() for item in request.form.getlist("pool_4_item") if item.strip()]
|
||||||
pool_5 = _parse_pool(pool_5_raw, [])
|
is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest"
|
||||||
pool_4 = _parse_pool(pool_4_raw, [])
|
|
||||||
if not pool_5 or not pool_4:
|
if not pool_5 or not pool_4:
|
||||||
|
if is_ajax:
|
||||||
|
return jsonify({"ok": False, "error": "Для 5★ и 4★ укажите минимум по одному варианту ответа."}), 400
|
||||||
return redirect(
|
return redirect(
|
||||||
url_for(
|
url_for(
|
||||||
"index",
|
"index",
|
||||||
@@ -785,6 +1317,8 @@ def auto_reply_pools():
|
|||||||
)
|
)
|
||||||
db.set_setting(AUTO_REPLY_POOL_5_KEY, _pool_to_multiline_text(pool_5))
|
db.set_setting(AUTO_REPLY_POOL_5_KEY, _pool_to_multiline_text(pool_5))
|
||||||
db.set_setting(AUTO_REPLY_POOL_4_KEY, _pool_to_multiline_text(pool_4))
|
db.set_setting(AUTO_REPLY_POOL_4_KEY, _pool_to_multiline_text(pool_4))
|
||||||
|
if is_ajax:
|
||||||
|
return jsonify({"ok": True})
|
||||||
return redirect(url_for("index", status="pools_saved", action="unanswered", stars=[5, 4]))
|
return redirect(url_for("index", status="pools_saved", action="unanswered", stars=[5, 4]))
|
||||||
|
|
||||||
|
|
||||||
@@ -928,6 +1462,16 @@ def admin_panel():
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
logger.info(
|
||||||
|
"App start debug=%s port=%s req_per_sec=%s batch_limit=%s interval_min=%s timezone=%s log_file=%s",
|
||||||
|
True,
|
||||||
|
os.getenv("FLASK_PORT", "5000"),
|
||||||
|
WB_REQUESTS_PER_SECOND,
|
||||||
|
AUTO_REPLY_BATCH_LIMIT,
|
||||||
|
AUTO_REPLY_INTERVAL_MINUTES,
|
||||||
|
APP_TIMEZONE,
|
||||||
|
APP_LOG_FILE,
|
||||||
|
)
|
||||||
is_reloader_process = os.environ.get("WERKZEUG_RUN_MAIN") == "true"
|
is_reloader_process = os.environ.get("WERKZEUG_RUN_MAIN") == "true"
|
||||||
if is_reloader_process or not app.debug:
|
if is_reloader_process or not app.debug:
|
||||||
threading.Thread(target=auto_reply_loop, daemon=True).start()
|
threading.Thread(target=auto_reply_loop, daemon=True).start()
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- FLASK_PORT=5000
|
||||||
|
ports:
|
||||||
|
- "54119:5000"
|
||||||
|
volumes:
|
||||||
|
- ./tokens.db:/app/tokens.db
|
||||||
|
- ./app.py:/app/app.py
|
||||||
|
- ./static:/app/static
|
||||||
|
- ./templates:/app/templates
|
||||||
Binary file not shown.
@@ -1,339 +0,0 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap');
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--bg: #f4f7fb;
|
|
||||||
--bg-soft: #eef3f9;
|
|
||||||
--card: #ffffff;
|
|
||||||
--text: #182033;
|
|
||||||
--muted: #5f6b86;
|
|
||||||
--line: #dfe6f2;
|
|
||||||
--brand: #1f6fff;
|
|
||||||
--brand-strong: #1357d6;
|
|
||||||
--ok-bg: #e8fff2;
|
|
||||||
--ok-line: #64d49a;
|
|
||||||
--warn-bg: #fff5df;
|
|
||||||
--warn-line: #f4c86a;
|
|
||||||
--err-bg: #ffecef;
|
|
||||||
--err-line: #ff8799;
|
|
||||||
--shadow: 0 10px 32px rgba(18, 38, 78, 0.08);
|
|
||||||
font-family: 'Manrope', 'Segoe UI', Tahoma, sans-serif;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
background:
|
|
||||||
radial-gradient(1200px 500px at -10% -20%, #dbe8ff 0%, transparent 60%),
|
|
||||||
radial-gradient(900px 500px at 110% 10%, #dff8ef 0%, transparent 55%),
|
|
||||||
var(--bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.page {
|
|
||||||
max-width: 1040px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
margin: 0 0 4px;
|
|
||||||
font-size: 32px;
|
|
||||||
line-height: 1.15;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
margin: 0 0 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: var(--brand);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:hover { text-decoration: underline; }
|
|
||||||
|
|
||||||
.hint {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
margin: 16px 0 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cabinet-link,
|
|
||||||
.user-bar {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 10px;
|
|
||||||
font-size: 14px;
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cabinet-link span,
|
|
||||||
.user-bar span,
|
|
||||||
.meta,
|
|
||||||
.token-owner { color: var(--muted); }
|
|
||||||
|
|
||||||
.star-filter {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 10px;
|
|
||||||
align-items: center;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.star-filter label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
background: var(--card);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
padding: 6px 10px;
|
|
||||||
border-radius: 999px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-buttons,
|
|
||||||
.token-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
border: none;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 10px 16px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
background: linear-gradient(180deg, #2c7bff 0%, #1f6fff 100%);
|
|
||||||
color: #fff;
|
|
||||||
box-shadow: 0 8px 16px rgba(31, 111, 255, 0.22);
|
|
||||||
}
|
|
||||||
|
|
||||||
button:hover { filter: brightness(1.03); }
|
|
||||||
|
|
||||||
button.secondary {
|
|
||||||
background: #ecf1fa;
|
|
||||||
color: #2c3a57;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert,
|
|
||||||
.reply-form,
|
|
||||||
.review,
|
|
||||||
.cabinet-section,
|
|
||||||
.auto-reply,
|
|
||||||
.auto-reply-pools,
|
|
||||||
.auto-reply-logs {
|
|
||||||
background: var(--card);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 14px;
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert {
|
|
||||||
background: var(--warn-bg);
|
|
||||||
border-color: var(--warn-line);
|
|
||||||
padding: 12px 14px;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-error { background: var(--err-bg); border-color: var(--err-line); }
|
|
||||||
.alert-success { background: var(--ok-bg); border-color: var(--ok-line); }
|
|
||||||
|
|
||||||
.auto-reply,
|
|
||||||
.auto-reply-pools,
|
|
||||||
.auto-reply-logs,
|
|
||||||
.reply-form,
|
|
||||||
.review,
|
|
||||||
.cabinet-section {
|
|
||||||
padding: 16px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auto-reply {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auto-reply p,
|
|
||||||
.auto-reply-pools p {
|
|
||||||
margin: 6px 0 0;
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.auto-reply-pools form,
|
|
||||||
.inline-reply form,
|
|
||||||
.cabinet-form,
|
|
||||||
.auth-form,
|
|
||||||
.reply-form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea,
|
|
||||||
.auth-form input,
|
|
||||||
.cabinet-form input,
|
|
||||||
.cabinet-form textarea,
|
|
||||||
.auto-reply-pools textarea {
|
|
||||||
width: 100%;
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: 10px;
|
|
||||||
border: 1px solid #cfd9ea;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reply-form textarea { min-height: 100px; resize: vertical; }
|
|
||||||
|
|
||||||
.reply-form__actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.review__header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rating { margin-left: 8px; color: #ee9700; }
|
|
||||||
.meta { display: flex; flex-direction: column; text-align: right; font-size: 13px; }
|
|
||||||
|
|
||||||
.details { margin: 0; }
|
|
||||||
.details dt { font-weight: 700; margin-top: 6px; }
|
|
||||||
.details dd { margin: 0; color: #314261; }
|
|
||||||
|
|
||||||
.answer {
|
|
||||||
margin-top: 10px;
|
|
||||||
padding: 10px;
|
|
||||||
background: #f0f6ff;
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-answer { margin-top: 10px; color: var(--muted); }
|
|
||||||
|
|
||||||
.inline-reply summary {
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--brand);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.token-list {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0 0 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.token-item {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 10px 0;
|
|
||||||
border-bottom: 1px solid #eef2f8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.token-item:last-child { border-bottom: none; }
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
background: #eaf4ff;
|
|
||||||
color: #1659c9;
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 4px 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
margin-left: 8px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-inactive {
|
|
||||||
background: #ffe9ee;
|
|
||||||
color: #b2234e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inline-form { margin: 0; }
|
|
||||||
.auth-page { max-width: 480px; }
|
|
||||||
.auth-form button { width: 100%; }
|
|
||||||
|
|
||||||
.logs-table-wrap { overflow-x: auto; }
|
|
||||||
.logs-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
.logs-table th,
|
|
||||||
.logs-table td {
|
|
||||||
border-bottom: 1px solid #ecf1f8;
|
|
||||||
padding: 8px;
|
|
||||||
text-align: left;
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.page { padding: 12px; }
|
|
||||||
h1 { font-size: 24px; }
|
|
||||||
h2 { font-size: 18px; }
|
|
||||||
|
|
||||||
.user-bar,
|
|
||||||
.cabinet-link,
|
|
||||||
.review__header,
|
|
||||||
.auto-reply,
|
|
||||||
.token-item,
|
|
||||||
.reply-form__actions {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta { text-align: left; }
|
|
||||||
|
|
||||||
.control-buttons,
|
|
||||||
.token-actions {
|
|
||||||
width: 100%;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
width: 100%;
|
|
||||||
padding: 12px 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logs-table {
|
|
||||||
font-size: 13px;
|
|
||||||
min-width: 720px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logs-table th,
|
|
||||||
.logs-table td {
|
|
||||||
white-space: nowrap;
|
|
||||||
padding: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logs-table td:last-child,
|
|
||||||
.logs-table th:last-child {
|
|
||||||
white-space: normal;
|
|
||||||
min-width: 220px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-page { max-width: 100%; }
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<title>Панель администратора</title>
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="page">
|
|
||||||
<header>
|
|
||||||
<h1>Панель администратора</h1>
|
|
||||||
<p class="hint">Управление пользователями и подтверждение заявок.</p>
|
|
||||||
<a href="{{ url_for('cabinet') }}">← Вернуться в кабинет</a>
|
|
||||||
</header>
|
|
||||||
{% if info_message %}
|
|
||||||
<div class="alert alert-success">{{ info_message }}</div>
|
|
||||||
{% endif %}
|
|
||||||
<section class="cabinet-section">
|
|
||||||
<h2>Пользователи</h2>
|
|
||||||
<ul class="token-list">
|
|
||||||
{% for user in users %}
|
|
||||||
<li class="token-item">
|
|
||||||
<div>
|
|
||||||
<strong>{{ user["username"] }}</strong>
|
|
||||||
{% if user["is_admin"] %}
|
|
||||||
<span class="badge">Администратор</span>
|
|
||||||
{% endif %}
|
|
||||||
{% if user["is_active"] %}
|
|
||||||
<span class="badge">Активен</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="badge badge-inactive">Не активен</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% if current_user["id"] != user["id"] %}
|
|
||||||
<form method="post" class="inline-form">
|
|
||||||
<input type="hidden" name="user_id" value="{{ user['id'] }}">
|
|
||||||
{% if user["is_active"] %}
|
|
||||||
<input type="hidden" name="admin_action" value="deactivate">
|
|
||||||
<button type="submit" class="secondary">Отключить</button>
|
|
||||||
{% else %}
|
|
||||||
<input type="hidden" name="admin_action" value="activate">
|
|
||||||
<button type="submit">Активировать</button>
|
|
||||||
{% endif %}
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<title>Личный кабинет</title>
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="page">
|
|
||||||
<header>
|
|
||||||
<h1>Личный кабинет</h1>
|
|
||||||
<p class="hint">Здесь можно управлять токенами магазинов и текущим пользователем.</p>
|
|
||||||
<div class="user-bar">
|
|
||||||
<span>{{ current_user["username"] }}</span>
|
|
||||||
<div>
|
|
||||||
{% if current_user["is_admin"] %}
|
|
||||||
<a href="{{ url_for('admin_panel') }}">Панель администратора</a>
|
|
||||||
{% endif %}
|
|
||||||
<a href="{{ url_for('index') }}">Вернуться к отзывам</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{% if error_message %}
|
|
||||||
<div class="alert alert-error">{{ error_message }}</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if success_message %}
|
|
||||||
<div class="alert alert-success">{{ success_message }}</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<section class="cabinet-section">
|
|
||||||
<h2>Сохранённые магазины</h2>
|
|
||||||
{% if tokens %}
|
|
||||||
<ul class="token-list">
|
|
||||||
{% for token in tokens %}
|
|
||||||
<li class="token-item">
|
|
||||||
<div>
|
|
||||||
<strong>{{ token.name }}</strong>
|
|
||||||
{% if current_user["is_admin"] and token.owner %}
|
|
||||||
<span class="token-owner">({{ token.owner }})</span>
|
|
||||||
{% endif %}
|
|
||||||
{% if token.id == active_token_id %}
|
|
||||||
<span class="badge">Активен</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="token-actions">
|
|
||||||
<form method="post" class="inline-form">
|
|
||||||
<input type="hidden" name="cabinet_action" value="select">
|
|
||||||
<input type="hidden" name="token_id" value="{{ token.id }}">
|
|
||||||
<button type="submit" {% if token.id == active_token_id %}class="secondary"{% endif %}>Использовать</button>
|
|
||||||
</form>
|
|
||||||
<form method="post" class="inline-form">
|
|
||||||
<input type="hidden" name="cabinet_action" value="check">
|
|
||||||
<input type="hidden" name="token_id" value="{{ token.id }}">
|
|
||||||
<button type="submit" class="secondary">Проверить</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% else %}
|
|
||||||
<p>Пока нет сохранённых токенов.</p>
|
|
||||||
{% endif %}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="cabinet-section">
|
|
||||||
<h2>Добавить магазин</h2>
|
|
||||||
<form method="post" class="cabinet-form">
|
|
||||||
<input type="hidden" name="cabinet_action" value="add">
|
|
||||||
<label>
|
|
||||||
Название магазина
|
|
||||||
<input type="text" name="name" required placeholder="Например, Основной аккаунт">
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Токен API
|
|
||||||
<textarea name="token" rows="3" required placeholder="Вставьте токен"></textarea>
|
|
||||||
</label>
|
|
||||||
<button type="submit">Сохранить</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<title>Отзывы Wildberries</title>
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="page">
|
|
||||||
<header>
|
|
||||||
<h1>Отзывы Wildberries</h1>
|
|
||||||
<p class="hint">Используйте кнопки ниже, чтобы загрузить свежие отзывы или оставить только неотвеченные.</p>
|
|
||||||
<div class="user-bar">
|
|
||||||
<span>Вы вошли как {{ current_user["username"] }}</span>
|
|
||||||
<a href="{{ url_for('logout') }}">Выйти</a>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{% if error_message %}
|
|
||||||
<div class="alert alert-error">{{ error_message }}</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if success_message %}
|
|
||||||
<div class="alert alert-success">{{ success_message }}</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="cabinet-link">
|
|
||||||
<a href="{{ url_for('cabinet') }}">Личный кабинет</a>
|
|
||||||
{% if active_token_name %}
|
|
||||||
<span>Текущий токен: {{ active_token_name }}</span>
|
|
||||||
{% else %}
|
|
||||||
<span>Токен не выбран</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form class="controls" method="get" action="/">
|
|
||||||
<div class="star-filter">
|
|
||||||
<span>Выберите оценки:</span>
|
|
||||||
{% for star in [5,4,3,2,1] %}
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" name="stars" value="{{ star }}" {% if star in selected_stars %}checked{% endif %}>
|
|
||||||
{{ star }}★
|
|
||||||
</label>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
<div class="control-buttons">
|
|
||||||
<button type="submit" name="action" value="all">Выгрузить отзывы</button>
|
|
||||||
<button type="submit" name="action" value="unanswered">Только неотвеченные</button>
|
|
||||||
<button type="submit" name="action" value="unanswered" class="secondary" formaction="/?stars=5&stars=4">Новые 5★ и 4★</button>
|
|
||||||
<button type="submit" name="action" value="clear" class="secondary">Очистить список</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<section class="auto-reply">
|
|
||||||
<div>
|
|
||||||
<strong>Автоответ:</strong>
|
|
||||||
{% if auto_reply_enabled %}
|
|
||||||
<span class="badge">Включён</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="badge badge-inactive">Выключен</span>
|
|
||||||
{% endif %}
|
|
||||||
<p>При включении сервис каждые {{ auto_reply_interval_minutes }} минут проверяет новые отзывы 5★ и 4★ и отвечает случайным текстом из пула.</p>
|
|
||||||
</div>
|
|
||||||
<form method="post" action="{{ url_for('auto_reply_toggle') }}">
|
|
||||||
<input type="hidden" name="enabled" value="{{ 0 if auto_reply_enabled else 1 }}">
|
|
||||||
<button type="submit">{{ "✓ Автоответ включён" if auto_reply_enabled else "Включить автоответ" }}</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="auto-reply-pools">
|
|
||||||
<h2>Пулы автоответов</h2>
|
|
||||||
<p>Один вариант ответа в каждой строке. Для каждого нового отзыва 5★/4★ текст выбирается случайно.</p>
|
|
||||||
<form method="post" action="{{ url_for('auto_reply_pools') }}">
|
|
||||||
<label for="pool-5">Ответы для 5★</label>
|
|
||||||
<textarea id="pool-5" name="pool_5" rows="5" required>{{ reply_pool_5_text }}</textarea>
|
|
||||||
<label for="pool-4">Ответы для 4★</label>
|
|
||||||
<textarea id="pool-4" name="pool_4" rows="5" required>{{ reply_pool_4_text }}</textarea>
|
|
||||||
<button type="submit">Сохранить пулы</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="auto-reply-logs">
|
|
||||||
<h2>Журнал автоответов (последние 100)</h2>
|
|
||||||
{% if auto_reply_logs %}
|
|
||||||
<div class="logs-table-wrap">
|
|
||||||
<table class="logs-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Дата</th>
|
|
||||||
<th>Оценка</th>
|
|
||||||
<th>Товар</th>
|
|
||||||
<th>Покупатель</th>
|
|
||||||
<th>Статус</th>
|
|
||||||
<th>Ответ</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for log in auto_reply_logs %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ log["created_at"] }}</td>
|
|
||||||
<td>{{ log["rating"] }}★</td>
|
|
||||||
<td>{{ log["product_name"] or "-" }}</td>
|
|
||||||
<td>{{ log["user_name"] or "-" }}</td>
|
|
||||||
<td>{{ "Отправлен" if log["status"] == "sent" else "Ошибка" }}</td>
|
|
||||||
<td>{{ log["reply_text"] }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p>Пока нет записей автоответа.</p>
|
|
||||||
{% endif %}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{% if reviews %}
|
|
||||||
<section class="summary">
|
|
||||||
{% if current_filter == 'unanswered' %}
|
|
||||||
<h2>Неотвеченные отзывы</h2>
|
|
||||||
{% else %}
|
|
||||||
<h2>Все отзывы</h2>
|
|
||||||
{% endif %}
|
|
||||||
<p>Показано {{ reviews|length }} отзывов. Выбраны оценки: {{ selected_stars_display|join(', ') }}★.</p>
|
|
||||||
</section>
|
|
||||||
{% if current_filter == 'unanswered' and has_token %}
|
|
||||||
<form class="reply-form" method="post" action="{{ url_for('reply_all') }}">
|
|
||||||
<label for="reply-text">Ответ для всех неотвеченных отзывов</label>
|
|
||||||
<textarea id="reply-text" name="message" rows="4" placeholder="Напишите ответ один раз — он будет отправлен каждому неотвеченному отзыву" required></textarea>
|
|
||||||
{% for star in selected_stars_display %}
|
|
||||||
<input type="hidden" name="stars" value="{{ star }}">
|
|
||||||
{% endfor %}
|
|
||||||
{% for review in reviews %}
|
|
||||||
<input type="hidden" name="review_id" value="{{ review.id }}">
|
|
||||||
{% endfor %}
|
|
||||||
<div class="reply-form__actions">
|
|
||||||
<span>Допустимая длина ответа: 2–5000 символов.</span>
|
|
||||||
<button type="submit">Ответить всем</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<section class="reviews">
|
|
||||||
{% for review in reviews %}
|
|
||||||
<article class="review">
|
|
||||||
<header class="review__header">
|
|
||||||
<div>
|
|
||||||
<strong>{{ review.product_name or 'Без названия' }}</strong>
|
|
||||||
<span class="rating">★ {{ review.rating }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="meta">
|
|
||||||
<span>{{ review.user_name or 'Покупатель' }}</span>
|
|
||||||
<span>{{ review.created_at|format_datetime }}</span>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
{% if review.text %}
|
|
||||||
<p class="review__text">{{ review.text }}</p>
|
|
||||||
{% endif %}
|
|
||||||
<dl class="details">
|
|
||||||
{% if review.pros %}
|
|
||||||
<dt>Достоинства</dt>
|
|
||||||
<dd>{{ review.pros }}</dd>
|
|
||||||
{% endif %}
|
|
||||||
{% if review.cons %}
|
|
||||||
<dt>Недостатки</dt>
|
|
||||||
<dd>{{ review.cons }}</dd>
|
|
||||||
{% endif %}
|
|
||||||
</dl>
|
|
||||||
<footer>
|
|
||||||
{% if review.answer %}
|
|
||||||
<p class="answer"><strong>Ответ:</strong> {{ review.answer }}</p>
|
|
||||||
{% else %}
|
|
||||||
<p class="no-answer">Без ответа</p>
|
|
||||||
{% endif %}
|
|
||||||
</footer>
|
|
||||||
{% if has_token %}
|
|
||||||
<details class="inline-reply">
|
|
||||||
<summary>Ответить</summary>
|
|
||||||
<form method="post" action="{{ url_for('reply_single', review_id=review.id) }}">
|
|
||||||
<textarea name="message" rows="3" placeholder="Введите ответ" required></textarea>
|
|
||||||
{% for star in selected_stars_display %}
|
|
||||||
<input type="hidden" name="stars" value="{{ star }}">
|
|
||||||
{% endfor %}
|
|
||||||
<input type="hidden" name="next_action" value="{{ 'unanswered' if current_filter == 'unanswered' else 'all' }}">
|
|
||||||
<button type="submit">Отправить</button>
|
|
||||||
</form>
|
|
||||||
</details>
|
|
||||||
{% endif %}
|
|
||||||
</article>
|
|
||||||
{% endfor %}
|
|
||||||
</section>
|
|
||||||
{% elif current_filter %}
|
|
||||||
<div class="alert">Отзывы с выбранными оценками ({{ selected_stars_display|join(', ') }}★) не найдены.</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<title>Вход</title>
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="page auth-page">
|
|
||||||
<h1>Вход в систему</h1>
|
|
||||||
{% if error_message %}
|
|
||||||
<div class="alert alert-error">{{ error_message }}</div>
|
|
||||||
{% endif %}
|
|
||||||
<form method="post" class="auth-form">
|
|
||||||
<label>
|
|
||||||
Логин
|
|
||||||
<input type="text" name="username" required />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Пароль
|
|
||||||
<input type="password" name="password" required />
|
|
||||||
</label>
|
|
||||||
<button type="submit">Войти</button>
|
|
||||||
</form>
|
|
||||||
<p>Нет аккаунта? <a href="{{ url_for('register') }}">Запросить доступ</a></p>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<title>Регистрация</title>
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="page auth-page">
|
|
||||||
<h1>Запрос доступа</h1>
|
|
||||||
{% if error_message %}
|
|
||||||
<div class="alert alert-error">{{ error_message }}</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if success_message %}
|
|
||||||
<div class="alert alert-success">{{ success_message }}</div>
|
|
||||||
{% endif %}
|
|
||||||
<form method="post" class="auth-form">
|
|
||||||
<label>
|
|
||||||
Логин
|
|
||||||
<input type="text" name="username" required />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Пароль
|
|
||||||
<input type="password" name="password" required />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Повторите пароль
|
|
||||||
<input type="password" name="confirm" required />
|
|
||||||
</label>
|
|
||||||
<button type="submit">Отправить заявку</button>
|
|
||||||
</form>
|
|
||||||
<p>Уже есть аккаунт? <a href="{{ url_for('login') }}">Войти</a></p>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
Flask==3.0.3
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
requests==2.32.3
|
||||||
+1285
File diff suppressed because it is too large
Load Diff
+1985
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,111 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Администратор — WB Feedback</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<nav class="topbar">
|
||||||
|
<div class="topbar__inner">
|
||||||
|
<a href="{{ url_for('index') }}" class="topbar__brand">
|
||||||
|
<span class="topbar__logo">WB</span>
|
||||||
|
<span class="topbar__name">Feedback</span>
|
||||||
|
</a>
|
||||||
|
<div class="topbar__nav">
|
||||||
|
<a href="{{ url_for('index') }}" class="topbar__link">Отзывы</a>
|
||||||
|
<a href="{{ url_for('cabinet') }}" class="topbar__link">Кабинет</a>
|
||||||
|
<a href="{{ url_for('admin_panel') }}" class="topbar__link active">Администратор</a>
|
||||||
|
</div>
|
||||||
|
<div class="topbar__user">
|
||||||
|
<span class="topbar__username">{{ current_user["username"] }}</span>
|
||||||
|
<a href="{{ url_for('logout') }}" class="btn-ghost">Выйти</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Панель администратора</h1>
|
||||||
|
<p class="hint">Управление пользователями и подтверждение заявок.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if info_message %}
|
||||||
|
<div class="alert alert-success">{{ info_message }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="cabinet-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Пользователи</h2>
|
||||||
|
<span class="badge">{{ users|length }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="token-list">
|
||||||
|
{% for user in users %}
|
||||||
|
<li class="token-item">
|
||||||
|
<div class="token-main">
|
||||||
|
<div class="token-name">
|
||||||
|
{{ user["username"] }}
|
||||||
|
{% if user["is_admin"] %}
|
||||||
|
<span class="badge badge-admin">Администратор</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if user["is_active"] %}
|
||||||
|
<span class="badge badge-green">Активен</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-inactive">Не активен</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if current_user["id"] != user["id"] %}
|
||||||
|
<div class="token-actions">
|
||||||
|
<form method="post" class="inline-form">
|
||||||
|
<input type="hidden" name="user_id" value="{{ user['id'] }}">
|
||||||
|
{% if user["is_active"] %}
|
||||||
|
<input type="hidden" name="admin_action" value="deactivate">
|
||||||
|
<button type="submit" class="secondary">Отключить</button>
|
||||||
|
{% else %}
|
||||||
|
<input type="hidden" name="admin_action" value="activate">
|
||||||
|
<button type="submit">Активировать</button>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Нижняя навигация (мобайл) -->
|
||||||
|
<nav class="bottom-nav">
|
||||||
|
<a href="{{ url_for('index') }}" class="bottom-nav__item">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2"/>
|
||||||
|
<rect x="9" y="3" width="6" height="4" rx="1"/>
|
||||||
|
<line x1="9" y1="12" x2="15" y2="12"/>
|
||||||
|
<line x1="9" y1="16" x2="13" y2="16"/>
|
||||||
|
</svg>
|
||||||
|
Отзывы
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('cabinet') }}" class="bottom-nav__item">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
||||||
|
<circle cx="12" cy="7" r="4"/>
|
||||||
|
</svg>
|
||||||
|
Кабинет
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('admin_panel') }}" class="bottom-nav__item active">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||||
|
</svg>
|
||||||
|
Админ
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Кабинет — WB Feedback</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<nav class="topbar">
|
||||||
|
<div class="topbar__inner">
|
||||||
|
<a href="{{ url_for('index') }}" class="topbar__brand">
|
||||||
|
<span class="topbar__logo">WB</span>
|
||||||
|
<span class="topbar__name">Feedback</span>
|
||||||
|
</a>
|
||||||
|
<div class="topbar__nav">
|
||||||
|
<a href="{{ url_for('index') }}" class="topbar__link">Отзывы</a>
|
||||||
|
<a href="{{ url_for('cabinet') }}" class="topbar__link active">Кабинет</a>
|
||||||
|
{% if current_user["is_admin"] %}
|
||||||
|
<a href="{{ url_for('admin_panel') }}" class="topbar__link">Администратор</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="topbar__user">
|
||||||
|
<span class="topbar__username">{{ current_user["username"] }}</span>
|
||||||
|
<a href="{{ url_for('logout') }}" class="btn-ghost">Выйти</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Личный кабинет</h1>
|
||||||
|
<p class="hint">Управляйте токенами магазинов Wildberries.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error_message %}
|
||||||
|
<div class="alert alert-error">{{ error_message }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if success_message %}
|
||||||
|
<div class="alert alert-success">{{ success_message }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="cabinet-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Сохранённые магазины</h2>
|
||||||
|
{% if tokens %}
|
||||||
|
<span class="badge">{{ tokens|length }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if tokens %}
|
||||||
|
<ul class="token-list">
|
||||||
|
{% for token in tokens %}
|
||||||
|
<li class="token-item">
|
||||||
|
<div class="token-main">
|
||||||
|
<div class="token-name">
|
||||||
|
{{ token.name }}
|
||||||
|
{% if current_user["is_admin"] and token.owner %}
|
||||||
|
<span class="token-owner">({{ token.owner }})</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if token.id == active_token_id %}
|
||||||
|
<span class="badge badge-green">Активен</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" class="cabinet-form token-edit-form">
|
||||||
|
<input type="hidden" name="cabinet_action" value="edit">
|
||||||
|
<input type="hidden" name="token_id" value="{{ token.id }}">
|
||||||
|
<label>
|
||||||
|
Название магазина
|
||||||
|
<input type="text" name="name" required value="{{ token.name }}">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Токен API
|
||||||
|
<textarea name="token" rows="3" required>{{ token.token }}</textarea>
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<button type="submit">Сохранить изменения</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="token-actions">
|
||||||
|
<form method="post" class="inline-form">
|
||||||
|
<input type="hidden" name="cabinet_action" value="select">
|
||||||
|
<input type="hidden" name="token_id" value="{{ token.id }}">
|
||||||
|
<button type="submit" {% if token.id == active_token_id %}class="secondary"{% endif %}>
|
||||||
|
{{ "Используется" if token.id == active_token_id else "Использовать" }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" class="inline-form">
|
||||||
|
<input type="hidden" name="cabinet_action" value="check">
|
||||||
|
<input type="hidden" name="token_id" value="{{ token.id }}">
|
||||||
|
<button type="submit" class="secondary">Проверить</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">Пока нет сохранённых магазинов.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cabinet-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Добавить магазин</h2>
|
||||||
|
</div>
|
||||||
|
<form method="post" class="cabinet-form">
|
||||||
|
<input type="hidden" name="cabinet_action" value="add">
|
||||||
|
<label>
|
||||||
|
Название магазина
|
||||||
|
<input type="text" name="name" required placeholder="Например, Основной аккаунт">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Токен API
|
||||||
|
<textarea name="token" rows="3" required placeholder="Вставьте токен Wildberries"></textarea>
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<button type="submit">Добавить магазин</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Нижняя навигация (мобайл) -->
|
||||||
|
<nav class="bottom-nav">
|
||||||
|
<a href="{{ url_for('index') }}" class="bottom-nav__item">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2"/>
|
||||||
|
<rect x="9" y="3" width="6" height="4" rx="1"/>
|
||||||
|
<line x1="9" y1="12" x2="15" y2="12"/>
|
||||||
|
<line x1="9" y1="16" x2="13" y2="16"/>
|
||||||
|
</svg>
|
||||||
|
Отзывы
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('cabinet') }}" class="bottom-nav__item active">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
||||||
|
<circle cx="12" cy="7" r="4"/>
|
||||||
|
</svg>
|
||||||
|
Кабинет
|
||||||
|
</a>
|
||||||
|
{% if current_user["is_admin"] %}
|
||||||
|
<a href="{{ url_for('admin_panel') }}" class="bottom-nav__item">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||||
|
</svg>
|
||||||
|
Админ
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,444 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Отзывы — WB Feedback</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<nav class="topbar">
|
||||||
|
<div class="topbar__inner">
|
||||||
|
<a href="{{ url_for('index') }}" class="topbar__brand">
|
||||||
|
<span class="topbar__logo">WB</span>
|
||||||
|
<span class="topbar__name">Feedback</span>
|
||||||
|
</a>
|
||||||
|
<div class="topbar__nav">
|
||||||
|
<a href="{{ url_for('index') }}" class="topbar__link active">Отзывы</a>
|
||||||
|
<a href="{{ url_for('cabinet') }}" class="topbar__link">Кабинет</a>
|
||||||
|
{% if current_user["is_admin"] %}
|
||||||
|
<a href="{{ url_for('admin_panel') }}" class="topbar__link">Администратор</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="topbar__user">
|
||||||
|
<span class="topbar__username">{{ current_user["username"] }}</span>
|
||||||
|
{% if active_token_name %}
|
||||||
|
<span class="badge">{{ active_token_name }}</span>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{{ url_for('logout') }}" class="btn-ghost">Выйти</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
|
||||||
|
{% if error_message %}
|
||||||
|
<div class="alert alert-error">{{ error_message }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if success_message %}
|
||||||
|
<div class="alert alert-success">{{ success_message }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Фильтры и кнопки -->
|
||||||
|
<div class="controls">
|
||||||
|
<div class="controls-card">
|
||||||
|
<form method="get" action="/">
|
||||||
|
<div class="star-filter">
|
||||||
|
<span class="star-filter-label">Оценки</span>
|
||||||
|
{% for star in [5,4,3,2,1] %}
|
||||||
|
<span class="star-pill" data-star="{{ star }}">
|
||||||
|
<input type="checkbox" id="star-{{ star }}" name="stars" value="{{ star }}" {% if star in selected_stars %}checked{% endif %}>
|
||||||
|
<label for="star-{{ star }}">{{ star }}★</label>
|
||||||
|
</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="control-buttons">
|
||||||
|
<button type="submit" name="action" value="all">Выгрузить отзывы</button>
|
||||||
|
<button type="submit" name="action" value="unanswered">Только неотвеченные</button>
|
||||||
|
<button type="submit" name="action" value="unanswered" class="secondary" formaction="/?stars=5&stars=4">Новые 5★ и 4★</button>
|
||||||
|
<button type="submit" name="action" value="clear" class="secondary">Очистить список</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Автоответ -->
|
||||||
|
<div class="card auto-reply">
|
||||||
|
<div class="auto-reply__info">
|
||||||
|
<div class="auto-reply__title">
|
||||||
|
<h3>Автоответ</h3>
|
||||||
|
{% if auto_reply_enabled %}
|
||||||
|
<span class="status-dot status-dot--green"></span>
|
||||||
|
<span class="badge badge-green">Включён</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="status-dot status-dot--gray"></span>
|
||||||
|
<span class="badge">Выключен</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<p>По требованию WB — <strong>1 ответ каждые 10 минут</strong>. Очередь обрабатывается автоматически.</p>
|
||||||
|
{% if api_cooldown_seconds_left and api_cooldown_seconds_left > 0 %}
|
||||||
|
<p style="margin-top:6px;color:var(--amber)">⚠ API на паузе: ещё <span id="cooldown-counter" data-seconds="{{ api_cooldown_seconds_left }}">{{ api_cooldown_seconds_left }}</span> сек.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ url_for('auto_reply_toggle') }}" id="toggle-form">
|
||||||
|
<input type="hidden" name="enabled" value="{{ 0 if auto_reply_enabled else 1 }}">
|
||||||
|
<label class="tumbler" title="{{ 'Выключить автоответ' if auto_reply_enabled else 'Включить автоответ' }}">
|
||||||
|
<input type="checkbox" class="tumbler__input" {{ 'checked' if auto_reply_enabled else '' }} onchange="document.getElementById('toggle-form').submit()">
|
||||||
|
<span class="tumbler__track">
|
||||||
|
<span class="tumbler__thumb"></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Пулы автоответов -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Пулы автоответов</h2>
|
||||||
|
</div>
|
||||||
|
<p style="margin-bottom:16px;font-size:13px">Для каждого нового отзыва текст выбирается случайно из пула.</p>
|
||||||
|
<form method="post" action="{{ url_for('auto_reply_pools') }}" id="pools-form">
|
||||||
|
<textarea name="pool_5" id="pool-5-hidden" hidden>{{ reply_pool_5_text }}</textarea>
|
||||||
|
<textarea name="pool_4" id="pool-4-hidden" hidden>{{ reply_pool_4_text }}</textarea>
|
||||||
|
<div class="pool-grid">
|
||||||
|
<div class="pool-block">
|
||||||
|
<div class="pool-label" style="margin-bottom:10px">
|
||||||
|
Ответы для <span class="star-chip star-chip--5">5★</span>
|
||||||
|
</div>
|
||||||
|
<div class="pool-items" id="pool-items-5"></div>
|
||||||
|
<button type="button" class="btn-add-item" onclick="addPoolItem('5')">+ Добавить ответ</button>
|
||||||
|
</div>
|
||||||
|
<div class="pool-block">
|
||||||
|
<div class="pool-label" style="margin-bottom:10px">
|
||||||
|
Ответы для <span class="star-chip star-chip--4">4★</span>
|
||||||
|
</div>
|
||||||
|
<div class="pool-items" id="pool-items-4"></div>
|
||||||
|
<button type="button" class="btn-add-item" onclick="addPoolItem('4')">+ Добавить ответ</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:16px">
|
||||||
|
<button type="submit">Сохранить пулы</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Очередь автоответов -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Очередь автоответов</h2>
|
||||||
|
{% if auto_reply_queue %}
|
||||||
|
<span class="badge">{{ auto_reply_queue|length }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if auto_reply_queue %}
|
||||||
|
<div class="logs-table-wrap">
|
||||||
|
<table class="logs-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>ID отзыва</th>
|
||||||
|
<th>Оценка</th>
|
||||||
|
<th>Товар</th>
|
||||||
|
<th>Покупатель</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in auto_reply_queue %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ loop.index }}</td>
|
||||||
|
<td><span class="rating rating--{{ item.rating }}">{{ item.rating }}★</span></td>
|
||||||
|
<td>
|
||||||
|
{{ item.get("product_name") or "—" }}
|
||||||
|
{% if item.get("nm_id") %}
|
||||||
|
<span class="tag-article">#{{ item.nm_id }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ item.get("user_name") or "—" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">Очередь пуста — новые отзывы будут загружены автоматически.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Журнал автоответов -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Журнал автоответов</h2>
|
||||||
|
<span class="badge">последние 100</span>
|
||||||
|
</div>
|
||||||
|
{% if auto_reply_logs %}
|
||||||
|
<div class="logs-table-wrap">
|
||||||
|
<table class="logs-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Дата лога</th>
|
||||||
|
<th>Дата оценки</th>
|
||||||
|
<th>Оценка</th>
|
||||||
|
<th>Товар</th>
|
||||||
|
<th>Покупатель</th>
|
||||||
|
<th>Текст отзыва</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>ID отзыва</th>
|
||||||
|
<th>Ответ</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for log in auto_reply_logs %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ log["created_at"]|format_log_datetime }}</td>
|
||||||
|
<td>{{ log["review_created_at"]|format_log_datetime if log["review_created_at"] else "—" }}</td>
|
||||||
|
<td><span class="rating rating--{{ log['rating'] }}">{{ log["rating"] }}★</span></td>
|
||||||
|
<td>
|
||||||
|
{{ log["product_name"] or "—" }}
|
||||||
|
{% if log["nm_id"] %}
|
||||||
|
<span class="tag-article">#{{ log["nm_id"] }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ log["user_name"] or "—" }}</td>
|
||||||
|
<td>{{ log["review_text"] or "—" }}</td>
|
||||||
|
<td>
|
||||||
|
{% if log["status"] == "sent" %}
|
||||||
|
<span class="log-status--sent">✓ Отправлен</span>
|
||||||
|
{% elif log["status"] == "skipped" %}
|
||||||
|
<span class="log-status--skip">— Пропущен</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="log-status--error">✗ Ошибка</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td><code style="font-size:0.72rem;color:var(--c-text-muted)">{{ log["review_id"] or "—" }}</code></td>
|
||||||
|
<td>{{ log["reply_text"] or "—" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">Пока нет записей автоответа.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Список отзывов -->
|
||||||
|
{% if reviews %}
|
||||||
|
{% set unanswered_count = reviews | rejectattr('answer') | list | length %}
|
||||||
|
{% set answered_count = reviews | selectattr('answer') | list | length %}
|
||||||
|
|
||||||
|
<div class="summary">
|
||||||
|
<h2>{{ "Неотвеченные отзывы" if current_filter == 'unanswered' else "Все отзывы" }}</h2>
|
||||||
|
<p>Оценки: {{ selected_stars_display|join(', ') }}★.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Счётчики -->
|
||||||
|
<div class="stats-bar">
|
||||||
|
<div class="stat-chip">
|
||||||
|
<span class="stat-value">{{ reviews|length }}</span>
|
||||||
|
<span>отзывов загружено</span>
|
||||||
|
</div>
|
||||||
|
{% if unanswered_count > 0 %}
|
||||||
|
<div class="stat-chip stat-chip--warn">
|
||||||
|
<span class="stat-value">{{ unanswered_count }}</span>
|
||||||
|
<span>без ответа</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if answered_count > 0 %}
|
||||||
|
<div class="stat-chip stat-chip--ok">
|
||||||
|
<span class="stat-value">{{ answered_count }}</span>
|
||||||
|
<span>отвечено</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% for star in [5,4,3,2,1] %}
|
||||||
|
{% set cnt = reviews | selectattr('rating', 'equalto', star) | list | length %}
|
||||||
|
{% if cnt > 0 %}
|
||||||
|
<div class="stat-chip">
|
||||||
|
<span class="stat-value">{{ cnt }}</span>
|
||||||
|
<span>{{ star }}★</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if current_filter == 'unanswered' and has_token %}
|
||||||
|
<form class="reply-form" method="post" action="{{ url_for('reply_all') }}">
|
||||||
|
<label for="reply-text">Ответ для всех неотвеченных отзывов</label>
|
||||||
|
<textarea id="reply-text" name="message" rows="4" placeholder="Напишите ответ один раз — он будет отправлен каждому неотвеченному отзыву" required></textarea>
|
||||||
|
{% for star in selected_stars_display %}
|
||||||
|
<input type="hidden" name="stars" value="{{ star }}">
|
||||||
|
{% endfor %}
|
||||||
|
{% for review in reviews %}
|
||||||
|
<input type="hidden" name="review_id" value="{{ review.id }}">
|
||||||
|
{% endfor %}
|
||||||
|
<div class="reply-form__actions">
|
||||||
|
<span>Допустимая длина: 2–5000 символов.</span>
|
||||||
|
<button type="submit">Ответить всем</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="reviews">
|
||||||
|
{% for review in reviews %}
|
||||||
|
<article class="review" data-rating="{{ review.rating }}">
|
||||||
|
<header class="review__header">
|
||||||
|
<div>
|
||||||
|
<span class="review__product">{{ review.product_name or 'Без названия' }}</span>
|
||||||
|
<span class="rating rating--{{ review.rating }}">★ {{ review.rating }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="review__meta">
|
||||||
|
<span>{{ review.user_name or 'Покупатель' }}</span>
|
||||||
|
<span>{{ review.created_at|format_datetime }}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% if review.text %}
|
||||||
|
<p class="review__text">{{ review.text }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<dl class="details">
|
||||||
|
{% if review.pros %}
|
||||||
|
<dt>Достоинства</dt>
|
||||||
|
<dd>{{ review.pros }}</dd>
|
||||||
|
{% endif %}
|
||||||
|
{% if review.cons %}
|
||||||
|
<dt>Недостатки</dt>
|
||||||
|
<dd>{{ review.cons }}</dd>
|
||||||
|
{% endif %}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{% if review.answer %}
|
||||||
|
<p class="answer"><strong>Ответ:</strong> {{ review.answer }}</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="no-answer">Без ответа</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if has_token %}
|
||||||
|
<details class="inline-reply">
|
||||||
|
<summary>Ответить на отзыв</summary>
|
||||||
|
<form method="post" action="{{ url_for('reply_single', review_id=review.id) }}">
|
||||||
|
<textarea name="message" rows="3" placeholder="Введите ответ" required></textarea>
|
||||||
|
{% for star in selected_stars_display %}
|
||||||
|
<input type="hidden" name="stars" value="{{ star }}">
|
||||||
|
{% endfor %}
|
||||||
|
<input type="hidden" name="next_action" value="{{ 'unanswered' if current_filter == 'unanswered' else 'all' }}">
|
||||||
|
<button type="submit">Отправить</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% elif current_filter %}
|
||||||
|
<div class="alert">Отзывы с выбранными оценками ({{ selected_stars_display|join(', ') }}★) не найдены.</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Нижняя навигация (мобайл) -->
|
||||||
|
<nav class="bottom-nav">
|
||||||
|
<a href="{{ url_for('index') }}" class="bottom-nav__item active">
|
||||||
|
{% if reviews and (reviews | rejectattr('answer') | list | length) > 0 %}
|
||||||
|
<span class="bottom-nav__badge">{{ reviews | rejectattr('answer') | list | length }}</span>
|
||||||
|
{% endif %}
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2"/>
|
||||||
|
<rect x="9" y="3" width="6" height="4" rx="1"/>
|
||||||
|
<line x1="9" y1="12" x2="15" y2="12"/>
|
||||||
|
<line x1="9" y1="16" x2="13" y2="16"/>
|
||||||
|
</svg>
|
||||||
|
Отзывы
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('cabinet') }}" class="bottom-nav__item">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
||||||
|
<circle cx="12" cy="7" r="4"/>
|
||||||
|
</svg>
|
||||||
|
Кабинет
|
||||||
|
</a>
|
||||||
|
{% if current_user["is_admin"] %}
|
||||||
|
<a href="{{ url_for('admin_panel') }}" class="bottom-nav__item">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||||
|
</svg>
|
||||||
|
Админ
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ── Cooldown counter ───────────────────────────────────────────────
|
||||||
|
(() => {
|
||||||
|
const node = document.getElementById('cooldown-counter');
|
||||||
|
if (!node) return;
|
||||||
|
let s = parseInt(node.dataset.seconds || '0', 10);
|
||||||
|
const tick = setInterval(() => {
|
||||||
|
s--;
|
||||||
|
if (s <= 0) { clearInterval(tick); node.closest('p').remove(); return; }
|
||||||
|
node.textContent = s;
|
||||||
|
}, 1000);
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ── Pool editor ────────────────────────────────────────────────────
|
||||||
|
function syncHidden(star) {
|
||||||
|
const items = document.querySelectorAll(`#pool-items-${star} .pool-item-input`);
|
||||||
|
const lines = [...items].map(i => i.value.trim()).filter(Boolean);
|
||||||
|
document.getElementById(`pool-${star}-hidden`).value = lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPoolItem(star, value = '') {
|
||||||
|
const container = document.getElementById(`pool-items-${star}`);
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'pool-item';
|
||||||
|
row.innerHTML = `
|
||||||
|
<input type="text" class="pool-item-input" value="${value.replace(/"/g, '"')}" placeholder="Текст ответа…">
|
||||||
|
<button type="button" class="btn-delete-item" title="Удалить">✕</button>`;
|
||||||
|
row.querySelector('.pool-item-input').addEventListener('input', () => syncHidden(star));
|
||||||
|
row.querySelector('.btn-delete-item').addEventListener('click', () => {
|
||||||
|
row.remove();
|
||||||
|
syncHidden(star);
|
||||||
|
});
|
||||||
|
container.appendChild(row);
|
||||||
|
syncHidden(star);
|
||||||
|
row.querySelector('input').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initPool(star) {
|
||||||
|
const hidden = document.getElementById(`pool-${star}-hidden`);
|
||||||
|
const lines = hidden.value.split('\n').map(l => l.trim()).filter(Boolean);
|
||||||
|
lines.forEach(line => addPoolItem(star, line));
|
||||||
|
}
|
||||||
|
|
||||||
|
initPool('5');
|
||||||
|
initPool('4');
|
||||||
|
|
||||||
|
document.getElementById('pools-form').addEventListener('submit', () => {
|
||||||
|
syncHidden('5');
|
||||||
|
syncHidden('4');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── API polling — reload when new log entry appears ────────────────
|
||||||
|
(() => {
|
||||||
|
let lastLogId = null;
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/status');
|
||||||
|
if (!r.ok) return;
|
||||||
|
const data = await r.json();
|
||||||
|
if (lastLogId === null) {
|
||||||
|
lastLogId = data.last_log_id;
|
||||||
|
} else if (data.last_log_id !== lastLogId) {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
// sync cooldown if page wasn't reloaded
|
||||||
|
const node = document.getElementById('cooldown-counter');
|
||||||
|
if (node && data.cooldown > 0) node.textContent = data.cooldown;
|
||||||
|
} catch(e) {}
|
||||||
|
};
|
||||||
|
poll();
|
||||||
|
setInterval(poll, 15000);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Вход — WB Feedback</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="auth-shell">
|
||||||
|
<section class="auth-card">
|
||||||
|
<div class="auth-kicker">
|
||||||
|
<span class="auth-kicker-logo">WB</span>
|
||||||
|
<span class="auth-kicker-text">Wildberries Feedback</span>
|
||||||
|
</div>
|
||||||
|
<h1>Добро пожаловать</h1>
|
||||||
|
<p class="auth-subtitle">Управляйте отзывами, ответами и автоответом в одном кабинете.</p>
|
||||||
|
|
||||||
|
{% if error_message %}
|
||||||
|
<div class="alert alert-error">{{ error_message }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" class="auth-form">
|
||||||
|
<label>
|
||||||
|
Логин
|
||||||
|
<input type="text" name="username" placeholder="Введите логин" required autofocus />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Пароль
|
||||||
|
<input type="password" name="password" placeholder="••••••••" required />
|
||||||
|
</label>
|
||||||
|
<button type="submit">Войти</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="auth-footer">Нет аккаунта? <a href="{{ url_for('register') }}">Запросить доступ</a></p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Регистрация — WB Feedback</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="auth-shell">
|
||||||
|
<section class="auth-card">
|
||||||
|
<div class="auth-kicker">
|
||||||
|
<span class="auth-kicker-logo">WB</span>
|
||||||
|
<span class="auth-kicker-text">Wildberries Feedback</span>
|
||||||
|
</div>
|
||||||
|
<h1>Запрос доступа</h1>
|
||||||
|
<p class="auth-subtitle">После регистрации администратор подтвердит доступ к кабинету.</p>
|
||||||
|
|
||||||
|
{% if error_message %}
|
||||||
|
<div class="alert alert-error">{{ error_message }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if success_message %}
|
||||||
|
<div class="alert alert-success">{{ success_message }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" class="auth-form">
|
||||||
|
<label>
|
||||||
|
Логин
|
||||||
|
<input type="text" name="username" placeholder="Придумайте логин" required autofocus />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Пароль
|
||||||
|
<input type="password" name="password" placeholder="Придумайте пароль" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Повторите пароль
|
||||||
|
<input type="password" name="confirm" placeholder="Повторите пароль" required />
|
||||||
|
</label>
|
||||||
|
<button type="submit">Отправить заявку</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="auth-footer">Уже есть аккаунт? <a href="{{ url_for('login') }}">Войти</a></p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user