- добавлен новый узел /grades/fingerprint

- в планировщик добавлен джоб, который проверяет не изменились ли оценки студентов
This commit is contained in:
serafim.urukov 2021-09-09 00:33:46 +03:00
parent 9a89cde748
commit 2d975df4b1
4 changed files with 142 additions and 59 deletions

View File

@ -4,8 +4,7 @@ from werkzeug.exceptions import *
from manager import * from manager import *
import flask import flask
import logging import logging
import models import uuid
import time
logging.basicConfig() logging.basicConfig()
LOG = logging.getLogger(__name__) LOG = logging.getLogger(__name__)
@ -41,11 +40,13 @@ def availability(func):
def superuser(func): def superuser(func):
@wraps(func) @wraps(func)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
tg_id = flask.request.args.get("tg_id", -1) god_mode = flask.request.args.get("god_mode", False)
student: Student = app.db.get_student_by_tg_id(tg_id) if god_mode.lower() != "true":
if student.number not in SUPER_USER: tg_id = flask.request.args.get("tg_id", -1)
raise Forbidden(f"Доступ запрещен для студента {student.name}") student: Student = app.db.get_student_by_tg_id(tg_id)
result = func(student, *args, **kwargs) if student.number not in SUPER_USER:
raise Forbidden(f"Доступ запрещен для студента {student.name}")
result = func(*args, **kwargs)
return result return result
return wrapper 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 @availability
@superuser @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") @app.route("/sub")
@ -97,6 +116,7 @@ def sub():
is_subbed = app.db.sub_by_tg_id(tg_id) is_subbed = app.db.sub_by_tg_id(tg_id)
return flask.jsonify(dict(is_subbed=is_subbed)) return flask.jsonify(dict(is_subbed=is_subbed))
@app.route("/unsub") @app.route("/unsub")
@availability @availability
def unsub(): def unsub():
@ -105,10 +125,9 @@ def unsub():
return flask.jsonify(dict(is_unsubbed=is_unsubbed)) return flask.jsonify(dict(is_unsubbed=is_unsubbed))
@app.errorhandler(StudentAlreadyAuthed) @app.errorhandler(StudentAlreadyAuthed)
def show_error(err): 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) @app.errorhandler(AnotherStudentAlreadyAuthed)
@ -120,13 +139,16 @@ def show_error(err):
def show_error(err): def show_error(err):
return flask.jsonify({"error": type(err).__name__, "message": err.message}), 404 return flask.jsonify({"error": type(err).__name__, "message": err.message}), 404
@app.errorhandler(StudentAlreadySubbed) @app.errorhandler(StudentAlreadySubbed)
def show_error(err): 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) @app.errorhandler(StudentAlreadyUnsubbed)
def show_error(err): 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) @app.errorhandler(BadRequest)
def show_error(err): def show_error(err):

View File

@ -31,6 +31,7 @@ ASSIGNMENT_HOW_TO_DISPLAY = 7
ASSIGNMENT_NOTES_RANGES = 8 ASSIGNMENT_NOTES_RANGES = 8
TOKEN_FILE = pathlib.Path() / ".secrets" / "token.json" TOKEN_FILE = pathlib.Path() / ".secrets" / "token.json"
CLIENT_SECRET_FILE = pathlib.Path() / ".secrets" / "client_secret.json" CLIENT_SECRET_FILE = pathlib.Path() / ".secrets" / "client_secret.json"
NON_CACHED_RANGES = ("StudentList",)
def timed_lru_cache(seconds: int, maxsize: int = 128): 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: if datetime.utcnow() >= func.expiration:
func.cache_clear() func.cache_clear()
func.expiration = datetime.utcnow() + func.lifetime func.expiration = datetime.utcnow() + func.lifetime
# if args[1] in NON_CACHED_RANGES:
# func.cache_clear()
print(*args)
return func(*args, **kwargs) return func(*args, **kwargs)
return wrapped_func return wrapped_func
@ -63,16 +66,19 @@ class AnotherStudentAlreadyAuthed(Exception):
self.message = message self.message = message
super().__init__(self.message) super().__init__(self.message)
class StudentAlreadySubbed(Exception): class StudentAlreadySubbed(Exception):
def __init__(self, message): def __init__(self, message):
self.message = message self.message = message
super().__init__(self.message) super().__init__(self.message)
class StudentAlreadyUnsubbed(Exception): class StudentAlreadyUnsubbed(Exception):
def __init__(self, message): def __init__(self, message):
self.message = message self.message = message
super().__init__(self.message) super().__init__(self.message)
class Manager: class Manager:
"""Позволяет выполнять операции с Google таблицами.""" """Позволяет выполнять операции с Google таблицами."""
@ -99,7 +105,6 @@ class Manager:
if creds and creds.expired and creds.refresh_token: if creds and creds.expired and creds.refresh_token:
creds.refresh(Request()) creds.refresh(Request())
else: else:
print(CLIENT_SECRET_FILE)
flow = InstalledAppFlow.from_client_secrets_file( flow = InstalledAppFlow.from_client_secrets_file(
str(CLIENT_SECRET_FILE), scopes str(CLIENT_SECRET_FILE), scopes
) )
@ -120,13 +125,11 @@ class Manager:
Returns: Returns:
list of rows [[val1, val2, ...], [], ...] list of rows [[val1, val2, ...], [], ...]
""" """
result = ( result = (
self.sheet.values() self.sheet.values()
.get(spreadsheetId=self.spreadsheet_id, range=range_name) .get(spreadsheetId=self.spreadsheet_id, range=range_name)
.execute() .execute()
) )
return result.get("values", []) return result.get("values", [])
def write_values(self, values: list, range_name: str): def write_values(self, values: list, range_name: str):
@ -176,7 +179,7 @@ class Manager:
name=row[STUDENT_NAMES], name=row[STUDENT_NAMES],
group_id=row[GROUP_IDS], group_id=row[GROUP_IDS],
tg_id=row[STUDENT_TELEGRAM_IDS], tg_id=row[STUDENT_TELEGRAM_IDS],
subjects=row[STUDENT_SUBJECTS].split(','), subjects=row[STUDENT_SUBJECTS].split(","),
is_subbed=bool(int(row[STUDENT_SUBSCRIPTION])), is_subbed=bool(int(row[STUDENT_SUBSCRIPTION])),
) )
) )
@ -187,8 +190,8 @@ class Manager:
"""Получить список всех групп.""" """Получить список всех групп."""
values = self.get_values() values = self.get_values()
groups = defaultdict(lambda: Group("0")) groups = defaultdict(lambda: Group("0"))
f = lambda row: row[STUDENT_SUBSCRIPTION] == '1' f = lambda row: row[STUDENT_SUBSCRIPTION] == "1"
for row in filter(f,values): for row in filter(f, values):
groups[row[GROUP_IDS]].group_id = row[GROUP_IDS] groups[row[GROUP_IDS]].group_id = row[GROUP_IDS]
groups[row[GROUP_IDS]].add_student( groups[row[GROUP_IDS]].add_student(
Student( Student(
@ -196,7 +199,7 @@ class Manager:
name=row[STUDENT_NAMES], name=row[STUDENT_NAMES],
group_id=row[GROUP_IDS], group_id=row[GROUP_IDS],
tg_id=row[STUDENT_TELEGRAM_IDS], tg_id=row[STUDENT_TELEGRAM_IDS],
subjects=row[STUDENT_SUBJECTS].split(','), subjects=row[STUDENT_SUBJECTS].split(","),
is_subbed=bool(int(row[STUDENT_SUBSCRIPTION])), is_subbed=bool(int(row[STUDENT_SUBSCRIPTION])),
) )
) )
@ -212,10 +215,11 @@ class Manager:
try: try:
row = next(filter(f, values)) row = next(filter(f, values))
return Student( return Student(
int(row[STUDENT_NUMBERS]), name, int(row[STUDENT_NUMBERS]),
row[GROUP_IDS], name,
row[STUDENT_TELEGRAM_IDS], row[GROUP_IDS],
row[STUDENT_SUBJECTS].split(',') row[STUDENT_TELEGRAM_IDS],
row[STUDENT_SUBJECTS].split(","),
) )
except StopIteration: except StopIteration:
raise StudentNotFound("Не могу найти студента по имени") raise StudentNotFound("Не могу найти студента по имени")
@ -229,10 +233,11 @@ class Manager:
try: try:
row = next(filter(f, values)) row = next(filter(f, values))
return Student( return Student(
int(row[STUDENT_NUMBERS]), int(row[STUDENT_NUMBERS]),
row[STUDENT_NAMES], row[STUDENT_NAMES],
row[GROUP_IDS], tg_id, row[GROUP_IDS],
row[STUDENT_SUBJECTS].split(',') tg_id,
row[STUDENT_SUBJECTS].split(","),
) )
except StopIteration: except StopIteration:
raise StudentNotFound("Не могу найти студента по telegram id") raise StudentNotFound("Не могу найти студента по telegram id")
@ -246,22 +251,22 @@ class Manager:
for row in filter(f, values): for row in filter(f, values):
assignment_values = self.get_values(row[ASSIGNMENT_RANGES]) assignment_values = self.get_values(row[ASSIGNMENT_RANGES])
toFloat = lambda x: float(x.replace(",", ".")) toFloat = lambda x: float(x.replace(",", "."))
assignment_value = list(map(toFloat, assignment_values[student.number-1])) assignment_value = list(map(toFloat, assignment_values[student.number - 1]))
notes_range, n_row = row[ASSIGNMENT_NOTES_RANGES].split(',') notes_range, n_row = row[ASSIGNMENT_NOTES_RANGES].split(",")
note = self.get_values(notes_range)[student.number-1][int(n_row)] note = self.get_values(notes_range)[student.number - 1][int(n_row)]
if note == '-': if note == "-":
note = "Замечаний по работе нет." note = "Замечаний по работе нет."
else: else:
note = f"Замечания:\n {note}" note = f"Замечания:\n {note}"
student.add_assignment( student.add_assignment(
Assignment( Assignment(
name=row[ASSIGNMENT_NAMES], name=row[ASSIGNMENT_NAMES],
points=assignment_value, points=assignment_value,
weight=toFloat(row[ASSIGNMENT_WEIGHTS]), weight=toFloat(row[ASSIGNMENT_WEIGHTS]),
subject=row[ASSIGNMENT_SUBJECT], subject=row[ASSIGNMENT_SUBJECT],
allow_to_display=bool(int(row[ASSIGNMENT_ALLOWS])), allow_to_display=bool(int(row[ASSIGNMENT_ALLOWS])),
how_to_display=row[ASSIGNMENT_HOW_TO_DISPLAY], how_to_display=row[ASSIGNMENT_HOW_TO_DISPLAY],
notes=note notes=note,
) )
) )
@ -282,15 +287,15 @@ class Manager:
row[STUDENT_TELEGRAM_IDS] = tg_id row[STUDENT_TELEGRAM_IDS] = tg_id
self.write_values(values, "StudentList") self.write_values(values, "StudentList")
return Student( return Student(
int(row[STUDENT_NUMBERS]), int(row[STUDENT_NUMBERS]),
row[STUDENT_NAMES], row[STUDENT_NAMES],
row[GROUP_IDS], tg_id, row[GROUP_IDS],
row[STUDENT_SUBJECTS].split(',') tg_id,
row[STUDENT_SUBJECTS].split(","),
) )
except StopIteration: except StopIteration:
raise StudentNotFound("Не могу найти студента по token") raise StudentNotFound("Не могу найти студента по token")
def sub_by_tg_id(self, tg_id: str) -> bool: def sub_by_tg_id(self, tg_id: str) -> bool:
"""Подписка пользователя по tg_id""" """Подписка пользователя по tg_id"""
@ -299,7 +304,7 @@ class Manager:
f = lambda row: tg_id == row[STUDENT_TELEGRAM_IDS] f = lambda row: tg_id == row[STUDENT_TELEGRAM_IDS]
try: try:
row = next(filter(f, values)) row = next(filter(f, values))
if row[STUDENT_SUBSCRIPTION] == '1': if row[STUDENT_SUBSCRIPTION] == "1":
raise StudentAlreadySubbed("Студент уже подписался") raise StudentAlreadySubbed("Студент уже подписался")
row[STUDENT_SUBSCRIPTION] = 1 row[STUDENT_SUBSCRIPTION] = 1
@ -316,10 +321,10 @@ class Manager:
f = lambda row: tg_id == row[STUDENT_TELEGRAM_IDS] f = lambda row: tg_id == row[STUDENT_TELEGRAM_IDS]
try: try:
row = next(filter(f, values)) row = next(filter(f, values))
if row[STUDENT_SUBSCRIPTION] == '0': if row[STUDENT_SUBSCRIPTION] == "0":
raise StudentAlreadyUnsubbed( raise StudentAlreadyUnsubbed(
"Студент уже не подписан (или никогда не был)" "Студент уже не подписан (или никогда не был)"
) )
row[STUDENT_SUBSCRIPTION] = 0 row[STUDENT_SUBSCRIPTION] = 0
self.write_values(values, "StudentList") self.write_values(values, "StudentList")

View File

@ -1,7 +1,9 @@
from collections import defaultdict
from pathlib import Path from pathlib import Path
import logging import logging
import requests import requests
import time import time
import datetime
import telegram import telegram
from telegram import ( from telegram import (
@ -19,17 +21,19 @@ from telegram.ext import (
) )
from telegram.ext.callbackqueryhandler import CallbackQueryHandler from telegram.ext.callbackqueryhandler import CallbackQueryHandler
from utils import TgLogger # from utils import TgLogger
from utils.inline_keyboard import * from utils.inline_keyboard import *
from messages import Messages from messages import Messages
TOKEN = Path(".secrets/bot_token.txt").read_text() TOKEN = Path(".secrets/bot_token.txt").read_text()
MESSAGES = Messages() MESSAGES = Messages()
HOST = "http://carolyn-spreadsheets:5000" HOST = "http://carolyn-spreadsheets:5000"
SUB_TIME_DELTA = datetime.timedelta(minutes=15)
BROADCAST_MESSAGE, BROADCAST_PUBLISH_DONE = range(2) BROADCAST_MESSAGE, BROADCAST_PUBLISH_DONE = range(2)
GRADES_ASSNT, GRADES_VIEW = range(2) GRADES_ASSNT, GRADES_VIEW = range(2)
logging.setLoggerClass(TgLogger) # logging.setLoggerClass(TgLogger)
logging.basicConfig( logging.basicConfig(
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO 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 return GRADES_ASSNT
def broadcast(update: Update, context: CallbackContext): def broadcast(update: Update, context: CallbackContext):
"""Команда: разослать студентам.""" """Команда: разослать студентам."""
access_message = update.message.reply_text("🔐 Проверка доступа...") access_message = update.message.reply_text("🔐 Проверка доступа...")
user_id = update.message.from_user.id user_id = update.message.from_user.id
try: 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" assert r.status_code == 200, "Доступ запрещен к /broadcast"
data = r.json() data = r.json()
context.bot_data["groups"] = data context.bot_data["groups"] = data
@ -250,9 +255,7 @@ def sub(update: Update, context: CallbackContext):
username = update.message.from_user.username username = update.message.from_user.username
# Длинная операция, сообщим о запущенном процессе. # Длинная операция, сообщим о запущенном процессе.
update.message.reply_text(text=MESSAGES.Sub.START) msg = update.message.reply_text(text=MESSAGES.Sub.START)
LOG.info(f"Subcripting {user_id}")
try: try:
req = requests.get( req = requests.get(
@ -272,7 +275,8 @@ def sub(update: Update, context: CallbackContext):
update.message.reply_sticker(MESSAGES.Stickers.bad()) update.message.reply_sticker(MESSAGES.Stickers.bad())
return return
update.message.reply_text(text=MESSAGES.Sub.SUBBED) msg.edit_text(text=MESSAGES.Sub.SUBBED)
def unsub(update: Update, context: CallbackContext): def unsub(update: Update, context: CallbackContext):
"""Стартовое сообщение (к команде /start)""" """Стартовое сообщение (к команде /start)"""
@ -281,9 +285,9 @@ def unsub(update: Update, context: CallbackContext):
username = update.message.from_user.username 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: try:
req = requests.get( req = requests.get(
@ -303,7 +307,54 @@ def unsub(update: Update, context: CallbackContext):
update.message.reply_sticker(MESSAGES.Stickers.bad()) update.message.reply_sticker(MESSAGES.Stickers.bad())
return 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 # ==================================================================== HANDLERS
@ -311,6 +362,8 @@ def unsub(update: Update, context: CallbackContext):
def main() -> None: def main() -> None:
updater = Updater(token=TOKEN, use_context=True) updater = Updater(token=TOKEN, use_context=True)
dispatcher = updater.dispatcher dispatcher = updater.dispatcher
jobber = updater.job_queue
jobber.run_repeating(grades_checker, SUB_TIME_DELTA)
LOG.bot = updater.bot LOG.bot = updater.bot
start_handler = CommandHandler("start", start) start_handler = CommandHandler("start", start)

View File

@ -22,7 +22,7 @@ class Messages:
return cases.get(err, "😟 Возникли неполадки") return cases.get(err, "😟 Возникли неполадки")
class Assignments: class Assignments:
START = "👀 Посмотрим (~3с) ..." START = "👀 Посмотрим (~5с) ..."
SELECT_COURSE = "👇 Выбери дисциплину" SELECT_COURSE = "👇 Выбери дисциплину"
SELECT_ASSNT = "👇 Выбери работу" SELECT_ASSNT = "👇 Выбери работу"
END = "😉 Всего наилучшего" END = "😉 Всего наилучшего"
@ -58,12 +58,15 @@ class Messages:
class Sub: class Sub:
START = "📜✍ Оформляю подписку на уведомления..." START = "📜✍ Оформляю подписку на уведомления..."
SUBBED = "👌 Подписка на уведомления оформлена" SUBBED = "👌 Подписка на уведомления оформлена"
GRADES_CHANGED = (
"💌 Твои оценки изменились! Вызови команду /grades, чтобы узнать!"
)
@staticmethod @staticmethod
def failure(err: str): def failure(err: str):
cases = { cases = {
"StudentNotFound": "⛔ Для подписки нужно зарегистрироваться." "StudentNotFound": "⛔ Для подписки нужно зарегистрироваться."
+ "Команда /start поможет с регистрацией", + "Команда /start поможет с регистрацией",
"StudentAlreadySubbed": "💁‍♀️ Подписка уже оформлена ранее", "StudentAlreadySubbed": "💁‍♀️ Подписка уже оформлена ранее",
} }
return cases.get(err, "😟 Возникли неполадки") return cases.get(err, "😟 Возникли неполадки")
@ -75,8 +78,8 @@ class Messages:
@staticmethod @staticmethod
def failure(err: str): def failure(err: str):
cases = { cases = {
"StudentNotFound": "⛔ Для отпидски от уведомлений нужно зарегистрироваться." "StudentNotFound": "⛔ Для отпидски от уведомлений нужно зарегистрироваться."
+ "Команда /start поможет с регистрацией", + "Команда /start поможет с регистрацией",
"StudentAlreadyUnsubbed": "💁‍♀️ Подписка на уведомления не была оформлена или уже отменена", "StudentAlreadyUnsubbed": "💁‍♀️ Подписка на уведомления не была оформлена или уже отменена",
} }
return cases.get(err, "😟 Возникли неполадки") return cases.get(err, "😟 Возникли неполадки")