diff --git a/SpreadsheetsService/app.py b/SpreadsheetsService/app.py index 4e51990..64b7c90 100644 --- a/SpreadsheetsService/app.py +++ b/SpreadsheetsService/app.py @@ -4,8 +4,7 @@ from werkzeug.exceptions import * from manager import * import flask import logging -import models -import time +import uuid logging.basicConfig() LOG = logging.getLogger(__name__) @@ -41,11 +40,13 @@ def availability(func): def superuser(func): @wraps(func) def wrapper(*args, **kwargs): - tg_id = flask.request.args.get("tg_id", -1) - student: Student = app.db.get_student_by_tg_id(tg_id) - if student.number not in SUPER_USER: - raise Forbidden(f"Доступ запрещен для студента {student.name}") - result = func(student, *args, **kwargs) + god_mode = flask.request.args.get("god_mode", False) + if god_mode.lower() != "true": + tg_id = flask.request.args.get("tg_id", -1) + student: Student = app.db.get_student_by_tg_id(tg_id) + if student.number not in SUPER_USER: + raise Forbidden(f"Доступ запрещен для студента {student.name}") + result = func(*args, **kwargs) return result return wrapper @@ -82,12 +83,30 @@ def grades(): ) -@app.route("/broadcast") +@app.route("/grades/fingerprint") +@availability +def fingerprint(): + """Возвращает оценки в виде хеш-суммы.""" + tg_id = flask.request.args.get("tg_id") + student = app.db.get_student_by_tg_id(tg_id) + app.db.set_assignments_for_student(student) + return flask.jsonify( + dict( + fingerprint=uuid.uuid3(uuid.NAMESPACE_DNS, "%s" % student.assignments), + ) + ) + + +@app.route("/students") @availability @superuser -def broadcast(student): +def broadcast(*args, **kwargs): """Выполняет рассылку студентам.""" - return flask.jsonify(app.db.get_all_groups_only_sub_students()) + sub_only = flask.request.args.get("sub_only", False) + if sub_only.lower() == "true": + return flask.jsonify(app.db.get_all_groups_only_sub_students()) + else: + return flask.jsonify(app.db.get_all_groups()) @app.route("/sub") @@ -97,6 +116,7 @@ def sub(): is_subbed = app.db.sub_by_tg_id(tg_id) return flask.jsonify(dict(is_subbed=is_subbed)) + @app.route("/unsub") @availability def unsub(): @@ -105,10 +125,9 @@ def unsub(): return flask.jsonify(dict(is_unsubbed=is_unsubbed)) - @app.errorhandler(StudentAlreadyAuthed) def show_error(err): - return flask.jsonify({"error": type(err).__name__, "message": err.message}), 409 + return flask.jsonify({"error": type(err).__name__, "message": err.message}), 400 @app.errorhandler(AnotherStudentAlreadyAuthed) @@ -120,13 +139,16 @@ def show_error(err): def show_error(err): return flask.jsonify({"error": type(err).__name__, "message": err.message}), 404 + @app.errorhandler(StudentAlreadySubbed) def show_error(err): - return flask.jsonify({"error": type(err).__name__, "message": err.message}), 405 + return flask.jsonify({"error": type(err).__name__, "message": err.message}), 400 + @app.errorhandler(StudentAlreadyUnsubbed) def show_error(err): - return flask.jsonify({"error": type(err).__name__, "message": err.message}), 406 + return flask.jsonify({"error": type(err).__name__, "message": err.message}), 400 + @app.errorhandler(BadRequest) def show_error(err): diff --git a/SpreadsheetsService/manager/manager.py b/SpreadsheetsService/manager/manager.py index ca6e3fd..11b0069 100644 --- a/SpreadsheetsService/manager/manager.py +++ b/SpreadsheetsService/manager/manager.py @@ -31,6 +31,7 @@ ASSIGNMENT_HOW_TO_DISPLAY = 7 ASSIGNMENT_NOTES_RANGES = 8 TOKEN_FILE = pathlib.Path() / ".secrets" / "token.json" CLIENT_SECRET_FILE = pathlib.Path() / ".secrets" / "client_secret.json" +NON_CACHED_RANGES = ("StudentList",) def timed_lru_cache(seconds: int, maxsize: int = 128): @@ -44,7 +45,9 @@ def timed_lru_cache(seconds: int, maxsize: int = 128): if datetime.utcnow() >= func.expiration: func.cache_clear() func.expiration = datetime.utcnow() + func.lifetime - + # if args[1] in NON_CACHED_RANGES: + # func.cache_clear() + print(*args) return func(*args, **kwargs) return wrapped_func @@ -63,16 +66,19 @@ class AnotherStudentAlreadyAuthed(Exception): self.message = message super().__init__(self.message) + class StudentAlreadySubbed(Exception): def __init__(self, message): self.message = message super().__init__(self.message) + class StudentAlreadyUnsubbed(Exception): def __init__(self, message): self.message = message super().__init__(self.message) + class Manager: """Позволяет выполнять операции с Google таблицами.""" @@ -99,7 +105,6 @@ class Manager: if creds and creds.expired and creds.refresh_token: creds.refresh(Request()) else: - print(CLIENT_SECRET_FILE) flow = InstalledAppFlow.from_client_secrets_file( str(CLIENT_SECRET_FILE), scopes ) @@ -120,13 +125,11 @@ class Manager: Returns: list of rows [[val1, val2, ...], [], ...] """ - result = ( self.sheet.values() .get(spreadsheetId=self.spreadsheet_id, range=range_name) .execute() ) - return result.get("values", []) def write_values(self, values: list, range_name: str): @@ -176,7 +179,7 @@ class Manager: name=row[STUDENT_NAMES], group_id=row[GROUP_IDS], tg_id=row[STUDENT_TELEGRAM_IDS], - subjects=row[STUDENT_SUBJECTS].split(','), + subjects=row[STUDENT_SUBJECTS].split(","), is_subbed=bool(int(row[STUDENT_SUBSCRIPTION])), ) ) @@ -187,8 +190,8 @@ class Manager: """Получить список всех групп.""" values = self.get_values() groups = defaultdict(lambda: Group("0")) - f = lambda row: row[STUDENT_SUBSCRIPTION] == '1' - for row in filter(f,values): + f = lambda row: row[STUDENT_SUBSCRIPTION] == "1" + for row in filter(f, values): groups[row[GROUP_IDS]].group_id = row[GROUP_IDS] groups[row[GROUP_IDS]].add_student( Student( @@ -196,7 +199,7 @@ class Manager: name=row[STUDENT_NAMES], group_id=row[GROUP_IDS], tg_id=row[STUDENT_TELEGRAM_IDS], - subjects=row[STUDENT_SUBJECTS].split(','), + subjects=row[STUDENT_SUBJECTS].split(","), is_subbed=bool(int(row[STUDENT_SUBSCRIPTION])), ) ) @@ -212,10 +215,11 @@ class Manager: try: row = next(filter(f, values)) return Student( - int(row[STUDENT_NUMBERS]), name, - row[GROUP_IDS], - row[STUDENT_TELEGRAM_IDS], - row[STUDENT_SUBJECTS].split(',') + int(row[STUDENT_NUMBERS]), + name, + row[GROUP_IDS], + row[STUDENT_TELEGRAM_IDS], + row[STUDENT_SUBJECTS].split(","), ) except StopIteration: raise StudentNotFound("Не могу найти студента по имени") @@ -229,10 +233,11 @@ class Manager: try: row = next(filter(f, values)) return Student( - int(row[STUDENT_NUMBERS]), - row[STUDENT_NAMES], - row[GROUP_IDS], tg_id, - row[STUDENT_SUBJECTS].split(',') + int(row[STUDENT_NUMBERS]), + row[STUDENT_NAMES], + row[GROUP_IDS], + tg_id, + row[STUDENT_SUBJECTS].split(","), ) except StopIteration: raise StudentNotFound("Не могу найти студента по telegram id") @@ -246,22 +251,22 @@ class Manager: for row in filter(f, values): assignment_values = self.get_values(row[ASSIGNMENT_RANGES]) toFloat = lambda x: float(x.replace(",", ".")) - assignment_value = list(map(toFloat, assignment_values[student.number-1])) - notes_range, n_row = row[ASSIGNMENT_NOTES_RANGES].split(',') - note = self.get_values(notes_range)[student.number-1][int(n_row)] - if note == '-': + assignment_value = list(map(toFloat, assignment_values[student.number - 1])) + notes_range, n_row = row[ASSIGNMENT_NOTES_RANGES].split(",") + note = self.get_values(notes_range)[student.number - 1][int(n_row)] + if note == "-": note = "Замечаний по работе нет." else: note = f"Замечания:\n {note}" student.add_assignment( Assignment( - name=row[ASSIGNMENT_NAMES], - points=assignment_value, + name=row[ASSIGNMENT_NAMES], + points=assignment_value, weight=toFloat(row[ASSIGNMENT_WEIGHTS]), subject=row[ASSIGNMENT_SUBJECT], allow_to_display=bool(int(row[ASSIGNMENT_ALLOWS])), how_to_display=row[ASSIGNMENT_HOW_TO_DISPLAY], - notes=note + notes=note, ) ) @@ -282,15 +287,15 @@ class Manager: row[STUDENT_TELEGRAM_IDS] = tg_id self.write_values(values, "StudentList") return Student( - int(row[STUDENT_NUMBERS]), - row[STUDENT_NAMES], - row[GROUP_IDS], tg_id, - row[STUDENT_SUBJECTS].split(',') + int(row[STUDENT_NUMBERS]), + row[STUDENT_NAMES], + row[GROUP_IDS], + tg_id, + row[STUDENT_SUBJECTS].split(","), ) except StopIteration: raise StudentNotFound("Не могу найти студента по token") - def sub_by_tg_id(self, tg_id: str) -> bool: """Подписка пользователя по tg_id""" @@ -299,7 +304,7 @@ class Manager: f = lambda row: tg_id == row[STUDENT_TELEGRAM_IDS] try: row = next(filter(f, values)) - if row[STUDENT_SUBSCRIPTION] == '1': + if row[STUDENT_SUBSCRIPTION] == "1": raise StudentAlreadySubbed("Студент уже подписался") row[STUDENT_SUBSCRIPTION] = 1 @@ -316,10 +321,10 @@ class Manager: f = lambda row: tg_id == row[STUDENT_TELEGRAM_IDS] try: row = next(filter(f, values)) - if row[STUDENT_SUBSCRIPTION] == '0': + if row[STUDENT_SUBSCRIPTION] == "0": raise StudentAlreadyUnsubbed( "Студент уже не подписан (или никогда не был)" - ) + ) row[STUDENT_SUBSCRIPTION] = 0 self.write_values(values, "StudentList") diff --git a/TelegramBot/bot.py b/TelegramBot/bot.py index 706414b..d9db6d2 100644 --- a/TelegramBot/bot.py +++ b/TelegramBot/bot.py @@ -1,7 +1,9 @@ +from collections import defaultdict from pathlib import Path import logging import requests import time +import datetime import telegram from telegram import ( @@ -19,17 +21,19 @@ from telegram.ext import ( ) from telegram.ext.callbackqueryhandler import CallbackQueryHandler -from utils import TgLogger +# from utils import TgLogger from utils.inline_keyboard import * from messages import Messages TOKEN = Path(".secrets/bot_token.txt").read_text() MESSAGES = Messages() HOST = "http://carolyn-spreadsheets:5000" +SUB_TIME_DELTA = datetime.timedelta(minutes=15) + BROADCAST_MESSAGE, BROADCAST_PUBLISH_DONE = range(2) GRADES_ASSNT, GRADES_VIEW = range(2) -logging.setLoggerClass(TgLogger) +# logging.setLoggerClass(TgLogger) logging.basicConfig( format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO ) @@ -176,12 +180,13 @@ def grades_view(update: Update, context: CallbackContext): return GRADES_ASSNT + def broadcast(update: Update, context: CallbackContext): """Команда: разослать студентам.""" access_message = update.message.reply_text("🔐 Проверка доступа...") user_id = update.message.from_user.id try: - r = requests.get(f"{HOST}/broadcast", params={"tg_id": user_id}) + r = requests.get(f"{HOST}/students", params={"tg_id": user_id}) assert r.status_code == 200, "Доступ запрещен к /broadcast" data = r.json() context.bot_data["groups"] = data @@ -250,9 +255,7 @@ def sub(update: Update, context: CallbackContext): username = update.message.from_user.username # Длинная операция, сообщим о запущенном процессе. - update.message.reply_text(text=MESSAGES.Sub.START) - - LOG.info(f"Subcripting {user_id}") + msg = update.message.reply_text(text=MESSAGES.Sub.START) try: req = requests.get( @@ -272,7 +275,8 @@ def sub(update: Update, context: CallbackContext): update.message.reply_sticker(MESSAGES.Stickers.bad()) return - update.message.reply_text(text=MESSAGES.Sub.SUBBED) + msg.edit_text(text=MESSAGES.Sub.SUBBED) + def unsub(update: Update, context: CallbackContext): """Стартовое сообщение (к команде /start)""" @@ -281,9 +285,9 @@ def unsub(update: Update, context: CallbackContext): username = update.message.from_user.username # Длинная операция, сообщим о запущенном процессе. - update.message.reply_text(text=MESSAGES.Unsub.START) + msg = update.message.reply_text(text=MESSAGES.Unsub.START) - LOG.info(f"Unsubcripting {user_id}") + # LOG.info(f"Unsubcripting {user_id}") try: req = requests.get( @@ -303,7 +307,54 @@ def unsub(update: Update, context: CallbackContext): update.message.reply_sticker(MESSAGES.Stickers.bad()) return - update.message.reply_text(text=MESSAGES.Unsub.UNSUBBED) + msg.edit_text(text=MESSAGES.Unsub.UNSUBBED) + + +# ======================================================================= SCHED + + +def grades_checker(context: CallbackContext): + """Задача по проверке оценок. + + Пробегает по списку подписавшихся и для каждого студента запрашивает + `/grades/fingerprint`. Отпечаток (fingerprint) - текстовая строка + захешированных оценок. При изменении оценок, меняется и их хеш. Бот с каждым + вызовом задачи запоминает эти хеши всех студентов и сверяет их. Если вдруг + несовпадение, то значит, что данные об оценках были изменены. + + Это простой и универсальный способ смотреть изменения, но он не дает инфы о + том, в каком задании появилась новая оценка. + + """ + + r = requests.get(f"{HOST}/students", params={"god_mode": True, "sub_only": True}) + data = r.json() + context.bot_data["groups"] = data + + # При инициализации бота словарь отсутствует + if "fingerprints" not in context.bot_data: + context.bot_data["fingerprints"] = defaultdict(str) + + for group in data.values(): + for student in group["students"]: + tg_id = student["tg_id"] + r = requests.get(f"{HOST}/grades/fingerprint", params={"tg_id": tg_id}) + fingerprint = r.json().get("fingerprint", "") + old_fingerprint = context.bot_data["fingerprints"][tg_id] + # При инициализации у старого отпечатка ещё не установлено значение. + # Там находится пустая строка, поэтому нужна дополн. проверка. + if old_fingerprint != fingerprint and old_fingerprint != "": + context.bot.send_message( + chat_id=tg_id, + text=MESSAGES.Sub.GRADES_CHANGED, + ) + LOG.info( + "Fingerprint = %s, old = %s", + fingerprint, + context.bot_data["fingerprints"][tg_id], + ) + context.bot_data["fingerprints"][tg_id] = fingerprint + # ==================================================================== HANDLERS @@ -311,6 +362,8 @@ def unsub(update: Update, context: CallbackContext): def main() -> None: updater = Updater(token=TOKEN, use_context=True) dispatcher = updater.dispatcher + jobber = updater.job_queue + jobber.run_repeating(grades_checker, SUB_TIME_DELTA) LOG.bot = updater.bot start_handler = CommandHandler("start", start) diff --git a/TelegramBot/messages/messages.py b/TelegramBot/messages/messages.py index 451c6aa..6992d3e 100644 --- a/TelegramBot/messages/messages.py +++ b/TelegramBot/messages/messages.py @@ -22,7 +22,7 @@ class Messages: return cases.get(err, "😟 Возникли неполадки") class Assignments: - START = "👀 Посмотрим (~3с) ..." + START = "👀 Посмотрим (~5с) ..." SELECT_COURSE = "👇 Выбери дисциплину" SELECT_ASSNT = "👇 Выбери работу" END = "😉 Всего наилучшего" @@ -58,12 +58,15 @@ class Messages: class Sub: START = "📜✍ Оформляю подписку на уведомления..." SUBBED = "👌 Подписка на уведомления оформлена" + GRADES_CHANGED = ( + "💌 Твои оценки изменились! Вызови команду /grades, чтобы узнать!" + ) @staticmethod def failure(err: str): cases = { "StudentNotFound": "⛔ Для подписки нужно зарегистрироваться." - + "Команда /start поможет с регистрацией", + + "Команда /start поможет с регистрацией", "StudentAlreadySubbed": "💁‍♀️ Подписка уже оформлена ранее", } return cases.get(err, "😟 Возникли неполадки") @@ -75,8 +78,8 @@ class Messages: @staticmethod def failure(err: str): cases = { - "StudentNotFound": "⛔ Для отпидски от уведомлений нужно зарегистрироваться." - + "Команда /start поможет с регистрацией", + "StudentNotFound": "⛔ Для отпидски от уведомлений нужно зарегистрироваться." + + "Команда /start поможет с регистрацией", "StudentAlreadyUnsubbed": "💁‍♀️ Подписка на уведомления не была оформлена или уже отменена", } return cases.get(err, "😟 Возникли неполадки")