From 719a9d282b13105b89dce395412bd7c7a90b6bc0 Mon Sep 17 00:00:00 2001 From: l1eanny Date: Mon, 21 Jul 2025 11:25:45 +0300 Subject: [PATCH 1/5] adding a bot module and augmented docker files --- .gitignore | 10 +- backend.Dockerfile | 2 +- bot.Dockerfile | 13 ++ courses/ml-2025.yaml | 123 +++++++++++ courses/os-2024.yaml | 233 +++++++++++++++++++ docker-compose.yml | 18 ++ frontend/courses-front/vite.config.js | 2 +- main.py | 114 +++++++--- {courses => old_courses}/ml-2024.yaml | 0 {courses => old_courses}/os-2023.yaml | 0 {courses => old_courses}/os-2028.yaml | 0 tg_bot/bot_db_manager.py | 78 +++++++ tg_bot/bot_main.py | 17 ++ tg_bot/bot_requirements.txt | 4 + tg_bot/handlers.py | 307 ++++++++++++++++++++++++++ tg_bot/markups.py | 74 +++++++ 16 files changed, 959 insertions(+), 36 deletions(-) create mode 100644 bot.Dockerfile create mode 100644 courses/ml-2025.yaml create mode 100644 courses/os-2024.yaml rename {courses => old_courses}/ml-2024.yaml (100%) rename {courses => old_courses}/os-2023.yaml (100%) rename {courses => old_courses}/os-2028.yaml (100%) create mode 100644 tg_bot/bot_db_manager.py create mode 100644 tg_bot/bot_main.py create mode 100644 tg_bot/bot_requirements.txt create mode 100644 tg_bot/handlers.py create mode 100644 tg_bot/markups.py diff --git a/.gitignore b/.gitignore index 0d68bc2..627d2a4 100644 --- a/.gitignore +++ b/.gitignore @@ -178,4 +178,12 @@ yarn-error.log* pnpm-debug.log* .vite/ dist/ -.cache/ \ No newline at end of file +.cache/ +.idea/ + +credentials.json + +# В корне .gitignore +tg_bot/*.db +tg_bot/*.db-* +tg_bot/__pycache__/ \ No newline at end of file diff --git a/backend.Dockerfile b/backend.Dockerfile index 0bdc087..90e2cd8 100644 --- a/backend.Dockerfile +++ b/backend.Dockerfile @@ -7,7 +7,7 @@ COPY requirements.txt . RUN pip install --no-cache-dir --upgrade pip \ && pip install --no-cache-dir -r requirements.txt -COPY . . +COPY credentials.json . EXPOSE 8000 diff --git a/bot.Dockerfile b/bot.Dockerfile new file mode 100644 index 0000000..eaeca7f --- /dev/null +++ b/bot.Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Установка зависимостей +COPY tg_bot/bot_requirements.txt . +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir -r bot_requirements.txt + +# Копирование файлов бота +COPY tg_bot/ /app/ + +CMD ["python", "bot_main.py"] \ No newline at end of file diff --git a/courses/ml-2025.yaml b/courses/ml-2025.yaml new file mode 100644 index 0000000..026d687 --- /dev/null +++ b/courses/ml-2025.yaml @@ -0,0 +1,123 @@ +course: + name: Machine learning + logo: "/assets/machine-learning.png" + alt-names: + - ML + - Машинное обучение + semester: Spring 2025 + email: k43guap@ya.ru + timezone: UTC+3 + github: + organization: suai-mlb-2025 + teachers: + - "Mark Polyak" + - markpolyak + google: + spreadsheet: 1i-woK-2y6Q-S5QTYEd0vxZoizZgJQsji8-Op5IIA9mc # тестовая таблица + info-sheet: График + task-id-column: 0 + student-name-column: 2 + lab-column-offset: 1 + staff: + - name: Поляк Марк Дмитриевич + title: ст. преп. + status: лектор + - name: Поляк Марк Дмитриевич + title: ст. преп. + status: лабораторные работы + labs: + "1": + github-prefix: mlb-task1 + short-name: ЛР1 + taskid-max: 25 + penalty-max: 7 + ci: + - workflows + files: + - jupyter_assignment.ipynb + - matplotlib_assignment.ipynb + - pandas_assignment.ipynb + tests: + - jupyter_tests.py + moss: + language: py + max-matches: 1000 + local-path: lab1 + additional: + - suai-mlb-2024 + basefiles: + - + repo: k43guap/mlb-course-task1 + filename: jupyter_assignment.ipynb + - + repo: k43guap/mlb-course-task1 + filename: matplotlib_assignment.ipynb + - + repo: k43guap/mlb-course-task1 + filename: pandas_assignment.ipynb + report: + - Цель работы + - Индивидуальное задание + - Описание входных данных + - Результат выполнения работы + - Исходный код программы с комментариями + - Выводы + "2": + github-prefix: mlb-task2 + short-name: ЛР2 + taskid-max: 20 + taskid-shift: 4 + penalty-max: 8 + ci: + - workflows + files: + - regression_assignment.ipynb + tests: [] + moss: + language: py + max-matches: 1000 + local-path: lab2 + additional: + - suai-mlb-2024 + basefiles: + - + repo: k43guap/mlb-course-task2 + filename: regression_assignment.ipynb + report: + - Цель работы + - Задание на лабораторную работу + - Граф запуска потоков + - Результат выполнения работы + - Исходный код программы с комментариями + - Выводы + "3": + github-prefix: mlb-task3 + short-name: ЛР3 + taskid-max: 20 + penalty-max: 9 + ci: + - workflows + files: + - classification_assignment.ipynb + tests: [] + moss: + language: py + max-matches: 1000 + local-path: lab3 + additional: + - suai-mlb-2024 + basefiles: + - + repo: k43guap/mlb-course-task3 + filename: classification_assignment.ipynb + report: + - Цель работы + - Задание на лабораторную работу + - Граф запуска потоков + - Результат выполнения работы + - Исходный код программы с комментариями + - Выводы +misc: + requests-timeout: 5 + + diff --git a/courses/os-2024.yaml b/courses/os-2024.yaml new file mode 100644 index 0000000..be9b880 --- /dev/null +++ b/courses/os-2024.yaml @@ -0,0 +1,233 @@ +course: + name: Operating systems + logo: "/assets/operating-system.png" + alt-names: + - OS + - Операционные системы + - Ос + semester: Autumn 2024 + email: k43guap@ya.ru + timezone: UTC+3 + github: + organization: suai-os-2024f + teachers: + - "Mark Polyak" + - markpolyak + google: + spreadsheet: 1i1RaPXSWOyIfKNNJgkeO09V2StQ2tzoklxkrGfQad6s # тестовая таблица + info-sheet: График + task-id-column: 0 + student-name-column: 2 + lab-column-offset: 1 + staff: + - name: Поляк Марк Дмитриевич + title: ст. преп. + status: лектор + - name: Поляк Марк Дмитриевич + title: ст. преп. + status: лабораторные работы + labs: + "0": + github-prefix: os-task0 + short-name: ЛР0 + penalty-max: 5 + ignore-task-id: True + ci: + - workflows + files: + - goals.md + - info.md + - report.pdf + tests: [] + moss: + language: c + report: + - Задание + - Результат + "1": + github-prefix: os-task1 + short-name: ЛР1 + taskid-max: 25 + penalty-max: 6 + ci: + - workflows + files: + - lab1.sh + tests: + - test.sh + moss: + language: c + max-matches: 1000 + local-path: lab1 + additional: + - suai-os-2020 + - suai-os-2021 + - suai-os-2022 + - suai-os-2023 + basefiles: + - + repo: k43guap/os-course-task1 + filename: lab1.sh + report: + - Цель работы + - Индивидуальное задание + - Описание входных данных + - Результат выполнения работы + - Исходный код программы с комментариями + - Выводы + "2": + github-prefix: os-task2 + short-name: ЛР2 + taskid-max: 20 + taskid-shift: 4 + penalty-max: 9 + ci: + - workflows + files: + - lab2.cpp + tests: + - test/tests.cpp + moss: + language: cc + max-matches: 1000 + local-path: lab2 + additional: + - suai-os-2020 + - suai-os-2021 + - suai-os-2022 + - suai-os-2023 + basefiles: + - + repo: k43guap/os-course-task2 + filename: lab2.cpp + - + repo: k43guap/os-course-task2 + filename: examples/ex3.cpp + report: + - Цель работы + - Задание на лабораторную работу + - Граф запуска потоков + - Результат выполнения работы + - Исходный код программы с комментариями + - Выводы + "3": + github-prefix: os-task3 + short-name: ЛР3 + taskid-max: 20 + penalty-max: 7 + ci: + - workflows + files: + - lab3.cpp + tests: + - test/tests.cpp + moss: + language: cc + max-matches: 1000 + local-path: lab3 + additional: + - suai-os-2020 + - suai-os-2021 + - suai-os-2022 + - suai-os-2023 + basefiles: + - + repo: k43guap/os-course-task3 + filename: lab3.cpp + report: + - Цель работы + - Задание на лабораторную работу + - Граф запуска потоков + - Результат выполнения работы + - Исходный код программы с комментариями + - Выводы + "4": + github-prefix: os-task4 + short-name: ЛР4 + taskid-max: 30 + penalty-max: 8 + ci: + - workflows + files: + - lab4.cpp + tests: + - tests.sh + moss: + language: cc + max-matches: 100 + local-path: lab4 + additional: + - suai-os-2021 + - suai-os-2022 + - suai-os-2023 + basefiles: + - + repo: k43guap/os-course-task4 + filename: lab4.cpp + report: + - Цель работы + - Задание на лабораторную работу + - Описание используемых алгоритмов замещения страниц + - Результат выполнения работы + - Исходный код программы с комментариями + - Выводы + "5": + github-prefix: os-task5 + short-name: ЛР5 + taskid-max: 30 + penalty-max: 10 + # ignore-completion-date: True + ci: + workflows: + - run-autograding-tests + - cpplint + files: + - client.cpp + - server.cpp + tests: [] + moss: + language: cc + max-matches: 1000 + local-path: lab5 + additional: + - suai-os-2021 + - suai-os-2022 + - suai-os-2023 + report: + - Цель работы + - Задание на лабораторную работу + - Схема взаимодействия между клиентом и сервером + - Результат выполнения работы + - Исходный код программы с комментариями + - Выводы + "6": + github-prefix: os-task5 + short-name: ЛР6 + taskid-max: 30 + penalty-max: 6 + ci: + workflows: + - run-autograding-tests + - cpplint + files: + - .github/workflows/tests.yml + moss: + language: cc + max-matches: 1000 + local-path: lab5 + additional: + - suai-os-2021 + - suai-os-2022 + - suai-os-2023 + report: + - Цель работы + - Задание на лабораторную работу + - Описание структуры конфигурационного файла + - Содержимое написанного конфигурационного файла + - Логи сборки проекта в облаке + - Исходный код тестов + - Выводы +misc: + requests-timeout: 5 + + diff --git a/docker-compose.yml b/docker-compose.yml index 11516ac..3b35f28 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,3 +21,21 @@ services: - .:/app env_file: - .env + environment: + - API_BASE_URL=http://backend:8000 + + bot: + build: + context: . + dockerfile: bot.Dockerfile + volumes: + - ./tg_bot:/app + + env_file: + - .env + # environment: + # - API_BASE_URL=http://backend:8000 # Для доступа к бэкенду + depends_on: + - backend + + diff --git a/frontend/courses-front/vite.config.js b/frontend/courses-front/vite.config.js index c52e670..d8a5490 100644 --- a/frontend/courses-front/vite.config.js +++ b/frontend/courses-front/vite.config.js @@ -7,7 +7,7 @@ export default defineConfig({ server: { proxy: { '/api': { - target: 'http://localhost:8000', + target: 'http://backend:8000', changeOrigin: true, secure: false, rewrite: (path) => { diff --git a/main.py b/main.py index 5d2c207..4930615 100644 --- a/main.py +++ b/main.py @@ -29,6 +29,7 @@ ) signer = TimestampSigner(SECRET_KEY) + class AuthRequest(BaseModel): login: str password: str @@ -45,6 +46,7 @@ class StudentRegistration(BaseModel): async def read_index(): return FileResponse("dist/index.html") + @app.post("/api/admin/login") def admin_login(data: AuthRequest, response: Response): if data.login == ADMIN_LOGIN and data.password == ADMIN_PASSWORD: @@ -60,6 +62,7 @@ def admin_login(data: AuthRequest, response: Response): return {"authenticated": True} raise HTTPException(status_code=401, detail="Неверный логин или пароль") + @app.get("/api/admin/check-auth") def check_auth(request: Request): cookie = request.cookies.get("admin_session") @@ -76,6 +79,7 @@ def check_auth(request: Request): return {"authenticated": True} + @app.post("/api/admin/logout") def logout(response: Response): response.delete_cookie("admin_session", path="/") @@ -116,6 +120,7 @@ def parse_lab_id(lab_id: str) -> int: raise HTTPException(status_code=400, detail="Некорректный lab_id") return int(match.group(0)) + @app.get("/courses/{course_id}") def get_course(course_id: str): files = sorted([f for f in os.listdir(COURSES_DIR) if f.endswith(".yaml")]) @@ -138,6 +143,7 @@ def get_course(course_id: str): "google-spreadsheet": course_info.get("google", {}).get("spreadsheet", "Unknown"), } + @app.delete("/courses/{course_id}") def delete_course(course_id: str): files = sorted([f for f in os.listdir(COURSES_DIR) if f.endswith(".yaml")]) @@ -188,7 +194,6 @@ def edit_course_put(course_id: str, data: EditCourseRequest): file_path = os.path.join(COURSES_DIR, filename) - try: yaml.safe_load(data.content) except yaml.YAMLError as e: @@ -218,7 +223,6 @@ def get_course_groups(course_id: str): if not spreadsheet_id: raise HTTPException(status_code=400, detail="Spreadsheet ID not found in course config") - scope = ["https://spreadsheets.google.com/feeds", "https://www.googleapis.com/auth/drive"] creds = ServiceAccountCredentials.from_json_keyfile_name(CREDENTIALS_FILE, scope) client = gspread.authorize(creds) @@ -250,7 +254,6 @@ def get_course_labs(course_id: str, group_id: str): if not spreadsheet_id or not labs: raise HTTPException(status_code=400, detail="Missing spreadsheet ID or labs in config") - scope = ["https://spreadsheets.google.com/feeds", "https://www.googleapis.com/auth/drive"] creds = ServiceAccountCredentials.from_json_keyfile_name(CREDENTIALS_FILE, scope) client = gspread.authorize(creds) @@ -259,7 +262,6 @@ def get_course_labs(course_id: str, group_id: str): spreadsheet = client.open_by_key(spreadsheet_id) sheet = spreadsheet.worksheet(group_id) - headers = sheet.row_values(2)[2:] except Exception as e: raise HTTPException(status_code=404, detail=f"Group not found in spreadsheet: {str(e)}") @@ -286,7 +288,6 @@ def register_student(course_id: str, group_id: str, student: StudentRegistration if not spreadsheet_id: raise HTTPException(status_code=400, detail="Spreadsheet ID not found in course config") - scope = ["https://spreadsheets.google.com/feeds", "https://www.googleapis.com/auth/drive"] creds = ServiceAccountCredentials.from_json_keyfile_name(CREDENTIALS_FILE, scope) client = gspread.authorize(creds) @@ -299,26 +300,23 @@ def register_student(course_id: str, group_id: str, student: StudentRegistration full_name = f"{student.surname} {student.name} {student.patronymic}".strip() - student_list = sheet.col_values(student_col)[2:] if full_name not in student_list: - raise HTTPException(status_code=404, detail={"message": "Студент не найден"}) + raise HTTPException(status_code=404, detail="Студент не найден") row_idx = student_list.index(full_name) + 3 - header_row = sheet.row_values(1) try: github_col_idx = header_row.index("GitHub") + 1 except ValueError: raise HTTPException(status_code=400, detail="Столбец 'GitHub' не найден в таблице") - try: github_response = requests.get(f"https://api.github.com/users/{student.github}") if github_response.status_code != 200: - raise HTTPException(status_code=404, detail={"message": "Пользователь GitHub не найден"}) + raise HTTPException(status_code=404, detail="Пользователь GitHub не найден") except Exception: raise HTTPException(status_code=500, detail="Ошибка проверки GitHub пользователя") @@ -334,10 +332,8 @@ def register_student(course_id: str, group_id: str, student: StudentRegistration "message": "Этот аккаунт GitHub уже был указан ранее для этого же студента" } - raise HTTPException(status_code=409, detail={ - "status": "conflict", - "message": "Аккаунт GitHub уже был указан ранее. Для изменения аккаунта обратитесь к преподавателю" - }) + raise HTTPException(status_code=409, + detail="Аккаунт GitHub уже был указан ранее. Для изменения аккаунта обратитесь к преподавателю") def normalize_lab_id(lab_id: str) -> str: @@ -349,8 +345,10 @@ def normalize_lab_id(lab_id: str) -> str: class GradeRequest(BaseModel): github: str = Field(..., min_length=1) + @app.post("/courses/{course_id}/groups/{group_id}/labs/{lab_id}/grade") def grade_lab(course_id: str, group_id: str, lab_id: str, request: GradeRequest): + # Загрузка конфигурации курса files = sorted([f for f in os.listdir(COURSES_DIR) if f.endswith(".yaml")]) try: filename = files[int(course_id) - 1] @@ -367,9 +365,11 @@ def grade_lab(course_id: str, group_id: str, lab_id: str, request: GradeRequest) lab_offset = course_info.get("google", {}).get("lab-column-offset", 1) labs = course_info.get("labs", {}) - normalized_lab_id = normalize_lab_id(lab_id) + normalized_lab_id = lab_id[2:] lab_config = labs.get(normalized_lab_id, {}) repo_prefix = lab_config.get("github-prefix") + required_files = lab_config.get("files", []) + test_files = lab_config.get("tests", []) if not all([org, spreadsheet_id, repo_prefix]): raise HTTPException(status_code=400, detail="Missing course configuration") @@ -380,29 +380,72 @@ def grade_lab(course_id: str, group_id: str, lab_id: str, request: GradeRequest) "Authorization": f"Bearer {GITHUB_TOKEN}", "Accept": "application/vnd.github+json" } - - test_file_url = f"https://api.github.com/repos/{org}/{repo_name}/contents/test_main.py" - if requests.get(test_file_url, headers=headers).status_code != 200: - raise HTTPException(status_code=400, detail="⚠️ test_main.py не найден в репозитории") - + + # 1. Проверка наличия обязательных файлов + missing_files = [] + for file in required_files: + file_url = f"https://api.github.com/repos/{org}/{repo_name}/contents/{file}" + if requests.get(file_url, headers=headers).status_code != 200: + missing_files.append(file) + + if missing_files: + raise HTTPException( + status_code=400, + detail=f"⚠️ Отсутствуют обязательные файлы: {', '.join(missing_files)}" + ) + + # 2. Проверка тестовых файлов (если указаны) + if test_files: + missing_tests = [] + for test in test_files: + test_url = f"https://api.github.com/repos/{org}/{repo_name}/contents/{test}" + if requests.get(test_url, headers=headers).status_code != 200: + missing_tests.append(test) + + if missing_tests: + raise HTTPException( + status_code=400, + detail=f"⚠️ Отсутствуют тестовые файлы: {', '.join(missing_tests)}" + ) + + # 3. Проверка CI workflows workflows_url = f"https://api.github.com/repos/{org}/{repo_name}/contents/.github/workflows" if requests.get(workflows_url, headers=headers).status_code != 200: raise HTTPException(status_code=400, detail="⚠️ Папка .github/workflows не найдена. CI не настроен") + # 4. Проверка коммитов и изменений файлов commits_url = f"https://api.github.com/repos/{org}/{repo_name}/commits" commits_resp = requests.get(commits_url, headers=headers) if commits_resp.status_code != 200 or not commits_resp.json(): raise HTTPException(status_code=404, detail="Нет коммитов в репозитории") - latest_sha = commits_resp.json()[0]["sha"] - + # Получаем информацию о последнем коммите + latest_commit = commits_resp.json()[0] + latest_sha = latest_commit["sha"] commit_url = f"https://api.github.com/repos/{org}/{repo_name}/commits/{latest_sha}" - commit_files = requests.get(commit_url, headers=headers).json().get("files", []) - for f in commit_files: - if f["filename"] == "test_main.py" and f["status"] in ("removed", "modified"): - raise HTTPException(status_code=403, detail="🚨 Нельзя изменять test_main.py") - if f["filename"].startswith("tests/") and f["status"] in ("removed", "modified"): - raise HTTPException(status_code=403, detail="🚨 Нельзя изменять папку tests/") + commit_data = requests.get(commit_url, headers=headers).json() + commit_files = commit_data.get("files", []) + commit_author = latest_commit.get("author", {}).get("login") + + if commit_author and commit_author.lower() == username.lower(): + for f in commit_files: + # Запрет изменения тестовых файлов + if test_files: + # Проверка отдельных тестовых файлов + if any(f["filename"] == test_file for test_file in test_files if not test_file.endswith('/')) and f[ + "status"] in ("removed", "modified"): + raise HTTPException( + status_code=403, + detail=f"🚨 Запрещено изменять тестовый файл: {f['filename']}" + ) + + # Проверка файлов в тестовых директориях + if any(f["filename"].startswith(test_file.rstrip('/') + '/') for test_file in test_files if + test_file.endswith('/')) and f["status"] in ("removed", "modified"): + raise HTTPException( + status_code=403, + detail=f"🚨 Запрещено изменять файлы в тестовой директории: {f['filename']}" + ) check_url = f"https://api.github.com/repos/{org}/{repo_name}/commits/{latest_sha}/check-runs" check_resp = requests.get(check_url, headers=headers) @@ -431,7 +474,6 @@ def grade_lab(course_id: str, group_id: str, lab_id: str, request: GradeRequest) total_checks = len(check_runs) result_string = f"{passed_count}/{total_checks} тестов пройдено" - final_result = "✓" if passed_count == total_checks else "✗" scope = ["https://spreadsheets.google.com/feeds", "https://www.googleapis.com/auth/drive"] @@ -456,19 +498,25 @@ def grade_lab(course_id: str, group_id: str, lab_id: str, request: GradeRequest) lab_number = parse_lab_id(lab_id) row_idx = github_values.index(username) + 3 lab_col = student_col + lab_number + lab_offset - sheet.update_cell(row_idx, lab_col, final_result) + current_value = sheet.cell(row_idx, lab_col).value + + if not current_value or str(current_value).strip() == "": + sheet.update_cell(row_idx, lab_col, final_result) + print("result:", final_result) return { "status": "updated", "result": final_result, "message": f"Результат CI: {'✅ Все проверки пройдены' if final_result == '✓' else '❌ Обнаружены ошибки'}", "passed": result_string, - "checks": summary + "checks": summary, + "files_checked": { + "required": required_files, + "tests": test_files + } } - - @app.post("/courses/upload") async def upload_course(file: UploadFile = File(...)): if not file.filename.endswith(".yaml") and not file.filename.endswith(".yml"): diff --git a/courses/ml-2024.yaml b/old_courses/ml-2024.yaml similarity index 100% rename from courses/ml-2024.yaml rename to old_courses/ml-2024.yaml diff --git a/courses/os-2023.yaml b/old_courses/os-2023.yaml similarity index 100% rename from courses/os-2023.yaml rename to old_courses/os-2023.yaml diff --git a/courses/os-2028.yaml b/old_courses/os-2028.yaml similarity index 100% rename from courses/os-2028.yaml rename to old_courses/os-2028.yaml diff --git a/tg_bot/bot_db_manager.py b/tg_bot/bot_db_manager.py new file mode 100644 index 0000000..bc4391f --- /dev/null +++ b/tg_bot/bot_db_manager.py @@ -0,0 +1,78 @@ +import sqlite3 +import os + +class Database: + def __init__(self, db_file): + self.db_file = db_file + self.connection = sqlite3.connect(db_file, check_same_thread=False) + self.cursor = self.connection.cursor() + self._init_db() + + def _init_db(self): + self.cursor.execute(''' + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY NOT NULL, + user_name TEXT, + user_surname TEXT, + user_patronim TEXT, + user_github_nickname TEXT + ) + ''') + self.connection.commit() + + def close(self): + self.cursor.close() + self.connection.close() + + def add_user(self, user_id): + self.cursor.execute("INSERT INTO users (id) VALUES (?)", (user_id,)) + self.connection.commit() + + def user_exist(self, user_id): + self.cursor.execute("SELECT * FROM users WHERE id = ?", (user_id,)) + result = self.cursor.fetchall() + return bool(len(result)) + + + + + + def set_user_name(self, user_id, user_name): + self.cursor.execute("UPDATE users SET user_name = ? WHERE id = ?", (user_name, user_id,)) + self.connection.commit() + + def set_user_surname(self, user_id, user_surname): + self.cursor.execute("UPDATE users SET user_surname = ? WHERE id = ?", (user_surname, user_id,)) + self.connection.commit() + + def set_user_patronim(self, user_id, user_patronim): + self.cursor.execute("UPDATE users SET user_patronim = ? WHERE id = ?", (user_patronim, user_id,)) + self.connection.commit() + + def set_user_github_nickname(self, user_id, user_github_nickname): + self.cursor.execute("UPDATE users SET user_github_nickname = ? WHERE id = ?", (user_github_nickname, user_id,)) + self.connection.commit() + + + + def get_user_name(self, user_id): + self.cursor.execute("SELECT user_name FROM users WHERE id = ?", (user_id,)) + result = self.cursor.fetchone() + return result[0] if result else None + + def get_user_surname(self, user_id): + self.cursor.execute("SELECT user_surname FROM users WHERE id = ?", (user_id,)) + result = self.cursor.fetchone() + return result[0] if result else None + + def get_user_patronim(self, user_id): + self.cursor.execute("SELECT user_patronim FROM users WHERE id = ?", (user_id,)) + result = self.cursor.fetchone() + return result[0] if result else None + + def get_user_github_nickname(self, user_id): + self.cursor.execute("SELECT user_github_nickname FROM users WHERE id = ?", (user_id,)) + result = self.cursor.fetchone() + return result[0] if result else None + + diff --git a/tg_bot/bot_main.py b/tg_bot/bot_main.py new file mode 100644 index 0000000..3fd6673 --- /dev/null +++ b/tg_bot/bot_main.py @@ -0,0 +1,17 @@ +import asyncio, logging +from aiogram import * +from aiogram import Bot, Dispatcher +import handlers +import os + + +async def main(): + logging.basicConfig(level = logging.INFO) + token=os.getenv("BOT_TOKEN") + bot = Bot(token) + dp = Dispatcher() + dp.include_router(handlers.router) + await dp.start_polling(bot) + +if __name__ == '__main__': + asyncio.run(main()) \ No newline at end of file diff --git a/tg_bot/bot_requirements.txt b/tg_bot/bot_requirements.txt new file mode 100644 index 0000000..5404df6 --- /dev/null +++ b/tg_bot/bot_requirements.txt @@ -0,0 +1,4 @@ +aiogram +fastapi +requests +python-dotenv \ No newline at end of file diff --git a/tg_bot/handlers.py b/tg_bot/handlers.py new file mode 100644 index 0000000..32c519f --- /dev/null +++ b/tg_bot/handlers.py @@ -0,0 +1,307 @@ +from aiogram import * +from aiogram import Router, F +from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton, CallbackQuery +from fastapi import HTTPException +from aiogram.filters import Command +from aiogram.fsm.state import State, StatesGroup +from aiogram.fsm.context import FSMContext +from bot_db_manager import Database +import requests +import markups as mp +import os + + +bd = Database('bot_db.db') +API_BASE_URL = os.getenv("API_BASE_URL") +router = Router() + +class UserRegister(StatesGroup): + WaitingForName = State() + WaitingForSurname = State() + WaitingForPatronim = State() + WaitingForNickname = State() + +class ProfileEdit(StatesGroup): + WaitingForNewName = State() + WaitingForNewSurname = State() + WaitingForNewPatronim = State() + WaitingForNewNickname = State() + +class SelectingData(StatesGroup): + SelectingCourse = State() + SelectingGroup = State() + SelectingLab = State() + SelectingCheck = State() + +@router.message(Command('start')) +async def start(message: Message, state: FSMContext): + if(not bd.user_exist(message.from_user.id)): + bd.add_user(message.from_user.id) + await message.answer("Начало процесса регистрации\n Введите свое имя: ") + await state.set_state(UserRegister.WaitingForName) + else: + await message.answer("Вы уже зарегистрированны", reply_markup=mp.main_keyboard) + + +@router.message(UserRegister.WaitingForName) +async def set_user_name(message: Message, state: FSMContext): + name = message.text.strip() + if not name or not all(x.isalpha() or x.isspace() for x in name): + await message.answer("Пожалуйста, введите корректное имя.") + return + bd.set_user_name(message.from_user.id, name) + await message.answer("Введите свое отчество:") + await state.set_state(UserRegister.WaitingForPatronim) + +@router.message(UserRegister.WaitingForPatronim) +async def set_user_patronim(message: Message, state: FSMContext): + patronim = message.text.strip() + if not patronim or not all(x.isalpha() or x.isspace() for x in patronim): + await message.answer("Пожалуйста, введите корректное отчество.") + return + bd.set_user_patronim(message.from_user.id, patronim) + await message.answer("Введите свою фамилию:") + await state.set_state(UserRegister.WaitingForSurname) + +@router.message(UserRegister.WaitingForSurname) +async def set_user_surname(message: Message, state: FSMContext): + surname = message.text.strip() + if not surname or not all(x.isalpha() or x.isspace() for x in surname): + await message.answer("Пожалуйста, введите корректную фамилию.") + return + bd.set_user_surname(message.from_user.id, surname) + await message.answer("Введите свой GitHub nickname:") + await state.set_state(UserRegister.WaitingForNickname) + +@router.message(UserRegister.WaitingForNickname) +async def set_user_nickname(message: Message, state: FSMContext): + nickname = message.text.strip() + if not nickname: + await message.answer("Пожалуйста, введите корректный GitHub nickname.") + return + + # Допустимые символы для GitHub nickname (согласно документации) + allowed_chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_" + allowed_set = set(allowed_chars) + + # Проверка 1: Длина (1-39 символов) + if len(nickname) > 39: + await message.answer("GitHub nickname должен быть короче 40 символов.") + return + + # Проверка 2: Только допустимые символы + if not all(char in allowed_set for char in nickname): + await message.answer("GitHub nickname может содержать только: буквы, цифры, дефисы и подчёркивания.") + return + + # Проверка 3: Не может начинаться/заканчиваться дефисом + if nickname.startswith('-') or nickname.endswith('-'): + await message.answer("GitHub nickname не может начинаться или заканчиваться дефисом.") + return + + try: + github_response = requests.get(f"https://api.github.com/users/{nickname}") + if github_response.status_code != 200: + raise HTTPException(status_code=404, detail="Пользователь GitHub не найден") + except Exception: + raise HTTPException(status_code=500, detail="Ошибка проверки GitHub пользователя") + bd.set_user_github_nickname(message.from_user.id, nickname) + await message.answer("Регистрация прошла успешно\nДобро пожаловать!", reply_markup=mp.main_keyboard) + await state.clear() + +@router.message(F.text == '👤 Профиль') +async def menu_profile(message: Message): + await message.answer(f"Ваш профиль:\nФИО: {bd.get_user_name(message.from_user.id)} {bd.get_user_patronim(message.from_user.id)} {bd.get_user_surname(message.from_user.id)}\nGithub nickname: {bd.get_user_github_nickname(message.from_user.id)}", reply_markup=mp.profile_keyboard) + +@router.message(F.text == '✏️ Редактировать профиль') +async def edit_profile(message: Message, state: FSMContext): + await message.answer("Редактирование профиля\n Введите новое имя:") + await state.set_state(ProfileEdit.WaitingForNewName) + +@router.message(ProfileEdit.WaitingForNewName) +async def set_new_user_name(message: Message, state: FSMContext): + name = message.text.strip() + if not name or not all(x.isalpha() or x.isspace() for x in name): + await message.answer("Пожалуйста, введите корректное имя.") + return + bd.set_user_name(message.from_user.id, name) + await message.answer("Введите новое отчество:") + await state.set_state(ProfileEdit.WaitingForNewPatronim) + +@router.message(ProfileEdit.WaitingForNewPatronim) +async def set_new_user_patronim(message: Message, state: FSMContext): + patronim = message.text.strip() + if not patronim or not all(x.isalpha() or x.isspace() for x in patronim): + await message.answer("Пожалуйста, введите корректное отчество.") + return + bd.set_user_patronim(message.from_user.id, patronim) + await message.answer("Введите новую фамилию:") + await state.set_state(ProfileEdit.WaitingForNewSurname) + +@router.message(ProfileEdit.WaitingForNewSurname) +async def set_new_user_surname(message: Message, state: FSMContext): + surname = message.text.strip() + if not surname or not all(x.isalpha() or x.isspace() for x in surname): + await message.answer("Пожалуйста, введите корректную фамилию.") + return + bd.set_user_surname(message.from_user.id, surname) + await message.answer("Введите новый GitHub nickname:") + await state.set_state(ProfileEdit.WaitingForNewNickname) + +@router.message(ProfileEdit.WaitingForNewNickname) +async def set_new_user_nickname(message: Message, state: FSMContext): + nickname = message.text.strip() + if not nickname: + await message.answer("Пожалуйста, введите корректный новый GitHub nickname.") + return + + # Допустимые символы для GitHub nickname (согласно документации) + allowed_chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_" + allowed_set = set(allowed_chars) + + # Проверка 1: Длина (1-39 символов) + if len(nickname) > 39: + await message.answer("GitHub nickname должен быть короче 40 символов.") + return + + # Проверка 2: Только допустимые символы + if not all(char in allowed_set for char in nickname): + await message.answer("GitHub nickname может содержать только: буквы, цифры, дефисы и подчёркивания.") + return + + # Проверка 3: Не может начинаться/заканчиваться дефисом + if nickname.startswith('-') or nickname.endswith('-'): + await message.answer("GitHub nickname не может начинаться или заканчиваться дефисом.") + return + + try: + github_response = requests.get(f"https://api.github.com/users/{nickname}") + if github_response.status_code != 200: + raise HTTPException(status_code=404, detail="Пользователь GitHub не найден") + except Exception: + raise HTTPException(status_code=500, detail="Ошибка проверки GitHub пользователя") + bd.set_user_github_nickname(message.from_user.id, nickname) + bd.set_user_github_nickname(message.from_user.id, nickname) + await message.answer("Профиль успешно отредактирован") + await state.clear() + await menu_profile(message) + +@router.message(F.text == '🔙 Назад') +async def go_back(message: Message): + await message.answer("Возвращение в главное меню", reply_markup=mp.main_keyboard) + + +@router.message(F.text == '📚 Выбрать дисциплину') +async def select_course(message: Message, state: FSMContext): + response = requests.get(f"{API_BASE_URL}/courses") + print("response:\n", response, "\n\n") + if response.status_code != 200: + await message.answer("⚠️ Ошибка получения дисциплин") + return + await message.answer("Выберите дисциплину:", reply_markup=mp.courses_keyboard(response.json())) + await state.set_state(SelectingData.SelectingCourse) + +@router.callback_query(SelectingData.SelectingCourse, F.data.startswith("course_")) +async def select_group(callback: CallbackQuery, state: FSMContext): + course_id=callback.data.split("_")[1] + await state.update_data(course_id=course_id) + data = await state.get_data() + + response=requests.get(f"{API_BASE_URL}/courses/{data["course_id"]}/groups") + if response.status_code != 200: + await callback.message.answer("⚠️ Ошибка получения групп") + return + await callback.message.edit_text("Выберите группу:", reply_markup=mp.groups_keyboard(response.json())) + await state.set_state(SelectingData.SelectingGroup) + +@router.callback_query(SelectingData.SelectingGroup, F.data.startswith("group_")) +async def select_lab(callback: CallbackQuery, state: FSMContext): + group_id=callback.data.split("_")[1] + await state.update_data(group_id=group_id) + data = await state.get_data() + + response=requests.get(f"{API_BASE_URL}/courses/{data["course_id"]}/groups/{data["group_id"]}/labs") + if response.status_code != 200: + await callback.message.answer("⚠️ Ошибка получения лабораторных работ") + return + await callback.message.edit_text("Выберите лабораторную работу:", reply_markup=mp.labs_keyboard(response.json())) + await state.set_state(SelectingData.SelectingLab) + + +@router.callback_query(SelectingData.SelectingLab, F.data.startswith("lab_")) +async def select_check(callback: CallbackQuery, state: FSMContext): + lab_id = callback.data.split("_")[1] + await state.update_data(lab_id=lab_id) + data = await state.get_data() + + await callback.message.answer( + f"Вы выбрали:\n" + f"Дисциплина: {data['course_id']}\n" + f"Группа: {data['group_id']}\n" + f"Лабораторная: {data['lab_id']}\n\n" + f"Начать проверку?", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="✅ Да", callback_data="confirm_check")], + [InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_check")] + ]) + ) + + await state.set_state(SelectingData.SelectingCheck) + +@router.callback_query(SelectingData.SelectingCheck, F.data == "confirm_check") +async def confirm_check(callback: CallbackQuery, state: FSMContext): + data = await state.get_data() + + + registration_data = { + "name": bd.get_user_name(callback.from_user.id), + "surname": bd.get_user_surname(callback.from_user.id), + "patronymic": bd.get_user_patronim(callback.from_user.id), + "github": bd.get_user_github_nickname(callback.from_user.id) + } + + reg_response = requests.post(f"{API_BASE_URL}/courses/{data["course_id"]}/groups/{data["group_id"]}/register", json=registration_data) + + if reg_response.status_code != 200: + await callback.message.answer("⚠️ Ошибка проверки данных студента.\n Проверьте свои данные или обратитесь к преподавателю.") + return + else: + await callback.message.answer("🔄 Проверка запущена.") + + grade_data = {"github": bd.get_user_github_nickname(callback.from_user.id)} + response = requests.post(f"{API_BASE_URL}/courses/{data["course_id"]}/groups/{data["group_id"]}/labs/{data["lab_id"]}/grade", json=grade_data) + result = response.json() + if response.status_code == 200 and result["result"] == '✓': + await callback.message.answer('✅ Все проверки пройдены', reply_markup=mp.main_keyboard) + else: + await callback.message.answer('❌ Обнаружены ошибки', reply_markup=mp.main_keyboard) + await state.clear() + +@router.callback_query(SelectingData.SelectingCheck, F.data == "cancel_check") +async def cancel_check(callback: CallbackQuery, state: FSMContext): + await callback.message.answer("Проверка отменена.\nПереход в главное меню", reply_markup=mp.main_keyboard) + await state.clear() + +@router.callback_query(F.data == "back_to_courses") +async def back_to_courses(callback: CallbackQuery, state: FSMContext): + response = requests.get(f"{API_BASE_URL}/courses") + print("response:\n", response, "\n\n") + if response.status_code != 200: + await callback.message.answer("⚠️ Ошибка получения дисциплин") + return + await callback.message.answer("Выберите дисциплину:", reply_markup=mp.courses_keyboard(response.json())) + await state.set_state(SelectingData.SelectingCourse) + +@router.callback_query(F.data == "back_to_groups") +async def back_to_groups(callback: CallbackQuery, state: FSMContext): + data = await state.get_data() + + response=requests.get(f"{API_BASE_URL}/courses/{data["course_id"]}/groups") + if response.status_code != 200: + await callback.message.answer("⚠️ Ошибка получения групп") + return + await callback.message.edit_text("Выберите группу:", reply_markup=mp.groups_keyboard(response.json())) + await state.set_state(SelectingData.SelectingGroup) + + + diff --git a/tg_bot/markups.py b/tg_bot/markups.py new file mode 100644 index 0000000..2bfecf4 --- /dev/null +++ b/tg_bot/markups.py @@ -0,0 +1,74 @@ +from aiogram.types import InlineKeyboardMarkup, ReplyKeyboardMarkup, InlineKeyboardButton, KeyboardButton + + +main_keyboard = ReplyKeyboardMarkup(keyboard=[[KeyboardButton(text="👤 Профиль"), + KeyboardButton(text="📚 Выбрать дисциплину")]], + resize_keyboard=True, + one_time_keyboard=True) + +profile_keyboard = ReplyKeyboardMarkup(keyboard=[[KeyboardButton(text="✏️ Редактировать профиль")], + [KeyboardButton(text="🔙 Назад")]], + resize_keyboard=True, + one_time_keyboard=True) + + + +def courses_keyboard(courses): + # Создаем список для рядов кнопок + keyboard = [] + + # Группируем кнопки по 2 в ряд + row = [] + for index, course in enumerate(courses, 1): + # Создаем кнопку для дисциплины + button = InlineKeyboardButton( + text=f"{course['name']} ({course['semester']})", + callback_data=f"course_{course['id']}" + ) + row.append(button) + + # Каждые 2 кнопки или в конце списка создаем новый ряд + if index % 2 == 0 or index == len(courses): + keyboard.append(row) + row = [] + + return InlineKeyboardMarkup(inline_keyboard=keyboard) # Явно передаем структуру + + +def groups_keyboard(groups): + buttons = [] + # Каждая группа в отдельном ряду (вертикальный список) + for group in groups: + buttons.append([ + InlineKeyboardButton( + text=group, + callback_data=f"group_{group}" + ) + ]) + # Кнопка "Назад" тоже в отдельном ряду + buttons.append([ + InlineKeyboardButton( + text="🔙 Назад к дисциплинам", + callback_data="back_to_courses" + ) + ]) + return InlineKeyboardMarkup(inline_keyboard=buttons) + +def labs_keyboard(labs): + buttons = [] + # Каждая лабораторная работа в отдельном ряду + for lab in labs: + buttons.append([ + InlineKeyboardButton( + text=lab, + callback_data=f"lab_{lab}" + ) + ]) + # Кнопка "Назад" в отдельном ряду + buttons.append([ + InlineKeyboardButton( + text="🔙 Назад к группам", + callback_data="back_to_groups" + ) + ]) + return InlineKeyboardMarkup(inline_keyboard=buttons) \ No newline at end of file From 351034bc68cdf3ed44f21c0bdc984c27198fbab6 Mon Sep 17 00:00:00 2001 From: l1eanny Date: Mon, 21 Jul 2025 11:29:07 +0300 Subject: [PATCH 2/5] update git ignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 627d2a4..5ec3ae6 100644 --- a/.gitignore +++ b/.gitignore @@ -186,4 +186,5 @@ credentials.json # В корне .gitignore tg_bot/*.db tg_bot/*.db-* -tg_bot/__pycache__/ \ No newline at end of file +tg_bot/__pycache__/ +.DS_Store \ No newline at end of file From eee3520718b73de25ab7aff28fc6cc1ef47e21e6 Mon Sep 17 00:00:00 2001 From: l1eanny Date: Mon, 21 Jul 2025 14:33:56 +0300 Subject: [PATCH 3/5] Create README.md --- README.md | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..60e0e7f --- /dev/null +++ b/README.md @@ -0,0 +1,99 @@ +# Телеграм-бот для проверки лабораторных работ + +## Запуск + +1. Создайте файл .env в корне проекта и заполните по примеру + + ADMIN_LOGIN=ваш_логин_админа + ADMIN_PASSWORD=ваш_пароль_админа + SECRET_KEY=сгенерированный_секретный_ключ + GITHUB_TOKEN=ваш_github_токен + TELEGRAM_BOT_TOKEN=токен_вашего_бота + +2. Создайте файл credentials.json в корне проекта +3. В терминале выполнить команду: + + docker compose up --build + +## Команды бота +- /start - Запуск бота, регистрация студента, главное меню студента (для авторизованных) + +## Как получить значения переменных окружения и файла credentials.json +### Файл .env: +- ADMIN_LOGIN/ADMIN_PASSWORD + + Произвольные логин и пароль администратора приложения + +- SECRET_KEY + + Переменная известная только вам + +- GITHUB_TOKEN + + 1. Перейдите: GitHub Settings → Developer Settings → Personal Access Tokens → Generate new token + + 2. Выберите scopes: repo, admin:org, user + + 3. Скопируйте токен (отображается только один раз!) + +- TELEGRAM_BOT_TOKEN + + 1. Создайте бота через @BotFather + + 2. Используйте команду /newbot + + 3. Скопируйте токен из сообщения BotFather + +### Файл credentials.json: + +Необходим для работы с Google Sheets API. + +Инструкция получения: + +1. Создайте проект в Google Cloud Console + +2. Включите API: + + - Google Sheets API + + - Google Drive API + +3. Создайте сервисный аккаунт: + + - APIs & Services → Credentials → Create Credentials → Service Account + + - Заполните имя (например app-service-account) + + - Роль: Project → Editor + +4. Сгенерируйте ключ: + + В настройках сервисного аккаунта → Keys → Add Key → Create new key → JSON + +5. Скачанный файл поместите в корень проекта как credentials.json + +## Доступ к приложению +- Веб-интерфейс: http://localhost:5173 + +- Админ-панель: http://localhost:5173/admin + +- Telegram-бот: @lab_auditor_bot + +## Важные примечания +1. Не коммитьте конфиденциальные файлы: + + + .env + credentials.json +2. Для доступа к Google Sheets: + + - Откройте нужную таблицу + + - Нажмите "Share" + + - Добавьте email из credentials.json (поле client_email) + +3. При изменении портов обновите docker-compose.yml: + + ports: + - "Новый_порт:8000" From feda4122b19e83461ac6275e98b5987fd6a5fc1c Mon Sep 17 00:00:00 2001 From: l1eanny Date: Thu, 24 Jul 2025 19:38:28 +0300 Subject: [PATCH 4/5] Delete .DS_Store --- .DS_Store | Bin 8196 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index d3314d842230fe30e0be8e5a32409e79497a8706..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8196 zcmeHM&5P4O6o1nXn_7j!?m}+{ycTV}?24Cctw%wuD56r^O?MkiGo?u>QVQ{^2Vrk| z63<>%Jcxh5UcGr;!Gj=r5dQ(+d~D4m&F-zp?o65aP3OIr_wswmq%Q>k(iq!yfC>N@ z*cp~fI4o#P&U&I{{uc)Dna!G!^4u3v zJ!%Ft1E-P!wm+EI8CnuUfqd)0ftLV?Ib7xq=VdlQG$mRRLxCv4MuaJ%FeS3YAi^Bi zO{r%|3_^@We6Y|I^61wA8+y=DJj05uN~693LigtZ_Z_hx z(0|?+YbgGKzoIBX8$UyM3R}>@-p5FAMU7?iCB~AXsm2ALzl*q1wWBc}r>(|&i}6*! z7%7Zl1U^RUcPIW(X5?dc=sJE-t9>DvY%YJMU=)m!@u=0OlUC3P#(twSdceNjq15dj z&O6<^PH)mKuU@BK&~d!ph#NR|4^Sv%bDD ztD4iRS87#reWNy;6^*rvm#^J!KXC^hea^zh%~)>Qa9(_$k1uc*J1dp_+DcMGWj!>1 z2-aiAiCqyXS_@dt;T7p1!|L6T`ST1Tnag4^+ZfPVf*Dlz^1=U3tB8~y?%=>yLI From 8c55e366619a5f21a7fd613d0a6a348fa22aa9b7 Mon Sep 17 00:00:00 2001 From: l1eanny Date: Thu, 24 Jul 2025 19:39:56 +0300 Subject: [PATCH 5/5] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 60e0e7f..c3da567 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ - SECRET_KEY - Переменная известная только вам + Переменная хранящая секретный ключ для безопасности приложения. - GITHUB_TOKEN