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:
2026-05-15 17:46:35 +03:00
parent 79768a4fac
commit 53f1bb2e71
21 changed files with 7037 additions and 783 deletions
+14
View File
@@ -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"]
+590 -46
View File
@@ -1,17 +1,21 @@
import logging
import json
import os
import random
import sqlite3
import threading
import time
from dataclasses import dataclass
from datetime import datetime
from datetime import datetime, timezone
from functools import wraps
from logging.handlers import RotatingFileHandler
from pathlib import Path
from typing import List, Optional, Set, Tuple
from zoneinfo import ZoneInfo
import requests
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
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_POOL_5_KEY = "auto_reply_pool_5"
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_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 = [
"Спасибо за высокую оценку! Нам очень приятно.",
"Благодарим за отзыв и доверие к нашему магазину!",
@@ -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):
"""Raised when the Wildberries Feedback API returns an error."""
@@ -50,6 +113,7 @@ class Review:
rating: int
created_at: datetime
product_name: str
nm_id: int
answer: Optional[str]
is_answered: bool
user_name: str
@@ -74,6 +138,7 @@ class Review:
rating=payload.get("productValuation") or 0,
created_at=created_dt,
product_name=product.get("productName") or "",
nm_id=int(product.get("nmId") or 0),
answer=answer_value,
is_answered=bool(answer_value),
user_name=payload.get("userName") or "",
@@ -133,6 +198,7 @@ class Database:
rating INTEGER NOT NULL,
product_name TEXT,
user_name TEXT,
review_text TEXT,
reply_text TEXT NOT NULL,
status TEXT NOT NULL,
error_text TEXT
@@ -141,6 +207,7 @@ class Database:
)
self._ensure_admin(conn)
self._ensure_token_user_column(conn)
self._ensure_auto_reply_log_review_text(conn)
conn.commit()
finally:
conn.close()
@@ -167,6 +234,16 @@ class Database:
(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
def create_user(self, username: str, password_hash: str) -> None:
conn = self._connect()
@@ -222,6 +299,17 @@ class Database:
finally:
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]:
conn = self._connect()
try:
@@ -296,7 +384,10 @@ class Database:
review_id: str,
rating: int,
product_name: str,
nm_id: int = 0,
user_name: str,
review_text: str,
review_created_at: str = "",
reply_text: str,
status: str,
error_text: Optional[str] = None,
@@ -306,15 +397,18 @@ class Database:
conn.execute(
"""
INSERT INTO auto_reply_logs(
created_at, review_id, rating, product_name, user_name, reply_text, status, error_text
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
created_at, review_id, rating, product_name, nm_id, user_name, review_text, review_created_at, reply_text, status, error_text
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
datetime.utcnow().isoformat(),
datetime.utcnow().replace(microsecond=0).isoformat(),
review_id,
rating,
product_name,
nm_id,
user_name,
review_text,
review_created_at,
reply_text,
status,
error_text,
@@ -347,45 +441,148 @@ class FeedbackClient:
self.token = token
self.page_size = page_size
self.timeout = timeout
self._last_request_ts = 0.0
def _get_headers(self) -> dict:
if not self.token:
raise FeedbackApiError(
"Токен 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]:
_check_api_cooldown_or_raise()
params = {
"isAnswered": str(is_answered).lower(),
"skip": skip,
"take": take,
}
headers = self._get_headers()
response = requests.get(self.BASE_URL, params=params, headers=headers, timeout=self.timeout)
if not response.ok:
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)
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}")
if response is None:
raise FeedbackApiError("Не удалось получить ответ от API Wildberries.")
payload = response.json()
if payload.get("error"):
raise FeedbackApiError(payload.get("errorText") or "Не удалось получить отзывы.")
data = payload.get("data") 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(
self,
limit: int = 50,
unanswered_only: bool = False,
allowed_ratings: Optional[Set[int]] = None,
) -> List[Review]:
reviews: List[Review] = []
if unanswered_only:
raw_reviews = self._request(is_answered=False, skip=0, take=min(limit, self.page_size))
reviews = [Review.from_api(item) for item in raw_reviews]
raw = self._paginate(is_answered=False, max_items=max(limit * 3, 300))
reviews = [Review.from_api(item) for item in raw]
else:
raw = []
raw.extend(self._request(is_answered=False, skip=0, take=self.page_size))
raw.extend(self._request(is_answered=True, skip=0, take=self.page_size))
raw.sort(key=lambda r: r.get("createdDate"), reverse=True)
raw_unanswered = self._paginate(is_answered=False, max_items=limit)
raw_answered = self._paginate(is_answered=True, max_items=limit)
raw = raw_unanswered + raw_answered
raw.sort(key=lambda r: r.get("createdDate") or "", reverse=True)
reviews = [Review.from_api(item) for item in raw]
allowed = allowed_ratings or DEFAULT_STARS
@@ -398,15 +595,74 @@ class FeedbackClient:
self._request(is_answered=False, skip=0, take=1)
def send_answer(self, review_id: str, text: str) -> None:
_check_api_cooldown_or_raise()
payload = {"id": review_id, "text": text}
response = requests.post(
self.ANSWER_URL,
json=payload,
headers=self._get_headers(),
timeout=self.timeout,
)
if not response.ok:
raise FeedbackApiError(f"Не удалось отправить ответ: {response.status_code} {response.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(
self.ANSWER_URL,
json=payload,
headers=self._get_headers(),
timeout=self.timeout,
)
logger.info(
"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:
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")
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]:
if star == 5:
db_value = db.get_setting(AUTO_REPLY_POOL_5_KEY)
@@ -526,38 +877,116 @@ def _pick_auto_reply(star: int) -> Optional[str]:
return None
def _has_review_content(review: Review) -> bool:
return bool((review.text or "").strip())
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()
if not token_value:
logger.info("Auto-reply skipped: background token is missing.")
return 0
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
for review in reviews:
reply_text = _pick_auto_reply(review.rating)
processed = 0
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:
logger.info("Auto-reply skip review_id=%s reason=no_pool_reply rating=%s", review_id, rating)
queue.pop(0)
processed += 1
continue
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(
review_id=review.id,
rating=review.rating,
product_name=review.product_name,
user_name=review.user_name,
review_id=review_id,
rating=rating,
product_name=item.get("product_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,
status="sent",
)
sent += 1
queue.pop(0)
processed += 1
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(
review_id=review.id,
rating=review.rating,
product_name=review.product_name,
user_name=review.user_name,
review_id=review_id,
rating=rating,
product_name=item.get("product_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,
status="failed",
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
@@ -568,7 +997,21 @@ def _scheduler_should_run(last_run_raw: Optional[str], now_ts: float) -> bool:
last_run = float(last_run_raw)
except ValueError:
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:
@@ -578,18 +1021,39 @@ def auto_reply_loop() -> None:
now_ts = time.time()
last_run = db.get_setting(AUTO_REPLY_LAST_RUN_KEY)
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))
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:
pass
time.sleep(60)
logger.exception("Auto-reply loop crashed")
time.sleep(AUTO_REPLY_LOOP_SLEEP_SECONDS)
@app.template_filter("format_datetime")
def format_datetime(value: Optional[datetime]) -> str:
if not value:
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
@@ -603,6 +1067,26 @@ def load_current_user() -> None:
else:
session.pop("user_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):
@@ -634,6 +1118,7 @@ def cabinet():
status = request.args.get("status")
status_map = {
"added": "Токен сохранён.",
"updated": "Токен обновлён.",
"selected": "Активирован выбранный магазин.",
"checked": "Токен успешно проверен.",
}
@@ -650,6 +1135,18 @@ def cabinet():
else:
db.add_token(user_id=user["id"], name=name, token=token_value)
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":
token_id = request.form.get("token_id")
if token_id:
@@ -671,7 +1168,13 @@ def cabinet():
error_message = "Недостаточно прав для проверки токена."
raw_tokens = db.fetch_tokens_for_user(user["id"], bool(user["is_admin"]))
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
]
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 = set(selected_stars_list)
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()
client: Optional[FeedbackClient] = None
client_error: Optional[str] = None
@@ -706,7 +1211,12 @@ def index():
success_message: Optional[str] = None
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:
if action == "all":
reviews = client.fetch_reviews(
@@ -751,13 +1261,34 @@ def index():
current_action=action or "all",
auto_reply_enabled=is_auto_reply_enabled(),
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_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),
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"])
@login_required
def auto_reply_toggle():
@@ -769,11 +1300,12 @@ def auto_reply_toggle():
@app.route("/auto-reply-pools", methods=["POST"])
@login_required
def auto_reply_pools():
pool_5_raw = (request.form.get("pool_5") or "").strip()
pool_4_raw = (request.form.get("pool_4") or "").strip()
pool_5 = _parse_pool(pool_5_raw, [])
pool_4 = _parse_pool(pool_4_raw, [])
pool_5 = [item.strip() for item in request.form.getlist("pool_5_item") if item.strip()]
pool_4 = [item.strip() for item in request.form.getlist("pool_4_item") if item.strip()]
is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest"
if not pool_5 or not pool_4:
if is_ajax:
return jsonify({"ok": False, "error": "Для 5★ и 4★ укажите минимум по одному варианту ответа."}), 400
return redirect(
url_for(
"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_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]))
@@ -928,6 +1462,16 @@ def admin_panel():
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"
if is_reloader_process or not app.debug:
threading.Thread(target=auto_reply_loop, daemon=True).start()
+16
View File
@@ -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.
-339
View File
@@ -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%; }
}
-53
View File
@@ -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>
-84
View File
@@ -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>
-196
View File
@@ -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>
-29
View File
@@ -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>
-36
View File
@@ -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>
+3
View File
@@ -0,0 +1,3 @@
Flask==3.0.3
python-dotenv==1.0.1
requests==2.32.3
+1285
View File
File diff suppressed because it is too large Load Diff
+1985
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+111
View File
@@ -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>
+158
View File
@@ -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>
+444
View File
@@ -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, '&quot;')}" 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>
+39
View File
@@ -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>
+46
View File
@@ -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>