Initial commit
This commit is contained in:
20
services/google.py
Normal file
20
services/google.py
Normal file
@@ -0,0 +1,20 @@
|
||||
import httplib2
|
||||
import googleapiclient.discovery
|
||||
from oauth2client.service_account import ServiceAccountCredentials
|
||||
|
||||
from config import CREDENTIALS_FILE, SPREADSHEET_ID
|
||||
|
||||
|
||||
def get_google_service():
|
||||
credentials = ServiceAccountCredentials.from_json_keyfile_name(
|
||||
CREDENTIALS_FILE,
|
||||
[
|
||||
"https://www.googleapis.com/auth/spreadsheets",
|
||||
"https://www.googleapis.com/auth/drive",
|
||||
],
|
||||
)
|
||||
|
||||
http = credentials.authorize(httplib2.Http())
|
||||
return googleapiclient.discovery.build(
|
||||
"sheets", "v4", http=http, cache_discovery=False
|
||||
)
|
||||
260
services/instructions.py
Normal file
260
services/instructions.py
Normal file
@@ -0,0 +1,260 @@
|
||||
from database.db import get_connection
|
||||
from database.repository import (
|
||||
get_terminal_steps,
|
||||
set_instruction_progress,
|
||||
get_instruction_progress,
|
||||
clear_instruction_progress,
|
||||
find_instructions,
|
||||
)
|
||||
from keyboards.factory import pause_keyboard, back_to_main, build_keyboard
|
||||
from services.log_export import log_event
|
||||
|
||||
PAUSE_PROMPT = "Помогла ли эта часть?"
|
||||
PAUSE_YES = {"да, помогло", "да помогло", "да"}
|
||||
PAUSE_NO = {"нет, дальше", "нет дальше", "нет"}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# PINPAD — простой ответ
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
async def send_pinpad_error(message, code: str, keyboard=None):
|
||||
"""
|
||||
Отправляет простой ответ для ошибок пинпада:
|
||||
причина + что делать
|
||||
"""
|
||||
with get_connection() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT reason, action FROM pinpad_errors WHERE code = ?",
|
||||
(code,)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
if not row:
|
||||
return False # сигнал, что ошибки нет
|
||||
|
||||
reason, action = row
|
||||
log_event(message.from_id, "pinpad_error", str(code))
|
||||
action_text = (action or "").strip()
|
||||
if action_text.lower().startswith("terminal:"):
|
||||
query = action_text.split(":", 1)[1].strip()
|
||||
if query:
|
||||
found = find_instructions(query.lower())
|
||||
if len(found) == 1:
|
||||
instruction_id, _ = found[0]
|
||||
await send_terminal_instruction(
|
||||
message,
|
||||
instruction_id,
|
||||
keyboard=keyboard or back_to_main(),
|
||||
)
|
||||
return True
|
||||
if len(found) > 1:
|
||||
buttons = [{"title": title} for _, title in found]
|
||||
kb = build_keyboard(buttons, back=True)
|
||||
await message.answer(
|
||||
"🔎 Найдено несколько вариантов. Выберите нужный:",
|
||||
keyboard=kb,
|
||||
)
|
||||
return True
|
||||
|
||||
await message.answer(
|
||||
f"❗ Ошибка пинпада {code}\n\n"
|
||||
f"📌 Причина:\n{reason}\n\n"
|
||||
f"❌ Не удалось найти инструкцию по ключу: {query or 'не задан'}",
|
||||
keyboard=keyboard or back_to_main(),
|
||||
)
|
||||
return True
|
||||
|
||||
await message.answer(
|
||||
f"❗ Ошибка пинпада {code}\n\n"
|
||||
f"📌 Причина:\n{reason}\n\n"
|
||||
f"✅ Что делать:\n{action}",
|
||||
keyboard=keyboard
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# TERMINAL — подробная инструкция
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
async def _send_text(target, text: str, keyboard=None):
|
||||
if hasattr(target, "answer"):
|
||||
await target.answer(text, keyboard=keyboard)
|
||||
else:
|
||||
await target.send_message(text, keyboard=keyboard)
|
||||
|
||||
|
||||
async def _send_attachment(target, attachment: str):
|
||||
if hasattr(target, "answer"):
|
||||
await target.answer(attachment=attachment)
|
||||
else:
|
||||
await target.send_message(attachment=attachment)
|
||||
|
||||
|
||||
def _parse_goto(value: str):
|
||||
parts = (value or "").strip().split(":", 1)
|
||||
if not parts[0].isdigit():
|
||||
return None
|
||||
instr_id = int(parts[0])
|
||||
step_index = 0
|
||||
if len(parts) > 1:
|
||||
if not parts[1].isdigit():
|
||||
return None
|
||||
step_num = int(parts[1])
|
||||
if step_num > 0:
|
||||
step_index = step_num - 1
|
||||
return instr_id, step_index
|
||||
|
||||
|
||||
async def _send_terminal_from_index(target, instruction_id: int, start_index: int, keyboard=None, max_hops: int = 5):
|
||||
"""
|
||||
Отправляет пошаговую инструкцию терминала:
|
||||
несколько сообщений подряд (text / image)
|
||||
"""
|
||||
steps = get_terminal_steps(instruction_id)
|
||||
|
||||
if not steps:
|
||||
await _send_text(target, "❌ Для этой инструкции нет шагов.", keyboard=keyboard)
|
||||
return
|
||||
|
||||
for idx, (step_type, content) in enumerate(steps[start_index:], start=start_index):
|
||||
if step_type == "text":
|
||||
await _send_text(target, content, keyboard=keyboard)
|
||||
elif step_type == "image":
|
||||
await _send_attachment(target, content)
|
||||
elif step_type == "pause":
|
||||
await _send_text(target, PAUSE_PROMPT, keyboard=pause_keyboard())
|
||||
pause_at_end = (idx == len(steps) - 1)
|
||||
set_instruction_progress(
|
||||
target.from_id if hasattr(target, "from_id") else target.user_id,
|
||||
instruction_id,
|
||||
idx + 1,
|
||||
pause_at_end
|
||||
)
|
||||
return
|
||||
elif step_type == "goto":
|
||||
if max_hops <= 0:
|
||||
await _send_text(target, "❌ Превышено число переходов между инструкциями.", keyboard=keyboard)
|
||||
return
|
||||
parsed = _parse_goto(content)
|
||||
if not parsed:
|
||||
await _send_text(target, f"❌ Некорректный goto: {content}", keyboard=keyboard)
|
||||
return
|
||||
next_id, next_index = parsed
|
||||
user_id = target.from_id if hasattr(target, "from_id") else target.user_id
|
||||
log_event(user_id, "terminal_instruction", str(next_id))
|
||||
clear_instruction_progress(target.from_id if hasattr(target, "from_id") else target.user_id)
|
||||
await _send_terminal_from_index(
|
||||
target,
|
||||
next_id,
|
||||
next_index,
|
||||
keyboard=keyboard,
|
||||
max_hops=max_hops - 1,
|
||||
)
|
||||
return
|
||||
|
||||
clear_instruction_progress(target.from_id if hasattr(target, "from_id") else target.user_id)
|
||||
|
||||
|
||||
async def send_terminal_instruction(message, instruction_id: int, keyboard=None):
|
||||
log_event(message.from_id, "terminal_instruction", str(instruction_id))
|
||||
await _send_terminal_from_index(message, instruction_id, 0, keyboard)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# УНИВЕРСАЛЬНЫЙ ХЕЛПЕР (по желанию)
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
async def send_instruction(message, instruction_id: int, keyboard=None):
|
||||
"""
|
||||
Алиас, чтобы использовать одно имя в коде
|
||||
"""
|
||||
await send_terminal_instruction(message, instruction_id, keyboard)
|
||||
|
||||
|
||||
async def handle_pause_response(message) -> bool:
|
||||
if not message.text:
|
||||
return False
|
||||
|
||||
progress = get_instruction_progress(message.from_id)
|
||||
if not progress:
|
||||
return False
|
||||
|
||||
text = message.text.strip().lower()
|
||||
instruction_id, next_step, pause_at_end = progress
|
||||
|
||||
if text in PAUSE_YES:
|
||||
clear_instruction_progress(message.from_id)
|
||||
await message.answer("Отлично, остановил инструкцию.", keyboard=back_to_main())
|
||||
return True
|
||||
|
||||
if text in PAUSE_NO:
|
||||
if pause_at_end:
|
||||
clear_instruction_progress(message.from_id)
|
||||
await message.answer(
|
||||
"Похоже, инструкция не помогла. Заполните, пожалуйста, форму:\n"
|
||||
"https://example.com/google-form",
|
||||
keyboard=back_to_main()
|
||||
)
|
||||
return True
|
||||
|
||||
steps = get_terminal_steps(instruction_id)
|
||||
if next_step >= len(steps):
|
||||
clear_instruction_progress(message.from_id)
|
||||
await message.answer(
|
||||
"Похоже, инструкция не помогла. Заполните, пожалуйста, форму:\n"
|
||||
"https://example.com/google-form",
|
||||
keyboard=back_to_main()
|
||||
)
|
||||
return True
|
||||
|
||||
await _send_terminal_from_index(message, instruction_id, next_step, keyboard=back_to_main())
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def handle_pause_event(event) -> bool:
|
||||
payload = event.get_payload_json() if hasattr(event, "get_payload_json") else None
|
||||
if not payload or payload.get("pause") not in ("yes", "no"):
|
||||
return False
|
||||
|
||||
progress = get_instruction_progress(event.user_id)
|
||||
if not progress:
|
||||
return False
|
||||
|
||||
instruction_id, next_step, pause_at_end = progress
|
||||
|
||||
if payload["pause"] == "yes":
|
||||
clear_instruction_progress(event.user_id)
|
||||
await event.send_message("Отлично, всё работает.", keyboard=back_to_main())
|
||||
return True
|
||||
|
||||
if pause_at_end:
|
||||
clear_instruction_progress(event.user_id)
|
||||
await event.send_message(
|
||||
"Похоже, инструкция не помогла. Заполните, пожалуйста, форму:\n"
|
||||
"https://example.com/google-form",
|
||||
keyboard=back_to_main()
|
||||
)
|
||||
return True
|
||||
|
||||
steps = get_terminal_steps(instruction_id)
|
||||
if next_step >= len(steps):
|
||||
clear_instruction_progress(event.user_id)
|
||||
await event.send_message(
|
||||
"Похоже, инструкция не помогла. Заполните, пожалуйста, форму:\n"
|
||||
"https://example.com/google-form",
|
||||
keyboard=back_to_main()
|
||||
)
|
||||
return True
|
||||
|
||||
await _send_terminal_from_index(
|
||||
event,
|
||||
instruction_id,
|
||||
next_step,
|
||||
keyboard=back_to_main()
|
||||
)
|
||||
return True
|
||||
216
services/log_export.py
Normal file
216
services/log_export.py
Normal file
@@ -0,0 +1,216 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
from config import LOG_SPREADSHEET_ID
|
||||
from database.db import get_connection
|
||||
from services.google import get_google_service
|
||||
|
||||
_IGNORE_MESSAGES = {"начать", "главное меню", "назад"}
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def _format_ts(value: str) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
try:
|
||||
dt = datetime.strptime(value, "%Y-%m-%d %H:%M:%S")
|
||||
return dt.strftime("%d.%m.%Y %H:%M:%S")
|
||||
except ValueError:
|
||||
return value
|
||||
|
||||
|
||||
def log_message(user_id: int, text: str) -> None:
|
||||
if not text:
|
||||
print("LOG message: skipped empty text")
|
||||
return
|
||||
normalized = text.strip().lower()
|
||||
if normalized in _IGNORE_MESSAGES:
|
||||
print(f"LOG message: ignored user_id={user_id} text={normalized!r}")
|
||||
return
|
||||
with get_connection() as conn:
|
||||
cur = conn.cursor()
|
||||
created_at = _now_iso()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO message_log (user_id, created_at, message)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(user_id, created_at, text.strip()),
|
||||
)
|
||||
conn.commit()
|
||||
print(f"LOG message: stored user_id={user_id} at={created_at}")
|
||||
|
||||
|
||||
def log_event(user_id: int, event_type: str, event_value: str) -> None:
|
||||
if not event_type or not event_value:
|
||||
print("LOG event: skipped empty event_type or event_value")
|
||||
return
|
||||
with get_connection() as conn:
|
||||
cur = conn.cursor()
|
||||
created_at = _now_iso()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO event_log (user_id, created_at, event_type, event_value)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(user_id, created_at, event_type, event_value),
|
||||
)
|
||||
conn.commit()
|
||||
print(f"LOG event: stored user_id={user_id} type={event_type} value={event_value}")
|
||||
|
||||
|
||||
def _fetch_chatlog_rows() -> List[List[str]]:
|
||||
print("LOG export: building ChatLog rows from SQLite")
|
||||
with get_connection() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT DISTINCT user_id FROM message_log ORDER BY user_id")
|
||||
users = [row[0] for row in cur.fetchall()]
|
||||
|
||||
user_msgs: Dict[int, List[Tuple[str, str]]] = {}
|
||||
for user_id in users:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT created_at, message
|
||||
FROM message_log
|
||||
WHERE user_id = ?
|
||||
ORDER BY id
|
||||
""",
|
||||
(user_id,),
|
||||
)
|
||||
user_msgs[user_id] = cur.fetchall()
|
||||
|
||||
if not users:
|
||||
print("LOG export: no users in message_log")
|
||||
return [["Время сообщения", "vk.com/id0"]]
|
||||
|
||||
header: List[str] = []
|
||||
for i, user_id in enumerate(users):
|
||||
header.extend(["Время сообщения", f"vk.com/id{user_id}"])
|
||||
if i != len(users) - 1:
|
||||
header.append("")
|
||||
|
||||
max_len = max(len(user_msgs[u]) for u in users)
|
||||
rows = [header]
|
||||
|
||||
for idx in range(max_len):
|
||||
row: List[str] = []
|
||||
for i, user_id in enumerate(users):
|
||||
if idx < len(user_msgs[user_id]):
|
||||
ts, msg = user_msgs[user_id][idx]
|
||||
row.extend([_format_ts(ts), msg])
|
||||
else:
|
||||
row.extend(["", ""])
|
||||
if i != len(users) - 1:
|
||||
row.append("")
|
||||
rows.append(row)
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
def _fetch_freq_rows(days: int = 30, limit: int = 50) -> List[List[str]]:
|
||||
print(f"LOG export: building FreqMsg rows for last {days} days")
|
||||
since = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
with get_connection() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT message, COUNT(*)
|
||||
FROM message_log
|
||||
WHERE created_at >= ?
|
||||
GROUP BY message
|
||||
ORDER BY COUNT(*) DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(since, limit),
|
||||
)
|
||||
messages = [row[0] for row in cur.fetchall()]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT event_value, COUNT(*)
|
||||
FROM event_log
|
||||
WHERE event_type = 'pinpad_error' AND created_at >= ?
|
||||
GROUP BY event_value
|
||||
""",
|
||||
(since,),
|
||||
)
|
||||
pinpad_counts = {row[0]: row[1] for row in cur.fetchall()}
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT event_value, COUNT(*)
|
||||
FROM event_log
|
||||
WHERE event_type = 'terminal_instruction' AND created_at >= ?
|
||||
GROUP BY event_value
|
||||
""",
|
||||
(since,),
|
||||
)
|
||||
terminal_counts = {row[0]: row[1] for row in cur.fetchall()}
|
||||
|
||||
cur.execute("SELECT code FROM pinpad_errors ORDER BY code")
|
||||
pinpad_codes = [str(row[0]) for row in cur.fetchall()]
|
||||
|
||||
cur.execute("SELECT id, title FROM terminal_instructions ORDER BY id")
|
||||
terminal_items = [(str(row[0]), row[1]) for row in cur.fetchall()]
|
||||
|
||||
errors_with_counts: List[Tuple[str, int]] = []
|
||||
for code in pinpad_codes:
|
||||
count = int(pinpad_counts.get(code, 0))
|
||||
if count > 0:
|
||||
errors_with_counts.append((f"PinPad {code}", count))
|
||||
|
||||
for instr_id, title in terminal_items:
|
||||
count = int(terminal_counts.get(instr_id, 0))
|
||||
if count > 0:
|
||||
label = title or f"Terminal {instr_id}"
|
||||
errors_with_counts.append((label, count))
|
||||
|
||||
errors_with_counts.sort(key=lambda x: (-x[1], x[0].lower()))
|
||||
errors = [label for label, _count in errors_with_counts]
|
||||
|
||||
max_len = max(len(messages), len(errors), 1)
|
||||
rows = [["Частые сообщения", "Частые ошибки"]]
|
||||
for i in range(max_len):
|
||||
msg = messages[i] if i < len(messages) else ""
|
||||
err = errors[i] if i < len(errors) else ""
|
||||
rows.append([msg, err])
|
||||
return rows
|
||||
|
||||
|
||||
def export_chatlog_and_freq() -> None:
|
||||
print("LOG export: start")
|
||||
service = get_google_service()
|
||||
|
||||
chat_rows = _fetch_chatlog_rows()
|
||||
print(f"LOG export: writing ChatLog rows={len(chat_rows)}")
|
||||
service.spreadsheets().values().clear(
|
||||
spreadsheetId=LOG_SPREADSHEET_ID,
|
||||
range="ChatLog",
|
||||
body={},
|
||||
).execute()
|
||||
service.spreadsheets().values().update(
|
||||
spreadsheetId=LOG_SPREADSHEET_ID,
|
||||
range="ChatLog!A1",
|
||||
valueInputOption="RAW",
|
||||
body={"values": chat_rows},
|
||||
).execute()
|
||||
|
||||
freq_rows = _fetch_freq_rows()
|
||||
print(f"LOG export: writing FreqMsg rows={len(freq_rows)}")
|
||||
service.spreadsheets().values().clear(
|
||||
spreadsheetId=LOG_SPREADSHEET_ID,
|
||||
range="FreqMsg",
|
||||
body={},
|
||||
).execute()
|
||||
service.spreadsheets().values().update(
|
||||
spreadsheetId=LOG_SPREADSHEET_ID,
|
||||
range="FreqMsg!A1",
|
||||
valueInputOption="RAW",
|
||||
body={"values": freq_rows},
|
||||
).execute()
|
||||
print("LOG export: done")
|
||||
10
services/recognizer.py
Normal file
10
services/recognizer.py
Normal file
@@ -0,0 +1,10 @@
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def recognize_pinpad_code(text: str) -> Optional[int]:
|
||||
if not text:
|
||||
return None
|
||||
|
||||
match = re.search(r"\b\d{3}\b", text)
|
||||
return int(match.group()) if match else None
|
||||
386
services/sync_google.py
Normal file
386
services/sync_google.py
Normal file
@@ -0,0 +1,386 @@
|
||||
import httplib2
|
||||
import googleapiclient.discovery
|
||||
from oauth2client.service_account import ServiceAccountCredentials
|
||||
|
||||
from config import CREDENTIALS_FILE, SPREADSHEET_ID
|
||||
from database.db import get_connection
|
||||
|
||||
SCOPES = [
|
||||
"https://www.googleapis.com/auth/spreadsheets",
|
||||
"https://www.googleapis.com/auth/drive",
|
||||
]
|
||||
|
||||
|
||||
def get_google_service():
|
||||
credentials = ServiceAccountCredentials.from_json_keyfile_name(
|
||||
CREDENTIALS_FILE, SCOPES
|
||||
)
|
||||
http = credentials.authorize(httplib2.Http())
|
||||
return googleapiclient.discovery.build(
|
||||
"sheets", "v4", http=http, cache_discovery=False
|
||||
)
|
||||
|
||||
|
||||
def _fetch_rows(service, sheet_range: str):
|
||||
return service.spreadsheets().values().get(
|
||||
spreadsheetId=SPREADSHEET_ID,
|
||||
range=sheet_range,
|
||||
majorDimension="ROWS",
|
||||
).execute().get("values", [])
|
||||
|
||||
|
||||
def _require_columns(row, count: int, sheet_name: str, idx: int):
|
||||
if len(row) < count:
|
||||
raise ValueError(f"{sheet_name} row {idx}: expected {count} columns, got {len(row)}")
|
||||
|
||||
|
||||
def _require_non_empty(value: str, sheet_name: str, idx: int, column_name: str) -> str:
|
||||
value = (value or "").strip()
|
||||
if not value:
|
||||
raise ValueError(f"{sheet_name} row {idx}: {column_name} is empty")
|
||||
return value
|
||||
|
||||
|
||||
def _is_empty_row(row) -> bool:
|
||||
return not row or not any((value or "").strip() for value in row)
|
||||
|
||||
|
||||
def _validate_pinpad_errors(rows):
|
||||
if not rows:
|
||||
raise ValueError("PinPad: table is empty")
|
||||
|
||||
parsed = []
|
||||
seen_codes = set()
|
||||
for idx, row in enumerate(rows, start=2):
|
||||
_require_columns(row, 3, "PinPad", idx)
|
||||
code = int(_require_non_empty(row[0], "PinPad", idx, "code"))
|
||||
if code in seen_codes:
|
||||
raise ValueError(f"PinPad row {idx}: duplicate code {code}")
|
||||
seen_codes.add(code)
|
||||
parsed.append(
|
||||
(
|
||||
code,
|
||||
_require_non_empty(row[1], "PinPad", idx, "reason"),
|
||||
_require_non_empty(row[2], "PinPad", idx, "action"),
|
||||
)
|
||||
)
|
||||
return parsed
|
||||
|
||||
|
||||
def _validate_terminal(rows, key_rows, step_rows):
|
||||
if not rows:
|
||||
raise ValueError("Terminal_Instructions: table is empty")
|
||||
if not key_rows:
|
||||
raise ValueError("Terminal_Keys: table is empty")
|
||||
if not step_rows:
|
||||
raise ValueError("Terminal_Steps: table is empty")
|
||||
|
||||
instructions = []
|
||||
instruction_ids = set()
|
||||
for idx, row in enumerate(rows, start=2):
|
||||
_require_columns(row, 2, "Terminal_Instructions", idx)
|
||||
instruction_id = int(_require_non_empty(row[0], "Terminal_Instructions", idx, "id"))
|
||||
if instruction_id in instruction_ids:
|
||||
raise ValueError(f"Terminal_Instructions row {idx}: duplicate id {instruction_id}")
|
||||
instruction_ids.add(instruction_id)
|
||||
instructions.append(
|
||||
(
|
||||
instruction_id,
|
||||
_require_non_empty(row[1], "Terminal_Instructions", idx, "title"),
|
||||
)
|
||||
)
|
||||
|
||||
keys = []
|
||||
for idx, row in enumerate(key_rows, start=2):
|
||||
_require_columns(row, 3, "Terminal_Keys", idx)
|
||||
instruction_id = int(_require_non_empty(row[0], "Terminal_Keys", idx, "instruction_id"))
|
||||
if instruction_id not in instruction_ids:
|
||||
raise ValueError(f"Terminal_Keys row {idx}: unknown instruction_id {instruction_id}")
|
||||
key_type = _require_non_empty(row[2], "Terminal_Keys", idx, "key_type").lower()
|
||||
if key_type not in ("code", "text"):
|
||||
raise ValueError(f"Terminal_Keys row {idx}: key_type must be code or text")
|
||||
keys.append(
|
||||
(
|
||||
instruction_id,
|
||||
_require_non_empty(row[1], "Terminal_Keys", idx, "key").lower(),
|
||||
key_type,
|
||||
)
|
||||
)
|
||||
|
||||
steps = []
|
||||
for idx, row in enumerate(step_rows, start=2):
|
||||
if _is_empty_row(row):
|
||||
continue
|
||||
if len(row) < 3:
|
||||
print(f"WARN Terminal_Steps row {idx}: skipped incomplete row")
|
||||
continue
|
||||
instruction_id = int(_require_non_empty(row[0], "Terminal_Steps", idx, "instruction_id"))
|
||||
if instruction_id not in instruction_ids:
|
||||
raise ValueError(f"Terminal_Steps row {idx}: unknown instruction_id {instruction_id}")
|
||||
step_type = _require_non_empty(row[2], "Terminal_Steps", idx, "type").lower()
|
||||
if step_type not in ("text", "image", "pause", "goto"):
|
||||
raise ValueError(f"Terminal_Steps row {idx}: type must be text, image, pause or goto")
|
||||
content = (row[3] if len(row) > 3 else "").strip()
|
||||
if step_type != "pause" and not content:
|
||||
print(f"WARN Terminal_Steps row {idx}: skipped {step_type} step with empty content")
|
||||
continue
|
||||
steps.append(
|
||||
(
|
||||
instruction_id,
|
||||
int(_require_non_empty(row[1], "Terminal_Steps", idx, "step_order")),
|
||||
step_type,
|
||||
content,
|
||||
)
|
||||
)
|
||||
|
||||
if not steps:
|
||||
raise ValueError("Terminal_Steps: no valid steps found")
|
||||
|
||||
return instructions, keys, steps
|
||||
|
||||
|
||||
def _validate_tech_problems(rows):
|
||||
if not rows:
|
||||
raise ValueError("TechProblems: table is empty")
|
||||
|
||||
problems = []
|
||||
problem_ids = set()
|
||||
for idx, row in enumerate(rows, start=2):
|
||||
_require_columns(row, 3, "TechProblems", idx)
|
||||
problem_id = _require_non_empty(row[0], "TechProblems", idx, "id")
|
||||
if problem_id in problem_ids:
|
||||
raise ValueError(f"TechProblems row {idx}: duplicate id {problem_id}")
|
||||
task_type = _require_non_empty(row[1], "TechProblems", idx, "task_type").upper()
|
||||
if task_type not in ("ADMIN", "TECH"):
|
||||
raise ValueError(f"TechProblems row {idx}: task_type must be ADMIN or TECH")
|
||||
problem_ids.add(problem_id)
|
||||
problems.append(
|
||||
(
|
||||
problem_id,
|
||||
task_type,
|
||||
_require_non_empty(row[2], "TechProblems", idx, "keywords"),
|
||||
)
|
||||
)
|
||||
return problems, problem_ids
|
||||
|
||||
|
||||
def _validate_tech_solutions(rows, problem_ids):
|
||||
if not rows:
|
||||
raise ValueError("TechProblems_Solution: table is empty")
|
||||
|
||||
solutions = []
|
||||
seen_ids = set()
|
||||
for idx, row in enumerate(rows, start=2):
|
||||
_require_columns(row, 8, "TechProblems_Solution", idx)
|
||||
problem_id = _require_non_empty(row[0], "TechProblems_Solution", idx, "problem_id")
|
||||
if problem_id in seen_ids:
|
||||
raise ValueError(f"TechProblems_Solution row {idx}: duplicate problem_id {problem_id}")
|
||||
if problem_id not in problem_ids:
|
||||
raise ValueError(f"TechProblems_Solution row {idx}: unknown problem_id {problem_id}")
|
||||
task_type = _require_non_empty(row[2], "TechProblems_Solution", idx, "task_type").upper()
|
||||
can_fix_self = _require_non_empty(row[3], "TechProblems_Solution", idx, "can_fix_self").upper()
|
||||
need_result_feedback = _require_non_empty(row[4], "TechProblems_Solution", idx, "need_result_feedback").upper()
|
||||
if task_type not in ("ADMIN", "TECH"):
|
||||
raise ValueError(f"TechProblems_Solution row {idx}: task_type must be ADMIN or TECH")
|
||||
if can_fix_self not in ("YES", "NO"):
|
||||
raise ValueError(f"TechProblems_Solution row {idx}: can_fix_self must be YES or NO")
|
||||
if need_result_feedback not in ("YES", "NO"):
|
||||
raise ValueError(f"TechProblems_Solution row {idx}: need_result_feedback must be YES or NO")
|
||||
seen_ids.add(problem_id)
|
||||
solutions.append(
|
||||
(
|
||||
problem_id,
|
||||
_require_non_empty(row[1], "TechProblems_Solution", idx, "problem_name"),
|
||||
task_type,
|
||||
can_fix_self,
|
||||
need_result_feedback,
|
||||
_require_non_empty(row[5], "TechProblems_Solution", idx, "solution_steps"),
|
||||
(row[6] or "").strip(),
|
||||
(row[7] or "").strip(),
|
||||
)
|
||||
)
|
||||
return solutions
|
||||
|
||||
|
||||
def _write_sync_data(pinpad_errors, terminal_data, tech_problems, tech_solutions):
|
||||
instructions, keys, steps = terminal_data
|
||||
with get_connection() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute("BEGIN")
|
||||
|
||||
cur.execute("DELETE FROM terminal_instruction_steps")
|
||||
cur.execute("DELETE FROM terminal_instruction_keys")
|
||||
cur.execute("DELETE FROM terminal_instructions")
|
||||
cur.execute("DELETE FROM pinpad_errors")
|
||||
cur.execute("DELETE FROM tech_problem_solutions")
|
||||
cur.execute("DELETE FROM tech_problems")
|
||||
|
||||
cur.executemany(
|
||||
"""
|
||||
INSERT INTO pinpad_errors (code, reason, action)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
pinpad_errors,
|
||||
)
|
||||
cur.executemany(
|
||||
"INSERT INTO terminal_instructions (id, title) VALUES (?, ?)",
|
||||
instructions,
|
||||
)
|
||||
cur.executemany(
|
||||
"""
|
||||
INSERT INTO terminal_instruction_keys
|
||||
(instruction_id, key, key_type)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
keys,
|
||||
)
|
||||
cur.executemany(
|
||||
"""
|
||||
INSERT INTO terminal_instruction_steps
|
||||
(instruction_id, step_order, type, content)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
steps,
|
||||
)
|
||||
cur.executemany(
|
||||
"""
|
||||
INSERT INTO tech_problems (id, task_type, keywords)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
tech_problems,
|
||||
)
|
||||
cur.executemany(
|
||||
"""
|
||||
INSERT INTO tech_problem_solutions
|
||||
(problem_id, problem_name, task_type, can_fix_self,
|
||||
need_result_feedback, solution_steps, tools_needed,
|
||||
when_stop_and_report)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
tech_solutions,
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def sync_pinpad_errors(service):
|
||||
rows = _fetch_rows(service, "PinPad!A2:C1000")
|
||||
pinpad_errors = _validate_pinpad_errors(rows)
|
||||
with get_connection() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute("DELETE FROM pinpad_errors")
|
||||
cur.executemany(
|
||||
"""
|
||||
INSERT INTO pinpad_errors (code, reason, action)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
pinpad_errors,
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def sync_terminal(service):
|
||||
terminal_data = _validate_terminal(
|
||||
_fetch_rows(service, "Terminal_Instructions!A2:B1000"),
|
||||
_fetch_rows(service, "Terminal_Keys!A2:C2000"),
|
||||
_fetch_rows(service, "Terminal_Steps!A2:D3000"),
|
||||
)
|
||||
instructions, keys, steps = terminal_data
|
||||
with get_connection() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute("DELETE FROM terminal_instruction_steps")
|
||||
cur.execute("DELETE FROM terminal_instruction_keys")
|
||||
cur.execute("DELETE FROM terminal_instructions")
|
||||
cur.executemany(
|
||||
"INSERT INTO terminal_instructions (id, title) VALUES (?, ?)",
|
||||
instructions,
|
||||
)
|
||||
cur.executemany(
|
||||
"""
|
||||
INSERT INTO terminal_instruction_keys
|
||||
(instruction_id, key, key_type)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
keys,
|
||||
)
|
||||
cur.executemany(
|
||||
"""
|
||||
INSERT INTO terminal_instruction_steps
|
||||
(instruction_id, step_order, type, content)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
steps,
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def sync_tech_problems(service):
|
||||
tech_problems, _problem_ids = _validate_tech_problems(
|
||||
_fetch_rows(service, "TechProblems!A2:C2000")
|
||||
)
|
||||
with get_connection() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute("DELETE FROM tech_problem_solutions")
|
||||
cur.execute("DELETE FROM tech_problems")
|
||||
cur.executemany(
|
||||
"""
|
||||
INSERT INTO tech_problems (id, task_type, keywords)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
tech_problems,
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def sync_tech_problem_solutions(service):
|
||||
tech_problems, problem_ids = _validate_tech_problems(
|
||||
_fetch_rows(service, "TechProblems!A2:C2000")
|
||||
)
|
||||
tech_solutions = _validate_tech_solutions(
|
||||
_fetch_rows(service, "TechProblems_Solution!A2:H2000"),
|
||||
problem_ids,
|
||||
)
|
||||
with get_connection() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute("DELETE FROM tech_problem_solutions")
|
||||
cur.execute("DELETE FROM tech_problems")
|
||||
cur.executemany(
|
||||
"""
|
||||
INSERT INTO tech_problems (id, task_type, keywords)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
tech_problems,
|
||||
)
|
||||
cur.executemany(
|
||||
"""
|
||||
INSERT INTO tech_problem_solutions
|
||||
(problem_id, problem_name, task_type, can_fix_self,
|
||||
need_result_feedback, solution_steps, tools_needed,
|
||||
when_stop_and_report)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
tech_solutions,
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def sync_all():
|
||||
service = get_google_service()
|
||||
print("SYNC fetch data")
|
||||
|
||||
pinpad_errors = _validate_pinpad_errors(_fetch_rows(service, "PinPad!A2:C1000"))
|
||||
terminal_data = _validate_terminal(
|
||||
_fetch_rows(service, "Terminal_Instructions!A2:B1000"),
|
||||
_fetch_rows(service, "Terminal_Keys!A2:C2000"),
|
||||
_fetch_rows(service, "Terminal_Steps!A2:D3000"),
|
||||
)
|
||||
tech_problems, problem_ids = _validate_tech_problems(
|
||||
_fetch_rows(service, "TechProblems!A2:C2000")
|
||||
)
|
||||
tech_solutions = _validate_tech_solutions(
|
||||
_fetch_rows(service, "TechProblems_Solution!A2:H2000"),
|
||||
problem_ids,
|
||||
)
|
||||
|
||||
print("SYNC write data")
|
||||
_write_sync_data(pinpad_errors, terminal_data, tech_problems, tech_solutions)
|
||||
print("SYNC done")
|
||||
Reference in New Issue
Block a user