- добавлен новый узел /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 *
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):
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(student, *args, **kwargs)
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):
"""Выполняет рассылку студентам."""
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):

View File

@ -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,
int(row[STUDENT_NUMBERS]),
name,
row[GROUP_IDS],
row[STUDENT_TELEGRAM_IDS],
row[STUDENT_SUBJECTS].split(',')
row[STUDENT_SUBJECTS].split(","),
)
except StopIteration:
raise StudentNotFound("Не могу найти студента по имени")
@ -231,8 +235,9 @@ class Manager:
return Student(
int(row[STUDENT_NUMBERS]),
row[STUDENT_NAMES],
row[GROUP_IDS], tg_id,
row[STUDENT_SUBJECTS].split(',')
row[GROUP_IDS],
tg_id,
row[STUDENT_SUBJECTS].split(","),
)
except StopIteration:
raise StudentNotFound("Не могу найти студента по telegram id")
@ -246,10 +251,10 @@ 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}"
@ -261,7 +266,7 @@ class Manager:
subject=row[ASSIGNMENT_SUBJECT],
allow_to_display=bool(int(row[ASSIGNMENT_ALLOWS])),
how_to_display=row[ASSIGNMENT_HOW_TO_DISPLAY],
notes=note
notes=note,
)
)
@ -284,13 +289,13 @@ class Manager:
return Student(
int(row[STUDENT_NUMBERS]),
row[STUDENT_NAMES],
row[GROUP_IDS], tg_id,
row[STUDENT_SUBJECTS].split(',')
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,7 +321,7 @@ 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(
"Студент уже не подписан (или никогда не был)"
)

View File

@ -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)

View File

@ -22,7 +22,7 @@ class Messages:
return cases.get(err, "😟 Возникли неполадки")
class Assignments:
START = "👀 Посмотрим (~3с) ..."
START = "👀 Посмотрим (~5с) ..."
SELECT_COURSE = "👇 Выбери дисциплину"
SELECT_ASSNT = "👇 Выбери работу"
END = "😉 Всего наилучшего"
@ -58,6 +58,9 @@ class Messages:
class Sub:
START = "📜✍ Оформляю подписку на уведомления..."
SUBBED = "👌 Подписка на уведомления оформлена"
GRADES_CHANGED = (
"💌 Твои оценки изменились! Вызови команду /grades, чтобы узнать!"
)
@staticmethod
def failure(err: str):