mirror of
https://github.com/Faridik/Carolyn.git
synced 2026-07-08 05:11:52 +00:00
- добавлен новый узел /grades/fingerprint
- в планировщик добавлен джоб, который проверяет не изменились ли оценки студентов
This commit is contained in:
parent
9a89cde748
commit
2d975df4b1
@ -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):
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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, "😟 Возникли неполадки")
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user