first commit
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,935 @@
|
||||
import os
|
||||
import random
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Set, Tuple
|
||||
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
from flask import Flask, g, redirect, render_template, request, session, url_for
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
load_dotenv(BASE_DIR / '.env')
|
||||
|
||||
STAR_VALUES = [5, 4, 3, 2, 1]
|
||||
DEFAULT_STARS = {5}
|
||||
AUTO_REPLY_STARS = {5, 4}
|
||||
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_INTERVAL_MINUTES = int(os.getenv("AUTO_REPLY_INTERVAL_MINUTES", "10"))
|
||||
DEFAULT_REPLY_POOL_5 = [
|
||||
"Спасибо за высокую оценку! Нам очень приятно.",
|
||||
"Благодарим за отзыв и доверие к нашему магазину!",
|
||||
"Спасибо! Рады, что товар вам понравился.",
|
||||
]
|
||||
DEFAULT_REPLY_POOL_4 = [
|
||||
"Спасибо за отзыв! Учтём ваши замечания и станем лучше.",
|
||||
"Благодарим за оценку! Работаем над тем, чтобы было на 5 звёзд.",
|
||||
"Спасибо за обратную связь! Нам важно ваше мнение.",
|
||||
]
|
||||
|
||||
|
||||
class FeedbackApiError(RuntimeError):
|
||||
"""Raised when the Wildberries Feedback API returns an error."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Review:
|
||||
id: str
|
||||
text: str
|
||||
pros: str
|
||||
cons: str
|
||||
rating: int
|
||||
created_at: datetime
|
||||
product_name: str
|
||||
answer: Optional[str]
|
||||
is_answered: bool
|
||||
user_name: str
|
||||
|
||||
@classmethod
|
||||
def from_api(cls, payload: dict) -> "Review":
|
||||
created_at = payload.get("createdDate")
|
||||
created_dt = None
|
||||
if created_at:
|
||||
created_dt = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
|
||||
product = payload.get("productDetails", {}) or {}
|
||||
answer_payload = payload.get("answer")
|
||||
if isinstance(answer_payload, dict):
|
||||
answer_value = answer_payload.get("text")
|
||||
else:
|
||||
answer_value = answer_payload
|
||||
return cls(
|
||||
id=payload.get("id") or "",
|
||||
text=payload.get("text") or "",
|
||||
pros=payload.get("pros") or "",
|
||||
cons=payload.get("cons") or "",
|
||||
rating=payload.get("productValuation") or 0,
|
||||
created_at=created_dt,
|
||||
product_name=product.get("productName") or "",
|
||||
answer=answer_value,
|
||||
is_answered=bool(answer_value),
|
||||
user_name=payload.get("userName") or "",
|
||||
)
|
||||
|
||||
|
||||
class Database:
|
||||
def __init__(self, db_path: Path) -> None:
|
||||
self.db_path = db_path
|
||||
self._ensure_db()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
return conn
|
||||
|
||||
def _ensure_db(self) -> None:
|
||||
conn = self._connect()
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER DEFAULT 0,
|
||||
is_active INTEGER DEFAULT 0
|
||||
);
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER,
|
||||
name TEXT NOT NULL,
|
||||
token TEXT NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
);
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS auto_reply_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT NOT NULL,
|
||||
review_id TEXT NOT NULL,
|
||||
rating INTEGER NOT NULL,
|
||||
product_name TEXT,
|
||||
user_name TEXT,
|
||||
reply_text TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
error_text TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
self._ensure_admin(conn)
|
||||
self._ensure_token_user_column(conn)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _ensure_admin(self, conn: sqlite3.Connection) -> None:
|
||||
admin = conn.execute("SELECT id FROM users WHERE username = ?", ("ruslan",)).fetchone()
|
||||
if not admin:
|
||||
password_hash = generate_password_hash("utOgbZ09ruslan+")
|
||||
conn.execute(
|
||||
"INSERT INTO users(username, password_hash, is_admin, is_active) VALUES (?, ?, 1, 1)",
|
||||
("ruslan", password_hash),
|
||||
)
|
||||
|
||||
def _ensure_token_user_column(self, conn: sqlite3.Connection) -> None:
|
||||
info = conn.execute("PRAGMA table_info(tokens)").fetchall()
|
||||
columns = {row["name"] for row in info}
|
||||
if "user_id" not in columns:
|
||||
conn.execute("ALTER TABLE tokens ADD COLUMN user_id INTEGER")
|
||||
admin = conn.execute("SELECT id FROM users WHERE username = ?", ("ruslan",)).fetchone()
|
||||
admin_id = admin["id"] if admin else None
|
||||
if admin_id:
|
||||
conn.execute(
|
||||
"UPDATE tokens SET user_id = ? WHERE user_id IS NULL",
|
||||
(admin_id,),
|
||||
)
|
||||
|
||||
# User helpers
|
||||
def create_user(self, username: str, password_hash: str) -> None:
|
||||
conn = self._connect()
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO users(username, password_hash, is_admin, is_active) VALUES (?, ?, 0, 0)",
|
||||
(username.lower(), password_hash),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_user_by_username(self, username: str) -> Optional[sqlite3.Row]:
|
||||
conn = self._connect()
|
||||
try:
|
||||
return conn.execute(
|
||||
"SELECT * FROM users WHERE username = ?", (username.lower(),)
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_user_by_id(self, user_id: int) -> Optional[sqlite3.Row]:
|
||||
conn = self._connect()
|
||||
try:
|
||||
return conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def list_users(self) -> List[sqlite3.Row]:
|
||||
conn = self._connect()
|
||||
try:
|
||||
return conn.execute("SELECT * FROM users ORDER BY username").fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def set_user_active(self, user_id: int, is_active: bool) -> None:
|
||||
conn = self._connect()
|
||||
try:
|
||||
conn.execute("UPDATE users SET is_active = ? WHERE id = ?", (1 if is_active else 0, user_id))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# Token helpers
|
||||
def add_token(self, user_id: int, name: str, token: str) -> None:
|
||||
conn = self._connect()
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO tokens(user_id, name, token) VALUES (?, ?, ?)",
|
||||
(user_id, name.strip(), token.strip()),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def fetch_tokens_for_user(self, user_id: int, is_admin: bool) -> List[sqlite3.Row]:
|
||||
conn = self._connect()
|
||||
try:
|
||||
if is_admin:
|
||||
query = """
|
||||
SELECT tokens.id, tokens.name, tokens.token, tokens.user_id, users.username AS owner
|
||||
FROM tokens
|
||||
LEFT JOIN users ON users.id = tokens.user_id
|
||||
ORDER BY tokens.id DESC
|
||||
"""
|
||||
return conn.execute(query).fetchall()
|
||||
query = """
|
||||
SELECT tokens.id, tokens.name, tokens.token, tokens.user_id, users.username AS owner
|
||||
FROM tokens
|
||||
LEFT JOIN users ON users.id = tokens.user_id
|
||||
WHERE tokens.user_id = ?
|
||||
ORDER BY tokens.id DESC
|
||||
"""
|
||||
return conn.execute(query, (user_id,)).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def fetch_first_token_for_user(self, user_id: int) -> Optional[sqlite3.Row]:
|
||||
conn = self._connect()
|
||||
try:
|
||||
return conn.execute(
|
||||
"SELECT * FROM tokens WHERE user_id = ? ORDER BY id DESC LIMIT 1",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def fetch_first_token_any(self) -> Optional[sqlite3.Row]:
|
||||
conn = self._connect()
|
||||
try:
|
||||
return conn.execute("SELECT * FROM tokens ORDER BY id DESC LIMIT 1").fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_token(self, token_id: int) -> Optional[sqlite3.Row]:
|
||||
conn = self._connect()
|
||||
try:
|
||||
return conn.execute("SELECT * FROM tokens WHERE id = ?", (token_id,)).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_setting(self, key: str) -> Optional[str]:
|
||||
conn = self._connect()
|
||||
try:
|
||||
row = conn.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
|
||||
return row["value"] if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def set_setting(self, key: str, value: str) -> None:
|
||||
conn = self._connect()
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO settings(key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
||||
""",
|
||||
(key, value),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def add_auto_reply_log(
|
||||
self,
|
||||
*,
|
||||
review_id: str,
|
||||
rating: int,
|
||||
product_name: str,
|
||||
user_name: str,
|
||||
reply_text: str,
|
||||
status: str,
|
||||
error_text: Optional[str] = None,
|
||||
) -> None:
|
||||
conn = self._connect()
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO auto_reply_logs(
|
||||
created_at, review_id, rating, product_name, user_name, reply_text, status, error_text
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
datetime.utcnow().isoformat(),
|
||||
review_id,
|
||||
rating,
|
||||
product_name,
|
||||
user_name,
|
||||
reply_text,
|
||||
status,
|
||||
error_text,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def list_auto_reply_logs(self, limit: int = 100) -> List[sqlite3.Row]:
|
||||
conn = self._connect()
|
||||
try:
|
||||
return conn.execute(
|
||||
"""
|
||||
SELECT * FROM auto_reply_logs
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
class FeedbackClient:
|
||||
BASE_URL = "https://feedbacks-api.wildberries.ru/api/v1/feedbacks"
|
||||
ANSWER_URL = "https://feedbacks-api.wildberries.ru/api/v1/feedbacks/answer"
|
||||
|
||||
def __init__(self, token: Optional[str], page_size: int = 100, timeout: int = 15) -> None:
|
||||
self.token = token
|
||||
self.page_size = page_size
|
||||
self.timeout = timeout
|
||||
|
||||
def _get_headers(self) -> dict:
|
||||
if not self.token:
|
||||
raise FeedbackApiError(
|
||||
"Токен Wildberries не найден. Добавьте токен в личном кабинете."
|
||||
)
|
||||
return {"Authorization": f"Bearer {self.token}"}
|
||||
|
||||
def _request(self, *, is_answered: bool, skip: int, take: int) -> List[dict]:
|
||||
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:
|
||||
raise FeedbackApiError(f"Ошибка запроса: {response.status_code} {response.text}")
|
||||
payload = response.json()
|
||||
if payload.get("error"):
|
||||
raise FeedbackApiError(payload.get("errorText") or "Не удалось получить отзывы.")
|
||||
data = payload.get("data") or {}
|
||||
return data.get("feedbacks") or []
|
||||
|
||||
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]
|
||||
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)
|
||||
reviews = [Review.from_api(item) for item in raw]
|
||||
|
||||
allowed = allowed_ratings or DEFAULT_STARS
|
||||
filtered = [item for item in reviews if item.rating in allowed]
|
||||
return filtered[:limit]
|
||||
|
||||
def validate_token(self) -> None:
|
||||
"""Проверяет токен, выполняя минимальный запрос к API."""
|
||||
# Используем минимальный набор данных, чтобы не тратить лимиты без необходимости.
|
||||
self._request(is_answered=False, skip=0, take=1)
|
||||
|
||||
def send_answer(self, review_id: str, text: str) -> None:
|
||||
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}")
|
||||
|
||||
def answer_many(self, review_ids: List[str], text: str) -> int:
|
||||
sent = 0
|
||||
for review_id in review_ids:
|
||||
if not review_id:
|
||||
continue
|
||||
self.send_answer(review_id, text)
|
||||
sent += 1
|
||||
return sent
|
||||
|
||||
|
||||
def _parse_pool(value: Optional[str], fallback: List[str]) -> List[str]:
|
||||
if not value:
|
||||
return fallback
|
||||
normalized = value.replace("\r\n", "\n").replace("||", "\n")
|
||||
parsed = [item.strip() for item in normalized.split("\n") if item.strip()]
|
||||
return parsed or fallback
|
||||
|
||||
|
||||
ENV_REPLY_POOL_5 = os.getenv("REPLY_POOL_5")
|
||||
ENV_REPLY_POOL_4 = os.getenv("REPLY_POOL_4")
|
||||
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config["SECRET_KEY"] = os.getenv("FLASK_SECRET_KEY", "dev-secret")
|
||||
db = Database(BASE_DIR / "tokens.db")
|
||||
|
||||
|
||||
def _parse_selected_stars(raw_values: List[str]) -> List[int]:
|
||||
normalized: List[int] = []
|
||||
for value in raw_values:
|
||||
try:
|
||||
star = int(value)
|
||||
except ValueError:
|
||||
continue
|
||||
if star in STAR_VALUES and star not in normalized:
|
||||
normalized.append(star)
|
||||
if not normalized:
|
||||
return [5]
|
||||
return normalized
|
||||
|
||||
|
||||
def _get_session_token() -> Tuple[Optional[int], Optional[str], Optional[str]]:
|
||||
user = g.get("user")
|
||||
if not user:
|
||||
return (None, None, None)
|
||||
token_id = session.get("token_id")
|
||||
token_row = None
|
||||
if token_id is not None:
|
||||
token_row = db.get_token(int(token_id))
|
||||
if not token_row or (
|
||||
not user["is_admin"] and token_row["user_id"] != user["id"]
|
||||
):
|
||||
session.pop("token_id", None)
|
||||
token_row = None
|
||||
if token_row is None:
|
||||
token_row = db.fetch_first_token_for_user(user["id"])
|
||||
if token_row is None and user["is_admin"]:
|
||||
token_row = db.fetch_first_token_any()
|
||||
if token_row is not None:
|
||||
session["token_id"] = token_row["id"]
|
||||
if token_row is None:
|
||||
return (None, None, None)
|
||||
return token_row["id"], token_row["name"], token_row["token"]
|
||||
|
||||
|
||||
def _get_active_token() -> Tuple[Optional[str], Optional[str]]:
|
||||
token_id, token_name, token_value = _get_session_token()
|
||||
if token_value:
|
||||
return token_value, token_name
|
||||
return None, None
|
||||
|
||||
|
||||
def _get_background_token() -> Optional[str]:
|
||||
env_token = (os.getenv("WB_API_TOKEN") or "").strip()
|
||||
if env_token:
|
||||
return env_token
|
||||
token_row = db.fetch_first_token_any()
|
||||
if token_row:
|
||||
return token_row["token"]
|
||||
return None
|
||||
|
||||
|
||||
def get_client() -> FeedbackClient:
|
||||
token_value, _ = _get_active_token()
|
||||
if not token_value:
|
||||
raise FeedbackApiError("Токен не задан. Добавьте его в личном кабинете.")
|
||||
return FeedbackClient(token_value)
|
||||
|
||||
|
||||
def is_auto_reply_enabled() -> bool:
|
||||
return db.get_setting(AUTO_REPLY_SETTING_KEY) == "1"
|
||||
|
||||
|
||||
def set_auto_reply_enabled(value: bool) -> None:
|
||||
db.set_setting(AUTO_REPLY_SETTING_KEY, "1" if value else "0")
|
||||
|
||||
|
||||
def _load_reply_pool(star: int) -> List[str]:
|
||||
if star == 5:
|
||||
db_value = db.get_setting(AUTO_REPLY_POOL_5_KEY)
|
||||
return _parse_pool(db_value or ENV_REPLY_POOL_5, DEFAULT_REPLY_POOL_5)
|
||||
if star == 4:
|
||||
db_value = db.get_setting(AUTO_REPLY_POOL_4_KEY)
|
||||
return _parse_pool(db_value or ENV_REPLY_POOL_4, DEFAULT_REPLY_POOL_4)
|
||||
return []
|
||||
|
||||
|
||||
def _pool_to_multiline_text(pool: List[str]) -> str:
|
||||
return "\n".join(pool)
|
||||
|
||||
|
||||
def _pick_auto_reply(star: int) -> Optional[str]:
|
||||
pool = _load_reply_pool(star)
|
||||
if pool:
|
||||
return random.choice(pool)
|
||||
return None
|
||||
|
||||
|
||||
def process_auto_replies() -> int:
|
||||
token_value = _get_background_token()
|
||||
if not token_value:
|
||||
return 0
|
||||
client = FeedbackClient(token_value)
|
||||
reviews = client.fetch_reviews(limit=100, unanswered_only=True, allowed_ratings=AUTO_REPLY_STARS)
|
||||
sent = 0
|
||||
for review in reviews:
|
||||
reply_text = _pick_auto_reply(review.rating)
|
||||
if not reply_text:
|
||||
continue
|
||||
try:
|
||||
client.send_answer(review.id, reply_text)
|
||||
db.add_auto_reply_log(
|
||||
review_id=review.id,
|
||||
rating=review.rating,
|
||||
product_name=review.product_name,
|
||||
user_name=review.user_name,
|
||||
reply_text=reply_text,
|
||||
status="sent",
|
||||
)
|
||||
sent += 1
|
||||
except FeedbackApiError as exc:
|
||||
db.add_auto_reply_log(
|
||||
review_id=review.id,
|
||||
rating=review.rating,
|
||||
product_name=review.product_name,
|
||||
user_name=review.user_name,
|
||||
reply_text=reply_text,
|
||||
status="failed",
|
||||
error_text=str(exc),
|
||||
)
|
||||
return sent
|
||||
|
||||
|
||||
def _scheduler_should_run(last_run_raw: Optional[str], now_ts: float) -> bool:
|
||||
if not last_run_raw:
|
||||
return True
|
||||
try:
|
||||
last_run = float(last_run_raw)
|
||||
except ValueError:
|
||||
return True
|
||||
return now_ts - last_run >= AUTO_REPLY_INTERVAL_MINUTES * 60
|
||||
|
||||
|
||||
def auto_reply_loop() -> None:
|
||||
while True:
|
||||
try:
|
||||
if is_auto_reply_enabled():
|
||||
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()
|
||||
db.set_setting(AUTO_REPLY_LAST_RUN_KEY, str(now_ts))
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(60)
|
||||
|
||||
|
||||
@app.template_filter("format_datetime")
|
||||
def format_datetime(value: Optional[datetime]) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
return value.strftime("%d.%m.%Y %H:%M")
|
||||
|
||||
|
||||
@app.before_request
|
||||
def load_current_user() -> None:
|
||||
user_id = session.get("user_id")
|
||||
g.user = None
|
||||
if user_id is not None:
|
||||
user = db.get_user_by_id(int(user_id))
|
||||
if user and user["is_active"]:
|
||||
g.user = user
|
||||
else:
|
||||
session.pop("user_id", None)
|
||||
session.pop("token_id", None)
|
||||
|
||||
|
||||
def login_required(view):
|
||||
@wraps(view)
|
||||
def wrapped(*args, **kwargs):
|
||||
if g.get("user") is None:
|
||||
return redirect(url_for("login", next=request.path))
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def admin_required(view):
|
||||
@wraps(view)
|
||||
def wrapped(*args, **kwargs):
|
||||
user = g.get("user")
|
||||
if not user or not user["is_admin"]:
|
||||
return redirect(url_for("index"))
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
@app.route("/cabinet", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def cabinet():
|
||||
error_message: Optional[str] = None
|
||||
success_message: Optional[str] = None
|
||||
status = request.args.get("status")
|
||||
status_map = {
|
||||
"added": "Токен сохранён.",
|
||||
"selected": "Активирован выбранный магазин.",
|
||||
"checked": "Токен успешно проверен.",
|
||||
}
|
||||
if status in status_map:
|
||||
success_message = status_map[status]
|
||||
user = g.user
|
||||
if request.method == "POST":
|
||||
action = request.form.get("cabinet_action")
|
||||
if action == "add":
|
||||
name = (request.form.get("name") or "").strip()
|
||||
token_value = (request.form.get("token") or "").strip()
|
||||
if not name or not token_value:
|
||||
error_message = "Введите название и токен."
|
||||
else:
|
||||
db.add_token(user_id=user["id"], name=name, token=token_value)
|
||||
return redirect(url_for("cabinet", status="added"))
|
||||
elif action == "select":
|
||||
token_id = request.form.get("token_id")
|
||||
if token_id:
|
||||
token_row = db.get_token(int(token_id))
|
||||
if token_row and (user["is_admin"] or token_row["user_id"] == user["id"]):
|
||||
session["token_id"] = int(token_id)
|
||||
return redirect(url_for("cabinet", status="selected"))
|
||||
error_message = "Не удалось выбрать токен."
|
||||
elif action == "check":
|
||||
token_id = request.form.get("token_id")
|
||||
token_row = db.get_token(int(token_id)) if token_id else None
|
||||
if token_row and (user["is_admin"] or token_row["user_id"] == user["id"]):
|
||||
try:
|
||||
FeedbackClient(token_row["token"]).validate_token()
|
||||
return redirect(url_for("cabinet", status="checked"))
|
||||
except FeedbackApiError as exc:
|
||||
error_message = str(exc)
|
||||
else:
|
||||
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"]}
|
||||
for row in raw_tokens
|
||||
]
|
||||
active_token_id = session.get("token_id")
|
||||
return render_template(
|
||||
"cabinet.html",
|
||||
tokens=tokens,
|
||||
active_token_id=active_token_id,
|
||||
error_message=error_message,
|
||||
success_message=success_message,
|
||||
current_user=user,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/")
|
||||
@login_required
|
||||
def index():
|
||||
action = request.args.get("action") or "all"
|
||||
status = request.args.get("status")
|
||||
selected_stars_list = _parse_selected_stars(request.args.getlist("stars"))
|
||||
selected_stars = set(selected_stars_list)
|
||||
selected_stars_display = sorted(selected_stars, reverse=True)
|
||||
active_token_value, active_token_name = _get_active_token()
|
||||
client: Optional[FeedbackClient] = None
|
||||
client_error: Optional[str] = None
|
||||
try:
|
||||
client = get_client()
|
||||
except FeedbackApiError as exc:
|
||||
client_error = str(exc)
|
||||
reviews: List[Review] = []
|
||||
current_filter: Optional[str] = None
|
||||
error_message: Optional[str] = None
|
||||
success_message: Optional[str] = None
|
||||
|
||||
if action in {"all", "unanswered"}:
|
||||
if client:
|
||||
try:
|
||||
if action == "all":
|
||||
reviews = client.fetch_reviews(
|
||||
limit=50,
|
||||
unanswered_only=False,
|
||||
allowed_ratings=selected_stars,
|
||||
)
|
||||
current_filter = "all"
|
||||
else:
|
||||
reviews = client.fetch_reviews(
|
||||
limit=50,
|
||||
unanswered_only=True,
|
||||
allowed_ratings=selected_stars,
|
||||
)
|
||||
current_filter = "unanswered"
|
||||
except FeedbackApiError as exc:
|
||||
error_message = str(exc)
|
||||
else:
|
||||
error_message = client_error or "Токен не задан."
|
||||
elif action == "clear":
|
||||
return redirect(url_for("index"))
|
||||
if status == "reply_sent":
|
||||
count = request.args.get("count") or "0"
|
||||
success_message = f"Отправлено ответов: {count}"
|
||||
elif status == "pools_saved":
|
||||
success_message = "Пулы автоответов сохранены."
|
||||
elif status == "reply_failed":
|
||||
error_text = request.args.get("error") or "Не удалось отправить ответы."
|
||||
error_message = error_text
|
||||
|
||||
return render_template(
|
||||
"index.html",
|
||||
reviews=reviews,
|
||||
current_filter=current_filter,
|
||||
error_message=error_message,
|
||||
success_message=success_message,
|
||||
selected_stars=selected_stars,
|
||||
selected_stars_display=selected_stars_display,
|
||||
selected_stars_list=selected_stars_list,
|
||||
active_token_name=active_token_name,
|
||||
has_token=bool(active_token_value),
|
||||
current_action=action or "all",
|
||||
auto_reply_enabled=is_auto_reply_enabled(),
|
||||
auto_reply_interval_minutes=AUTO_REPLY_INTERVAL_MINUTES,
|
||||
reply_pool_5_text=_pool_to_multiline_text(_load_reply_pool(5)),
|
||||
reply_pool_4_text=_pool_to_multiline_text(_load_reply_pool(4)),
|
||||
auto_reply_logs=db.list_auto_reply_logs(limit=100),
|
||||
current_user=g.user,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/auto-reply-toggle", methods=["POST"])
|
||||
@login_required
|
||||
def auto_reply_toggle():
|
||||
enabled = request.form.get("enabled") == "1"
|
||||
set_auto_reply_enabled(enabled)
|
||||
return redirect(url_for("index", action="unanswered", stars=[5, 4]))
|
||||
|
||||
|
||||
@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, [])
|
||||
if not pool_5 or not pool_4:
|
||||
return redirect(
|
||||
url_for(
|
||||
"index",
|
||||
status="reply_failed",
|
||||
error="Для 5★ и 4★ укажите минимум по одному варианту ответа.",
|
||||
action="unanswered",
|
||||
stars=[5, 4],
|
||||
)
|
||||
)
|
||||
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))
|
||||
return redirect(url_for("index", status="pools_saved", action="unanswered", stars=[5, 4]))
|
||||
|
||||
|
||||
@app.route("/reply", methods=["POST"])
|
||||
@login_required
|
||||
def reply_all():
|
||||
message = (request.form.get("message") or "").strip()
|
||||
review_ids = request.form.getlist("review_id")
|
||||
stars_from_form = request.form.getlist("stars")
|
||||
selected_star_values = _parse_selected_stars(stars_from_form)
|
||||
redirect_params: dict = {"action": "unanswered", "stars": selected_star_values}
|
||||
if not message:
|
||||
return redirect(
|
||||
url_for("index", status="reply_failed", error="Введите текст ответа.", **redirect_params)
|
||||
)
|
||||
if len(message) < 2 or len(message) > 5000:
|
||||
return redirect(
|
||||
url_for(
|
||||
"index",
|
||||
status="reply_failed",
|
||||
error="Ответ должен содержать от 2 до 5000 символов.",
|
||||
**redirect_params,
|
||||
)
|
||||
)
|
||||
deduped_ids = []
|
||||
for review_id in review_ids:
|
||||
if review_id and review_id not in deduped_ids:
|
||||
deduped_ids.append(review_id)
|
||||
if not deduped_ids:
|
||||
return redirect(
|
||||
url_for(
|
||||
"index",
|
||||
status="reply_failed",
|
||||
error="Нет отзывов для ответа.",
|
||||
**redirect_params,
|
||||
)
|
||||
)
|
||||
try:
|
||||
client = get_client()
|
||||
sent = client.answer_many(deduped_ids, message)
|
||||
except FeedbackApiError as exc:
|
||||
return redirect(url_for("index", status="reply_failed", error=str(exc), **redirect_params))
|
||||
|
||||
return redirect(url_for("index", status="reply_sent", count=sent, **redirect_params))
|
||||
|
||||
|
||||
@app.route("/reply/<review_id>", methods=["POST"])
|
||||
@login_required
|
||||
def reply_single(review_id: str):
|
||||
message = (request.form.get("message") or "").strip()
|
||||
stars_from_form = request.form.getlist("stars")
|
||||
next_action = request.form.get("next_action") or "all"
|
||||
redirect_params: dict = {"action": next_action, "stars": _parse_selected_stars(stars_from_form)}
|
||||
if not message:
|
||||
return redirect(
|
||||
url_for("index", status="reply_failed", error="Введите текст ответа.", **redirect_params)
|
||||
)
|
||||
if len(message) < 2 or len(message) > 5000:
|
||||
return redirect(
|
||||
url_for(
|
||||
"index",
|
||||
status="reply_failed",
|
||||
error="Ответ должен содержать от 2 до 5000 символов.",
|
||||
**redirect_params,
|
||||
)
|
||||
)
|
||||
try:
|
||||
client = get_client()
|
||||
client.send_answer(review_id, message)
|
||||
except FeedbackApiError as exc:
|
||||
return redirect(url_for("index", status="reply_failed", error=str(exc), **redirect_params))
|
||||
return redirect(url_for("index", status="reply_sent", count=1, **redirect_params))
|
||||
|
||||
|
||||
@app.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
if g.get("user"):
|
||||
return redirect(url_for("index"))
|
||||
error_message: Optional[str] = None
|
||||
if request.method == "POST":
|
||||
username = (request.form.get("username") or "").strip().lower()
|
||||
password = request.form.get("password") or ""
|
||||
user = db.get_user_by_username(username)
|
||||
if not user or not check_password_hash(user["password_hash"], password):
|
||||
error_message = "Неверный логин или пароль."
|
||||
elif not user["is_active"]:
|
||||
error_message = "Аккаунт ожидает подтверждения администратора."
|
||||
else:
|
||||
session.clear()
|
||||
session["user_id"] = user["id"]
|
||||
return redirect(request.args.get("next") or url_for("index"))
|
||||
return render_template("login.html", error_message=error_message)
|
||||
|
||||
|
||||
@app.route("/logout")
|
||||
def logout():
|
||||
session.clear()
|
||||
return redirect(url_for("login"))
|
||||
|
||||
|
||||
@app.route("/register", methods=["GET", "POST"])
|
||||
def register():
|
||||
if g.get("user"):
|
||||
return redirect(url_for("index"))
|
||||
error_message: Optional[str] = None
|
||||
success_message: Optional[str] = None
|
||||
if request.method == "POST":
|
||||
username = (request.form.get("username") or "").strip().lower()
|
||||
password = request.form.get("password") or ""
|
||||
confirm = request.form.get("confirm") or ""
|
||||
if not username or not password:
|
||||
error_message = "Введите логин и пароль."
|
||||
elif password != confirm:
|
||||
error_message = "Пароли не совпадают."
|
||||
elif username == "ruslan":
|
||||
error_message = "Нельзя использовать зарезервированный логин."
|
||||
else:
|
||||
try:
|
||||
password_hash = generate_password_hash(password)
|
||||
db.create_user(username, password_hash)
|
||||
success_message = "Заявка создана. Дождитесь подтверждения администратора."
|
||||
except sqlite3.IntegrityError:
|
||||
error_message = "Такой пользователь уже существует."
|
||||
return render_template("register.html", error_message=error_message, success_message=success_message)
|
||||
|
||||
|
||||
@app.route("/admin", methods=["GET", "POST"])
|
||||
@admin_required
|
||||
def admin_panel():
|
||||
message: Optional[str] = None
|
||||
if request.args.get("status") == "updated":
|
||||
message = "Статус пользователя обновлён."
|
||||
if request.method == "POST":
|
||||
user_id = request.form.get("user_id")
|
||||
action = request.form.get("admin_action")
|
||||
if user_id and action in {"activate", "deactivate"}:
|
||||
db.set_user_active(int(user_id), action == "activate")
|
||||
return redirect(url_for("admin_panel", status="updated"))
|
||||
users = db.list_users()
|
||||
return render_template("admin.html", users=users, info_message=message, current_user=g.user)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
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()
|
||||
port = int(os.getenv("FLASK_PORT", "5000"))
|
||||
app.run(host="0.0.0.0", port=port, debug=True)
|
||||
@@ -0,0 +1,61 @@
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
|
||||
BASE_DIR = Path('/home/sites/wild')
|
||||
load_dotenv(BASE_DIR / '.env')
|
||||
|
||||
token = (os.getenv('WB_API_TOKEN') or '').strip()
|
||||
if not token:
|
||||
raise SystemExit('WB_API_TOKEN not found in /home/sites/wild/.env')
|
||||
|
||||
url = 'https://feedbacks-api.wildberries.ru/api/v1/feedbacks'
|
||||
headers = {'Authorization': f'Bearer {token}'}
|
||||
params = {'isAnswered': 'true', 'skip': 0, 'take': 300}
|
||||
resp = requests.get(url, headers=headers, params=params, timeout=20)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
items = (payload.get('data') or {}).get('feedbacks') or []
|
||||
|
||||
filtered = []
|
||||
for item in items:
|
||||
rating = item.get('productValuation') or 0
|
||||
if rating not in (4, 5):
|
||||
continue
|
||||
ans = item.get('answer')
|
||||
if isinstance(ans, dict):
|
||||
ans_text = (ans.get('text') or '').strip()
|
||||
else:
|
||||
ans_text = (ans or '').strip()
|
||||
if not ans_text:
|
||||
continue
|
||||
filtered.append({
|
||||
'createdDate': item.get('createdDate') or '',
|
||||
'id': item.get('id') or '',
|
||||
'rating': rating,
|
||||
'productName': ((item.get('productDetails') or {}).get('productName') or ''),
|
||||
'userName': item.get('userName') or '',
|
||||
'answer': ans_text,
|
||||
})
|
||||
|
||||
filtered.sort(key=lambda x: x['createdDate'], reverse=True)
|
||||
last100 = filtered[:100]
|
||||
|
||||
out_json = BASE_DIR / 'last_100_answers_4_5.json'
|
||||
out_txt = BASE_DIR / 'last_100_answers_4_5.txt'
|
||||
out_json.write_text(json.dumps(last100, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
|
||||
lines = []
|
||||
for i, row in enumerate(last100, 1):
|
||||
lines.append(
|
||||
f"{i}. {row['createdDate']} | {row['rating']}★ | {row['productName']} | {row['userName']} | {row['answer']}"
|
||||
)
|
||||
out_txt.write_text('\n'.join(lines), encoding='utf-8')
|
||||
|
||||
print(f'exported={len(last100)}')
|
||||
print(out_json)
|
||||
print(out_txt)
|
||||
@@ -0,0 +1,60 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
import requests
|
||||
|
||||
base = Path('/app')
|
||||
|
||||
def read_env(path: Path):
|
||||
data = {}
|
||||
for line in path.read_text(encoding='utf-8').splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#') or '=' not in line:
|
||||
continue
|
||||
k, v = line.split('=', 1)
|
||||
data[k.strip()] = v.strip()
|
||||
return data
|
||||
|
||||
env = read_env(base / '.env')
|
||||
token = (env.get('WB_API_TOKEN') or '').strip()
|
||||
if not token:
|
||||
raise SystemExit('WB_API_TOKEN not found')
|
||||
|
||||
url = 'https://feedbacks-api.wildberries.ru/api/v1/feedbacks'
|
||||
headers = {'Authorization': f'Bearer {token}'}
|
||||
params = {'isAnswered': 'true', 'skip': 0, 'take': 100}
|
||||
r = requests.get(url, headers=headers, params=params, timeout=20)
|
||||
r.raise_for_status()
|
||||
chunk = (r.json().get('data') or {}).get('feedbacks') or []
|
||||
|
||||
rows = []
|
||||
for item in chunk:
|
||||
rating = item.get('productValuation') or 0
|
||||
if rating not in (4, 5):
|
||||
continue
|
||||
ans = item.get('answer')
|
||||
if isinstance(ans, dict):
|
||||
ans_text = (ans.get('text') or '').strip()
|
||||
else:
|
||||
ans_text = (ans or '').strip()
|
||||
if not ans_text:
|
||||
continue
|
||||
rows.append({
|
||||
'createdDate': item.get('createdDate') or '',
|
||||
'id': item.get('id') or '',
|
||||
'rating': rating,
|
||||
'productName': ((item.get('productDetails') or {}).get('productName') or ''),
|
||||
'userName': item.get('userName') or '',
|
||||
'answer': ans_text,
|
||||
})
|
||||
|
||||
rows.sort(key=lambda x: x['createdDate'], reverse=True)
|
||||
out_json = base / 'last_100_answers_4_5.json'
|
||||
out_txt = base / 'last_100_answers_4_5.txt'
|
||||
out_json.write_text(json.dumps(rows, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
lines = []
|
||||
for i, row in enumerate(rows, 1):
|
||||
lines.append(f"{i}. {row['createdDate']} | {row['rating']}★ | {row['productName']} | {row['userName']} | {row['answer']}")
|
||||
out_txt.write_text('\n'.join(lines), encoding='utf-8')
|
||||
print(f'exported={len(rows)}')
|
||||
print('/app/last_100_answers_4_5.json')
|
||||
print('/app/last_100_answers_4_5.txt')
|
||||
@@ -0,0 +1,339 @@
|
||||
@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%; }
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<!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>
|
||||
@@ -0,0 +1,84 @@
|
||||
<!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>
|
||||
@@ -0,0 +1,196 @@
|
||||
<!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>
|
||||
@@ -0,0 +1,29 @@
|
||||
<!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>
|
||||
@@ -0,0 +1,36 @@
|
||||
<!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>
|
||||
Reference in New Issue
Block a user