diff --git a/app.db b/app.db index 67f578d..557a4b4 100644 Binary files a/app.db and b/app.db differ diff --git a/app.py b/app.py index 5152801..eb8f2a0 100644 --- a/app.py +++ b/app.py @@ -1,8 +1,25 @@ -from flask import Flask, request, jsonify, render_template +from flask import Flask, redirect, request, jsonify, render_template, url_for from config import Config -from db import PostScheduler, UsersMedods, VkAPI, VkPost, db, MedodsAPI, ApiEndpoint -from medods_handler import updateMedodsUsers -from scheduler import get_scheduler_status, init_scheduler, enable_publish_job +from db import ( + BirthdateScheduler, + PostScheduler, + Protection, + UsersBirthdate, + UsersMedods, + VkAPI, + VkPost, + db, + MedodsAPI, + ApiEndpoint, +) +from medods_handler import updateMedodsUsers, updateUsersBirthdate +from scheduler import ( + enable_birthdate_job, + get_birthdate_scheduler_status, + get_scheduler_status, + init_scheduler, + enable_publish_job, +) from http_client import send_request import logging import os @@ -139,7 +156,7 @@ def vk(): @app.route("/posts", methods=["GET"]) def posts(): medodsUsers = UsersMedods.query.all() - if len(medodsUsers) > 0: + if medodsUsers: medodsUsers = [user.toDict() for user in medodsUsers] vkPost = VkPost.query.first() if vkPost: @@ -159,6 +176,21 @@ def posts(): ) +@app.route("/birthdate", methods=["GET"]) +def birthdate(): + schedulerStatus = get_birthdate_scheduler_status() + schedulerSettings = BirthdateScheduler.query.first() + if schedulerSettings: + schedulerSettings = schedulerSettings.toDict() + return render_template( + "birthdate.html", + data={ + "schedulerStatus": schedulerStatus, + "schedulerSettings": schedulerSettings, + }, + ) + + @app.route("/api/medods", methods=["POST"]) def api_medods(): try: @@ -273,7 +305,8 @@ def api_requests(): logger.info("Получен список запросов") requestsDB = ApiEndpoint.query.all() - requestsList = [r.toDict() for r in requestsDB] + if requestsDB: + requestsList = [r.toDict() for r in requestsDB] return jsonify( { "status": "ok", @@ -286,6 +319,79 @@ def api_requests(): return jsonify({"status": "error"}), 405 +@app.route("/api/birthdate", methods=["GET", "POST", "PATCH"]) +def api_birthdate(): + match request.method: + case "POST": + reqData = request.json + userUpdate = reqData["userUpdate"] + if userUpdate: + logger.info("Обновлен пользователь") + userId = userUpdate["userId"] + userData = userUpdate["userData"] + try: + userDB = UsersBirthdate.query.filter_by(id=userId).first() + userDB.photo_link = userData["photoLink"] + userDB.congratulations = userData["congratulations"] + db.session.commit() + return jsonify({"status": "ok"}) + except Exception as e: + logger.error(f"Ошибка при обновлении пользователя: {e}") + return jsonify({"status": "error"}), 500 + scheduleSettings = reqData["scheduleSettings"] + if scheduleSettings: + logger.info("Обновлены настройки расписания") + try: + scheduleDB = BirthdateScheduler.query.first() + if scheduleDB: + scheduleDB.hour = scheduleSettings["hour"] + scheduleDB.minute = scheduleSettings["minute"] + scheduleDB.enabled = scheduleSettings["enabled"] + else: + scheduleDB = BirthdateScheduler( + hour=scheduleSettings["hour"], + minute=scheduleSettings["minute"], + enabled=scheduleSettings["enabled"], + ) + db.session.add(scheduleDB) + db.session.commit() + enable_birthdate_job() + scheduleInfo = get_birthdate_scheduler_status() + return jsonify( + { + "status": "ok", + "next_run_time": scheduleInfo.get("next_run_time"), + } + ) + except Exception as e: + logger.error(f"Ошибка при обновлении настройки расписания: {e}") + return jsonify({"status": "error"}), 500 + + case "GET": + logger.info("Получен список пользователей") + + users = UsersBirthdate.query.all() + if users: + users = [u.toDict() for u in users] + return jsonify( + { + "status": "ok", + "users": users, + } + ) + + case "PATCH": + success = updateUsersBirthdate() + if success: + return jsonify({"status": "ok"}) + else: + return jsonify({"status": "error"}), 500 + + case _: + logger.error("Неверный метод запроса") + return jsonify({"status": "error"}), 405 + + @app.route("/api/vk", methods=["POST"]) def api_vk(): requestData = request.json @@ -410,13 +516,84 @@ def api_posts(): return jsonify({"status": "error"}), 405 +@app.route("/login", methods=["GET", "POST", "PATCH"]) +def login(): + match request.method: + case "GET": + return render_template("login.html") + case "POST": + data = request.get_json() + password = data.get("password") + + protection = Protection.query.first() + if protection: + if not protection.verify_password(password): + return ( + jsonify({"status": "error", "errorMessage": "Неверный пароль"}), + 401, + ) + else: + protection.generate_token() + db.session.commit() + else: + protection = Protection() + protection.set_password(password) + protection.generate_token() + + db.session.add(protection) + db.session.commit() + + return jsonify({"status": "ok", "token": protection.token}), 200 + case "PATCH": + data = request.get_json() + new_password = data.get("password") + + if not new_password: + return {"status": "error", "errorMessage": "Пароль не задан"}, 400 + + protection = Protection.query.first() + if not protection: + protection = Protection() + protection.set_password(password) + protection.generate_token() + + db.session.add(protection) + db.session.commit() + else: + protection.set_password(password) + db.session.commit() + + return {"status": "ok"} + case _: + logger.error("Неверный метод запроса") + return jsonify({"status": "error"}), 405 + + def init_app(): with app.app_context(): db.create_all() enable_publish_job() + enable_birthdate_job() logger.info("Приложение запущено") +@app.before_request +def check_auth_cookie(): + endpoint = request.endpoint + + if endpoint is None: + return + + if endpoint == "login" or endpoint.startswith("static"): + return + + p = Protection.query.first() + + if not p or not p.verify_token(request.cookies.get("auth_token")): + return redirect(url_for("login")) + + if __name__ == "__main__": init_app() - app.run(host="0.0.0.0", port=80) + app.run(debug=True, host="0.0.0.0", port=80) + # app.run(host="0.0.0.0", port=80) diff --git a/db.py b/db.py index 298e137..4647973 100644 --- a/db.py +++ b/db.py @@ -1,7 +1,11 @@ from flask_sqlalchemy import SQLAlchemy from datetime import datetime +import secrets +from argon2 import PasswordHasher +from argon2.exceptions import VerifyMismatchError db = SQLAlchemy() +ph = PasswordHasher() class MedodsAPI(db.Model): @@ -67,6 +71,66 @@ class VkAPI(db.Model): } +class UsersBirthdate(db.Model): + __tablename__ = "users_birthdate" + + id = db.Column(db.Integer, primary_key=True) + enabled = db.Column(db.Boolean, default=True) + name = db.Column(db.Text) + short_name = db.Column(db.Text) + sex = db.Column(db.Text) + birthdate = db.Column(db.Date) + photo_link = db.Column(db.Text, nullable=True) + congratulations = db.Column(db.Text, nullable=True) + specialties = db.Column(db.JSON) + post_link = db.Column(db.Text, nullable=True) + publish_at = db.Column(db.DateTime, nullable=True) + created_at = db.Column(db.DateTime, default=datetime.now) + updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now) + + def toDict(self): + return { + "id": self.id, + "enabled": self.enabled, + "name": self.name, + "shortName": self.short_name, + "sex": self.sex, + "birthdate": self.birthdate.strftime("%Y-%m-%d"), + "photoLink": self.photo_link, + "congratulations": self.congratulations, + "specialties": self.specialties, + "postLink": self.post_link, + "publishAt": ( + self.publish_at.strftime("%Y-%m-%d %H:%M:%S") + if self.publish_at + else None + ), + "created_at": self.created_at.strftime("%Y-%m-%d %H:%M:%S"), + "updated_at": self.updated_at.strftime("%Y-%m-%d %H:%M:%S"), + } + + +class BirthdateScheduler(db.Model): + __tablename__ = "birthdate_scheduler" + + id = db.Column(db.Integer, primary_key=True) + hour = db.Column(db.Integer) + minute = db.Column(db.Integer) + enabled = db.Column(db.Boolean) + created_at = db.Column(db.DateTime, default=datetime.now) + updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now) + + def toDict(self): + return { + "id": self.id, + "hour": self.hour, + "minute": self.minute, + "enabled": self.enabled, + "created_at": self.created_at.strftime("%Y-%m-%d %H:%M:%S"), + "updated_at": self.updated_at.strftime("%Y-%m-%d %H:%M:%S"), + } + + class UsersMedods(db.Model): __tablename__ = "users_medods" @@ -138,3 +202,47 @@ class PostScheduler(db.Model): "created_at": self.created_at.strftime("%Y-%m-%d %H:%M:%S"), "updated_at": self.updated_at.strftime("%Y-%m-%d %H:%M:%S"), } + + +class Protection(db.Model): + __tablename__ = "protection" + + id = db.Column(db.Integer, primary_key=True) + password = db.Column(db.Text, nullable=False) # hash + token = db.Column(db.Text, nullable=False) + + # ========================= + # Пароль + # ========================= + + def set_password(self, raw_password: str) -> None: + """ + Хэширует и сохраняет пароль (Argon2) + """ + self.password = ph.hash(raw_password) + + def verify_password(self, raw_password: str) -> bool: + """ + Проверяет пароль + """ + try: + return ph.verify(self.password, raw_password) + except VerifyMismatchError: + return False + + # ========================= + # Token + # ========================= + + def generate_token(self) -> str: + """ + Генерирует новый token и сохраняет его + """ + self.token = secrets.token_urlsafe(32) + return self.token + + def verify_token(self, token: str) -> bool: + """ + Проверяет token + """ + return bool(token) and secrets.compare_digest(self.token, token) diff --git a/medods_handler.py b/medods_handler.py index 4c0c07d..175ac43 100644 --- a/medods_handler.py +++ b/medods_handler.py @@ -1,8 +1,83 @@ import datetime -from db import ApiEndpoint, MedodsAPI, UsersMedods, VkPost, db +from db import ApiEndpoint, MedodsAPI, UsersBirthdate, UsersMedods, VkPost, db from http_client import send_request +def updateUsersBirthdate() -> bool: + from app import logger + + try: + medodsApi = MedodsAPI.query.first() + if not medodsApi: + return False + requestParams = ApiEndpoint.query.filter_by(title="Список сотрудников").first() + if not requestParams: + return False + response = send_request( + requestParams.method, + f"{medodsApi.url}{requestParams.url_path}", + params=requestParams.query_params, + ) + if not response: + logger.error("Ответ не получен") + return False + responseData = response.json() + usersFromDB = [] + for user in responseData["data"]: + if user["birthdate"] is None: + continue + userDict = { + "id": int(user["id"]), + "name": f"{user['surname']} {user['name']} {user['secondName']}", + "short_name": f"{user['surname']} {user['name'][:1]}. {user['secondName'][:1]}.", + "sex": user["sex"], + "birthdate": datetime.date.fromisoformat(user["birthdate"]), + "specialties": [spec["title"] for spec in user["specialties"]], + } + usersFromDB.append(userDict) + actualUsersIds = [user["id"] for user in usersFromDB] + allExistingUsers = UsersBirthdate.query.all() + for user in allExistingUsers: + if user.id not in actualUsersIds: + logger.info(f"Удален сотрудник {user.name} {user.surname}") + db.session.delete(user) + db.session.commit() + + for user in usersFromDB: + existingUser = UsersMedods.query.filter_by(id=user["id"]).first() + if existingUser: + changes = False + if existingUser.name != user["name"]: + existingUser.name = user["name"] + existingUser.short_name = user["short_name"] + changes = True + if existingUser.birthdate != user["birthdate"]: + existingUser.birthdate = user["birthdate"] + changes = True + if existingUser.specialties != user["specialties"]: + existingUser.specialties = user["specialties"] + changes = True + + if changes: + db.session.commit() + else: + newUser = UsersMedods( + id=user["id"], + name=user["name"], + short_name=user["short_name"], + sex=user["sex"], + birthdate=user["birthdate"], + specialties=user["specialties"], + ) + db.session.add(newUser) + db.session.commit() + + return True + except Exception as e: + logger.error(f"Ошибка при обновлении списка сотрудников: {e}") + return False + + def updateMedodsUsers() -> bool: from app import logger diff --git a/pyproject.toml b/pyproject.toml index 6771305..1b721c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,7 @@ readme = "README.md" requires-python = ">=3.13" dependencies = [ "apscheduler>=3.11.1", + "argon2-cffi>=25.1.0", "flask>=3.1.2", "flask-sqlalchemy>=3.1.1", "pyjwt>=2.10.1", diff --git a/scheduler.py b/scheduler.py index 6b1c066..469e249 100644 --- a/scheduler.py +++ b/scheduler.py @@ -1,7 +1,7 @@ from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.cron import CronTrigger -from db import PostScheduler +from db import BirthdateScheduler, PostScheduler # ========================= # Flask app (будет установлен из app.py) @@ -13,6 +13,7 @@ flask_app = None # ========================= scheduler: BackgroundScheduler | None = None JOB_ID = "vk_publish_job" +BIRTHDATE_JOB_ID = "vk_birthdate_job" def clearLog(): @@ -60,6 +61,19 @@ def vk_publish_job(): handle_vk_post() +def vk_birthdate_job(): + """ + Обёртка для APScheduler + """ + if flask_app is None: + raise RuntimeError("Scheduler is not initialized with Flask app") + + from vk_handler import handle_vk_birthdate + + with flask_app.app_context(): + handle_vk_birthdate() + + # ========================= # Enable job # ========================= @@ -89,6 +103,32 @@ def enable_publish_job(): ) +def enable_birthdate_job(): + """ + Включает выполнение публикации постов + """ + if not scheduler: + return + + scheduleData = BirthdateScheduler.query.first() + if not scheduleData or not scheduleData.enabled: + disable_birthdate_job() + return + + trigger = CronTrigger( + hour=f"{scheduleData.hour}", + minute=f"{scheduleData.minute}", + day="*", + ) + + scheduler.add_job( + vk_birthdate_job, + trigger=trigger, + id=BIRTHDATE_JOB_ID, + replace_existing=True, + ) + + # ========================= # Disable job # ========================= @@ -97,6 +137,11 @@ def disable_publish_job(): scheduler.remove_job(JOB_ID) +def disable_birthdate_job(): + if scheduler and scheduler.get_job(BIRTHDATE_JOB_ID): + scheduler.remove_job(BIRTHDATE_JOB_ID) + + # ========================= # Status # ========================= @@ -113,3 +158,18 @@ def get_scheduler_status() -> dict: else None ), } + + +def get_birthdate_scheduler_status() -> dict: + scheduler_running = bool(scheduler and scheduler.running) + job = scheduler.get_job(BIRTHDATE_JOB_ID) if scheduler_running else None + + return { + "scheduler": scheduler_running, + "vk_birthdate_job": job is not None, + "next_run_time": ( + job.next_run_time.strftime("%Y-%m-%d %H:%M:%S") + if job and job.next_run_time + else None + ), + } diff --git a/static/css/birthdate.css b/static/css/birthdate.css new file mode 100644 index 0000000..098b800 --- /dev/null +++ b/static/css/birthdate.css @@ -0,0 +1,270 @@ + +/* Цвет для темы дней рождения */ +.bg-pink { + background-color: #e83e8c !important; +} + +.text-pink { + color: #e83e8c !important; +} + +/* Карточки */ +.card { + border-radius: 10px; + border: 1px solid #e9ecef; + transition: all 0.3s ease; +} + +.card:hover { + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.1); +} + +.card-header { + background-color: rgba(0, 0, 0, 0.02); + border-bottom: 1px solid #e9ecef; + padding: 1rem 1.25rem; +} + +/* Таблица сотрудников */ +.table-responsive { + max-height: 600px; + overflow-y: auto; +} + +.table { + margin-bottom: 0; +} + +.table thead th { + position: sticky; + top: 0; + background-color: #f8f9fa; + z-index: 10; + font-weight: 600; + text-transform: uppercase; + font-size: 0.85rem; + color: #6c757d; + border-bottom: 2px solid #dee2e6; + vertical-align: middle; +} + +.table tbody tr { + transition: all 0.2s ease; + cursor: pointer; +} + +.table tbody tr:hover { + background-color: rgba(232, 62, 140, 0.05); +} + +.table tbody tr.selected { + background-color: rgba(232, 62, 140, 0.1); +} + +.table tbody td { + vertical-align: middle; +} + +/* Иконки статуса */ +.status-icon { + width: 24px; + height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 50%; + font-size: 0.8rem; +} + +.status-enabled { + background-color: rgba(25, 135, 84, 0.1); + color: #198754; +} + +.status-disabled { + background-color: rgba(108, 117, 125, 0.1); + color: #6c757d; +} + +.status-data { + background-color: rgba(13, 110, 253, 0.1); + color: #0d6efd; +} + +.status-nodata { + background-color: rgba(220, 53, 69, 0.1); + color: #dc3545; +} + +/* Бейджи для специализаций */ +.specialty-badge { + font-size: 0.75rem; + padding: 0.2em 0.4em; + margin: 0.1rem; +} + +/* Поля ввода */ +.form-control:focus, +.form-select:focus { + border-color: #e83e8c; + box-shadow: 0 0 0 0.25rem rgba(232, 62, 140, 0.25); +} + +/* Свитч */ +.form-switch .form-check-input { + width: 3em; + height: 1.5em; +} + +.form-switch .form-check-input:checked { + background-color: #e83e8c; + border-color: #e83e8c; +} + +/* Кнопки */ +.btn { + border-radius: 6px; + font-weight: 500; + transition: all 0.2s ease; +} + +.btn-outline-secondary:hover { + background-color: #6c757d; + color: white; +} + +/* Дата рождения */ +.birthdate-cell { + font-family: 'SF Mono', Monaco, 'Cascadia Code', Consolas, 'Courier New', monospace; + font-weight: 600; + color: #495057; +} + +.age-badge { + font-size: 0.75rem; + background-color: #e9ecef; + color: #6c757d; + padding: 0.2em 0.6em; + border-radius: 10px; +} + +/* Пол */ +.sex-badge { + font-size: 0.75rem; + padding: 0.3em 0.6em; +} + +.sex-male { + background-color: rgba(13, 110, 253, 0.1); + color: #0d6efd; +} + +.sex-female { + background-color: rgba(232, 62, 140, 0.1); + color: #e83e8c; +} + +/* Уведомления */ +.alert-fixed { + position: fixed; + top: 80px; + right: 20px; + z-index: 1050; + min-width: 300px; + max-width: 400px; +} + +/* Модальное окно */ +.modal-content { + border-radius: 10px; + border: none; + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); +} + +/* Анимации */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.fade-in { + animation: fadeIn 0.3s ease-in; +} + +/* Адаптивность */ +@media (max-width: 768px) { + .table-responsive { + font-size: 0.9rem; + } + + .table thead th { + font-size: 0.8rem; + } + + .specialty-badge { + font-size: 0.7rem; + } + + .btn { + padding: 0.375rem 0.75rem; + font-size: 0.875rem; + } +} + +/* Пустая таблица */ +.text-center.py-5 { + color: #6c757d; +} + +/* Информация о посте */ +#postInfo .alert { + border-left: 4px solid #0dcaf0; +} + +/* Иконки в таблице */ +.icon-group { + display: flex; + gap: 0.5rem; + justify-content: center; +} + +/* Месяц и дата рождения */ +.month-day { + font-weight: 600; + color: #212529; +} + +/* Зодиак (опционально) */ +.zodiac-badge { + font-size: 0.7rem; + background-color: #f8f9fa; + color: #6c757d; + padding: 0.15em 0.4em; + border-radius: 4px; + margin-top: 0.2rem; +} + +/* Скроллбар */ +.table-responsive::-webkit-scrollbar { + width: 6px; +} + +.table-responsive::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 3px; +} + +.table-responsive::-webkit-scrollbar-thumb { + background: #888; + border-radius: 3px; +} + +.table-responsive::-webkit-scrollbar-thumb:hover { + background: #555; +} \ No newline at end of file diff --git a/static/js/base.js b/static/js/base.js new file mode 100644 index 0000000..5a699fa --- /dev/null +++ b/static/js/base.js @@ -0,0 +1,87 @@ +function deleteAuthToken() { + document.cookie = "auth_token=; expires=Thu, 01 Jan 1970 00:00:00 GMT;"; + window.location.href = "/login"; +} + +// Вспомогательные функции для уведомлений +function showAlert(type, message) { + const alertContainer = document.getElementById('alertContainer'); + + // Очищаем старые алерты + alertContainer.innerHTML = ''; + + // Создаем новый алерт + const alert = document.createElement('div'); + alert.className = `alert alert-${type} alert-dismissible fade show shadow`; + + // Иконка для типа алерта + const icon = getAlertIcon(type); + + alert.innerHTML = ` +
+ +
${message}
+ +
+ `; + + alertContainer.appendChild(alert); + + // Автоматическое удаление через 5 секунд + setTimeout(() => { + if (alert.parentNode) { + alert.classList.remove('show'); + setTimeout(() => alert.remove(), 150); + } + }, 5000); +} + +function getAlertIcon(type) { + const icons = { + 'success': 'bi-check-circle-fill', + 'warning': 'bi-exclamation-triangle-fill', + 'danger': 'bi-x-circle-fill', + 'info': 'bi-info-circle-fill' + }; + return icons[type] || 'bi-info-circle-fill'; +} + +document.getElementById("changePasswordForm").addEventListener("submit", async function (e) { + e.preventDefault(); // ❌ перезагрузка страницы + + const passwordInput = document.getElementById("newPassword"); + const newPassword = passwordInput.value.trim(); + + if (!newPassword) { + showAlert("warning", "Введите новый пароль"); + return; + } + + try { + const response = await fetch("/login", { + method: "PATCH", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ password: newPassword }) + }); + + const data = await response.json(); + + if (data.status === "ok") { + showAlert("success", "Пароль успешно изменён"); + + // очистить поле + passwordInput.value = ""; + + // закрыть dropdown + const btn = document.getElementById("changePasswordBtn"); + const dropdown = bootstrap.Dropdown.getOrCreateInstance(btn); + dropdown.hide(); + } else { + showAlert("danger", data.errorMessage || "Ошибка изменения пароля"); + } + } catch (err) { + showAlert("danger", "Ошибка соединения с сервером"); + } +}); \ No newline at end of file diff --git a/static/js/birthdate.js b/static/js/birthdate.js new file mode 100644 index 0000000..0dfd0c6 --- /dev/null +++ b/static/js/birthdate.js @@ -0,0 +1,554 @@ +// Глобальные переменные +let usersData = []; +let selectedUserId = null; +let selectedUserName = ''; +let userFormChanged = false; +let schedulerFormChanged = false; +let originalUserData = null; +let originalSchedulerData = null; +let pendingUserSwitch = null; + +// Инициализация при загрузке страницы +document.addEventListener('DOMContentLoaded', function () { + // Загружаем список сотрудников + loadUsersList(); + + // Сохраняем оригинальные настройки планировщика + saveOriginalSchedulerData(); + + // Устанавливаем обработчики событий + setupEventListeners(); +}); + +// Загрузка списка сотрудников +async function loadUsersList() { + try { + const response = await fetch('/api/birthdate'); + const data = await response.json(); + + if (data.status === 'ok') { + usersData = data.users; + renderUsersTable(); + } else { + showAlert('danger', 'Ошибка загрузки списка сотрудников'); + } + } catch (error) { + console.error('Ошибка:', error); + showAlert('danger', 'Ошибка загрузки списка сотрудников'); + renderUsersTable(); // Рендерим пустую таблицу + } +} + +// Отображение таблицы сотрудников +function renderUsersTable() { + const tbody = document.getElementById('usersTableBody'); + + if (!usersData || usersData.length === 0) { + tbody.innerHTML = ` + + + +
Нет данных о сотрудниках
+

Нажмите "Обновить список" для загрузки данных

+ + + + `; + return; + } + + // Сортируем по дате рождения (месяц и день) + usersData.sort((a, b) => { + const dateA = new Date(a.birthdate); + const dateB = new Date(b.birthdate); + + // Извлекаем месяц и день + const monthA = dateA.getMonth(); + const dayA = dateA.getDate(); + const monthB = dateB.getMonth(); + const dayB = dateB.getDate(); + + // Сначала сравниваем месяцы, потом дни + return monthA !== monthB ? monthA - monthB : dayA - dayB; + }); + + let html = ''; + + usersData.forEach(user => { + const birthdate = new Date(user.birthdate); + const now = new Date(); + const age = now.getFullYear() - birthdate.getFullYear(); + const month = birthdate.getMonth() + 1; + const day = birthdate.getDate(); + const fullDate = birthdate.toLocaleDateString('ru-RU'); + + // Форматируем месяц и день (двузначные) + const monthStr = month.toString().padStart(2, '0'); + const dayStr = day.toString().padStart(2, '0'); + + // Определяем пол + const sexBadge = user.sex === 'male' ? + 'М' : + 'Ж'; + + // Специальности + const specialtiesHtml = user.specialties && user.specialties.length > 0 ? + user.specialties.map(s => `${s}`).join('') : + '-'; + + // Статус enabled + const enabledStatus = user.enabled ? + '
' : + '
'; + + // Статус данных (фото и текст) + const hasPhoto = user.photoLink && user.photoLink.trim() !== ''; + const hasText = user.congratulations && user.congratulations.trim() !== ''; + const hasData = hasPhoto && hasText; + + const dataStatus = hasData ? + '
' : + '
'; + + // Определяем класс строки (выделение выбранного) + const isSelected = selectedUserId === user.id ? 'selected' : ''; + + html += ` + + +
+ ${monthStr}.${dayStr} +
+ + + ${user.name} + + ${user.shortName} + +
${fullDate}
+ ${age} лет + + ${sexBadge} + ${specialtiesHtml} + ${enabledStatus} + +
${dataStatus}
+ + + `; + }); + + tbody.innerHTML = html; +} + +// Выбор сотрудника +function selectUser(userId) { + // Если есть несохраненные изменения + if (userFormChanged && selectedUserId !== null) { + pendingUserSwitch = userId; + document.getElementById('modalUserName').textContent = selectedUserName; + const modal = new bootstrap.Modal(document.getElementById('confirmModal')); + modal.show(); + return; + } + + // Выполняем переключение + performUserSwitch(userId); +} + +// Выполнение переключения сотрудника +function performUserSwitch(userId) { + const user = usersData.find(u => u.id === userId); + if (!user) return; + + // Обновляем выделение в таблице + document.querySelectorAll('#usersTableBody tr').forEach(row => { + row.classList.remove('selected'); + }); + document.querySelector(`tr[data-user-id="${userId}"]`).classList.add('selected'); + + // Сохраняем данные + selectedUserId = userId; + selectedUserName = user.name; + + // Обновляем заголовок + document.getElementById('selectedUserName').textContent = user.name; + + // Заполняем форму + document.getElementById('userId').value = user.id; + document.getElementById('user_enabled').checked = user.enabled; + document.getElementById('photo_link').value = user.photoLink || ''; + document.getElementById('congratulations').value = user.congratulations || ''; + + // Показываем информацию о посте, если есть + const postInfo = document.getElementById('postInfo'); + const postLinkContainer = document.getElementById('postLinkContainer'); + const publishTime = document.getElementById('publishTime'); + + if (user.postLink) { + postInfo.style.display = 'block'; + postLinkContainer.innerHTML = ` + + Перейти к посту в VK + + `; + publishTime.innerHTML = `Опубликовано: ${user.publishAt || 'неизвестно'}`; + } else { + postInfo.style.display = 'none'; + } + + // Сохраняем оригинальные данные для сравнения + saveOriginalUserData(); + + // Сбрасываем флаг изменений + userFormChanged = false; + updateSaveUserButton(); +} + +// Отметка изменений в форме сотрудника +function markUserChanged() { + userFormChanged = true; + updateSaveUserButton(); +} + +// Отметка изменений в форме планировщика +function markSchedulerChanged() { + schedulerFormChanged = true; + updateSaveSchedulerButton(); +} + +// Обновление кнопки сохранения сотрудника +function updateSaveUserButton() { + const button = document.getElementById('saveUserButton'); + button.disabled = !userFormChanged; +} + +// Обновление кнопки сохранения планировщика +function updateSaveSchedulerButton() { + const button = document.getElementById('saveSchedulerButton'); + button.disabled = !schedulerFormChanged; +} + +// Сохранение оригинальных данных сотрудника +function saveOriginalUserData() { + if (!selectedUserId) return; + + originalUserData = { + enabled: document.getElementById('user_enabled').checked, + photoLink: document.getElementById('photo_link').value, + congratulations: document.getElementById('congratulations').value + }; +} + +// Сохранение оригинальных данных планировщика +function saveOriginalSchedulerData() { + originalSchedulerData = { + hour: parseInt(document.getElementById('scheduler_hour').value) || 9, + minute: parseInt(document.getElementById('scheduler_minute').value) || 0, + enabled: document.getElementById('scheduler_enabled').checked + }; +} + +// Сброс формы сотрудника +function resetUserForm() { + if (!selectedUserId) return; + + if (userFormChanged && confirm('Отменить изменения?')) { + const user = usersData.find(u => u.id === selectedUserId); + if (user) { + document.getElementById('user_enabled').checked = user.enabled; + document.getElementById('photo_link').value = user.photoLink || ''; + document.getElementById('congratulations').value = user.congratulations || ''; + + userFormChanged = false; + updateSaveUserButton(); + saveOriginalUserData(); + } + } +} + +// Сброс формы планировщика +function resetSchedulerForm() { + if (schedulerFormChanged && confirm('Отменить изменения в настройках планировщика?')) { + if (originalSchedulerData) { + document.getElementById('scheduler_hour').value = originalSchedulerData.hour; + document.getElementById('scheduler_minute').value = originalSchedulerData.minute; + document.getElementById('scheduler_enabled').checked = originalSchedulerData.enabled; + + schedulerFormChanged = false; + updateSaveSchedulerButton(); + } + } +} + +// Сохранение данных сотрудника +async function saveUserData() { + function normalizeVkPhotoLink(link) { + if (!link) return link; + + try { + const url = new URL(link); + const z = url.searchParams.get('z'); + + if (z && z.includes('%2F')) { + url.searchParams.set('z', z.split('%2F')[0]); + } + + return url.toString(); + } catch (e) { + // если это невалидный URL — возвращаем как есть + return link; + } + } + if (!selectedUserId) { + showAlert('warning', 'Выберите сотрудника для сохранения'); + return; + } + + const userData = { + enabled: document.getElementById('user_enabled').checked, + photoLink: normalizeVkPhotoLink( + document.getElementById('photo_link').value.trim() + ), + congratulations: document.getElementById('congratulations').value.trim() + }; + // Проверка длины поздравления + if (userData.congratulations.length > 2000) { + showAlert('warning', 'Длина поздравления не должна превышать 2000 символов, сейчас - ' + userData.congratulations.length); + return; + } + + // Проверка: если фото или текст заполнены, оба должны быть заполнены + if ((userData.photoLink && !userData.congratulations) || (!userData.photoLink && userData.congratulations)) { + showAlert('warning', 'Для поздравления должны быть заполнены и фото, и текст'); + return; + } + + const postData = { + userUpdate: { + userId: selectedUserId, + userData: userData + } + }; + + try { + const response = await fetch('/api/birthdate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(postData) + }); + + const data = await response.json(); + + if (response.ok && data.status === 'ok') { + showAlert('success', 'Данные сотрудника сохранены!'); + + // Обновляем данные в локальном массиве + const userIndex = usersData.findIndex(u => u.id === selectedUserId); + if (userIndex !== -1) { + usersData[userIndex].enabled = userData.enabled; + usersData[userIndex].photoLink = userData.photoLink; + usersData[userIndex].congratulations = userData.congratulations; + + // Перерисовываем таблицу + renderUsersTable(); + } + + // Сбрасываем флаг изменений + userFormChanged = false; + updateSaveUserButton(); + saveOriginalUserData(); + } else { + showAlert('danger', 'Ошибка сохранения данных сотрудника'); + } + } catch (error) { + console.error('Ошибка:', error); + showAlert('danger', 'Ошибка сохранения данных сотрудника'); + } +} + +// Сохранение настроек планировщика +async function saveSchedulerSettings() { + const scheduleSettings = { + hour: parseInt(document.getElementById('scheduler_hour').value), + minute: parseInt(document.getElementById('scheduler_minute').value), + enabled: document.getElementById('scheduler_enabled').checked + }; + + // Валидация + if (scheduleSettings.hour < 0 || scheduleSettings.hour > 23) { + showAlert('warning', 'Час должен быть от 0 до 23'); + return; + } + + if (scheduleSettings.minute < 0 || scheduleSettings.minute > 59) { + showAlert('warning', 'Минута должна быть от 0 до 59'); + return; + } + + const postData = { + scheduleSettings: scheduleSettings + }; + + try { + const response = await fetch('/api/birthdate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(postData) + }); + + const data = await response.json(); + + if (response.ok && data.status === 'ok') { + showAlert('success', 'Настройки планировщика сохранены!'); + + // Обновляем информацию о следующем запуске + if (data.next_run_time) { + const schedulerStatus = document.getElementById('schedulerStatus'); + schedulerStatus.innerHTML = `Активен`; + + // Обновляем отображение следующего запуска (если есть соответствующий элемент) + const nextRunAlert = document.querySelector('.alert-success'); + if (nextRunAlert) { + nextRunAlert.querySelector('p').textContent = data.next_run_time; + } + } + + // Сбрасываем флаг изменений + schedulerFormChanged = false; + updateSaveSchedulerButton(); + saveOriginalSchedulerData(); + } else { + showAlert('danger', 'Ошибка сохранения настроек планировщика'); + } + } catch (error) { + console.error('Ошибка:', error); + showAlert('danger', 'Ошибка сохранения настроек планировщика'); + } +} + +// Обновление списка сотрудников +async function refreshUsersList() { + try { + const response = await fetch('/api/birthdate?action=update', { + method: 'PATCH' + }); + + const data = await response.json(); + + if (response.ok && data.status === 'ok') { + showAlert('success', 'Список сотрудников обновлен!'); + // Перезагружаем список + await loadUsersList(); + } else { + showAlert('danger', 'Ошибка обновления списка сотрудников'); + } + } catch (error) { + console.error('Ошибка:', error); + showAlert('danger', 'Ошибка обновления списка сотрудников'); + } +} + +// Включить всех сотрудников +async function enableAllUsers() { + if (!confirm('Включить поздравления для всех сотрудников?')) return; + + try { + // Находим всех сотрудников без данных + const usersWithoutData = usersData.filter(u => + !u.enabled || !u.photoLink || !u.congratulations + ); + + if (usersWithoutData.length === 0) { + showAlert('info', 'Все сотрудники уже включены'); + return; + } + + // Показываем уведомление о необходимости заполнения данных + showAlert('info', `Включено ${usersWithoutData.length} сотрудников. Не забудьте заполнить данные для поздравлений.`); + + // Обновляем статус в локальном массиве + usersData.forEach(user => { + user.enabled = true; + }); + + // Перерисовываем таблицу + renderUsersTable(); + + } catch (error) { + console.error('Ошибка:', error); + showAlert('danger', 'Ошибка включения сотрудников'); + } +} + +// Отключить всех сотрудников +async function disableAllUsers() { + if (!confirm('Отключить поздравления для всех сотрудников?')) return; + + try { + // Обновляем статус в локальном массиве + usersData.forEach(user => { + user.enabled = false; + }); + + // Перерисовываем таблицу + renderUsersTable(); + + showAlert('success', 'Все сотрудники отключены'); + + } catch (error) { + console.error('Ошибка:', error); + showAlert('danger', 'Ошибка отключения сотрудников'); + } +} + +// Обработчики событий для модального окна +function cancelSwitchUser() { + pendingUserSwitch = null; +} + +function discardUserChanges() { + userFormChanged = false; + updateSaveUserButton(); + if (pendingUserSwitch) { + performUserSwitch(pendingUserSwitch); + pendingUserSwitch = null; + } +} + +function saveAndSwitchUser() { + if (pendingUserSwitch) { + saveUserData().then(() => { + if (!userFormChanged) { + performUserSwitch(pendingUserSwitch); + pendingUserSwitch = null; + } + }); + } +} + +// Настройка обработчиков событий +function setupEventListeners() { + // Валидация полей времени + document.getElementById('scheduler_hour').addEventListener('input', function () { + let value = parseInt(this.value); + if (value < 0) this.value = 0; + if (value > 23) this.value = 23; + }); + + document.getElementById('scheduler_minute').addEventListener('input', function () { + let value = parseInt(this.value); + if (value < 0) this.value = 0; + if (value > 59) this.value = 59; + }); + + // Предотвращение закрытия страницы при несохраненных изменениях + window.addEventListener('beforeunload', function (e) { + if (userFormChanged || schedulerFormChanged) { + e.preventDefault(); + e.returnValue = ''; + } + }); +} diff --git a/static/js/index.js b/static/js/index.js index 964a09e..f581f22 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -127,14 +127,3 @@ async function refreshLogs() { console.error('Ошибка при обновлении логов:', error); } } - - -function getAlertIcon(type) { - const icons = { - 'success': 'bi-check-circle-fill', - 'warning': 'bi-exclamation-triangle-fill', - 'danger': 'bi-x-circle-fill', - 'info': 'bi-info-circle-fill' - }; - return icons[type] || 'bi-info-circle-fill'; -} \ No newline at end of file diff --git a/static/js/medods.js b/static/js/medods.js index 739149a..a4f5b1d 100644 --- a/static/js/medods.js +++ b/static/js/medods.js @@ -618,44 +618,6 @@ function downloadResponse() { URL.revokeObjectURL(url); } -// Вспомогательные функции -function showAlert(type, message) { - const alertContainer = document.getElementById('alertContainer'); - - const alert = document.createElement('div'); - alert.className = `alert alert-${type} alert-dismissible fade show shadow`; - alert.style.minWidth = '300px'; - alert.style.maxWidth = '400px'; - - alert.innerHTML = ` -
- -
${message}
- -
- `; - - alertContainer.appendChild(alert); - - // Автоматическое удаление через 4 секунды - setTimeout(() => { - if (alert.parentNode) { - alert.classList.remove('show'); - setTimeout(() => alert.remove(), 150); - } - }, 4000); -} - -function getAlertIcon(type) { - const icons = { - 'success': 'bi-check-circle-fill', - 'warning': 'bi-exclamation-triangle-fill', - 'danger': 'bi-x-circle-fill', - 'info': 'bi-info-circle-fill' - }; - return icons[type] || 'bi-info-circle-fill'; -} - function showLoader(show) { const executeButton = document.getElementById('executeButton'); if (show) { diff --git a/static/js/posts.js b/static/js/posts.js index 875f4eb..e16a816 100644 --- a/static/js/posts.js +++ b/static/js/posts.js @@ -305,46 +305,3 @@ async function updateSchedulerStatus() { console.error('Ошибка обновления статуса:', error); } } - -// Вспомогательные функции для уведомлений -function showAlert(type, message) { - const alertContainer = document.getElementById('alertContainer'); - - // Очищаем старые алерты - alertContainer.innerHTML = ''; - - // Создаем новый алерт - const alert = document.createElement('div'); - alert.className = `alert alert-${type} alert-dismissible fade show shadow`; - - // Иконка для типа алерта - const icon = getAlertIcon(type); - - alert.innerHTML = ` -
- -
${message}
- -
- `; - - alertContainer.appendChild(alert); - - // Автоматическое удаление через 5 секунд - setTimeout(() => { - if (alert.parentNode) { - alert.classList.remove('show'); - setTimeout(() => alert.remove(), 150); - } - }, 5000); -} - -function getAlertIcon(type) { - const icons = { - 'success': 'bi-check-circle-fill', - 'warning': 'bi-exclamation-triangle-fill', - 'danger': 'bi-x-circle-fill', - 'info': 'bi-info-circle-fill' - }; - return icons[type] || 'bi-info-circle-fill'; -} \ No newline at end of file diff --git a/static/js/vk.js b/static/js/vk.js index d5a142e..cde8583 100644 --- a/static/js/vk.js +++ b/static/js/vk.js @@ -101,46 +101,6 @@ async function saveVkSettings() { } } -// Вспомогательные функции для уведомлений -function showAlert(type, message) { - const alertContainer = document.getElementById('alertContainer'); - - // Создаем алерт - const alert = document.createElement('div'); - alert.className = `alert alert-${type} alert-dismissible fade show shadow`; - - // Иконка для типа алерта - const icon = getAlertIcon(type); - - alert.innerHTML = ` -
- -
${message}
- -
- `; - - alertContainer.appendChild(alert); - - // Автоматическое удаление через 5 секунд - setTimeout(() => { - if (alert.parentNode) { - alert.classList.remove('show'); - setTimeout(() => alert.remove(), 150); - } - }, 5000); -} - -function getAlertIcon(type) { - const icons = { - 'success': 'bi-check-circle-fill', - 'warning': 'bi-exclamation-triangle-fill', - 'danger': 'bi-x-circle-fill', - 'info': 'bi-info-circle-fill' - }; - return icons[type] || 'bi-info-circle-fill'; -} - // Дополнительная проверка при вводе данных document.getElementById('group_id').addEventListener('input', function (e) { this.value = this.value.replace(/[^\d]/g, ''); diff --git a/templates/base.html b/templates/base.html index 89ac764..97bfe7b 100644 --- a/templates/base.html +++ b/templates/base.html @@ -34,29 +34,62 @@ + + + +
+ + +
+ @@ -64,8 +97,11 @@
{% block content %}{% endblock %}
+ +
+ {% block scripts %}{% endblock %} diff --git a/templates/birthdate.html b/templates/birthdate.html new file mode 100644 index 0000000..ac4746e --- /dev/null +++ b/templates/birthdate.html @@ -0,0 +1,267 @@ +{% extends "base.html" %} +{% block title %}Дни рождения{% endblock %} + +{% block styles %} + +{% endblock %} + +{% block content %} + +
+
+

Дни рождения

+

Управление поздравлениями сотрудников

+
+
+ Поздравления +
+
+ + +
+ +
+
+
+
Сотрудники
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + + +
Дата рожденияИмяКороткое имяПолная дата
(возраст)
ПолСпециальностиСтатусДанные
+
+ Загрузка... +
+
+
+
+ +
+
+ + +
+ +
+
+
+ + Выберите сотрудника +
+
+
+
+ + + +
+
+ + +
+
+ + +
+ + +
+ Ссылка на изображение для поздравления. Должна быть доступна для всех. Лишние символы в + ссылке будут удалены автоматически. +
+
+ + +
+ + +
+ Не поддерживается никакая разметка. Только текст и эмоджи. Максимум 2000 символов. +
+
+ + + + + +
+ + +
+
+
+
+ + +
+
+
Настройки планировщика
+ + {% if data.schedulerStatus.scheduler %} + Активен + {% else %} + Неактивен + {% endif %} + +
+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ Планировщик будет проверять дни рождения и публиковать поздравления ежедневно в + указанное время. +
+
+ + + {% if data.schedulerStatus.next_run_time %} +
+
+ +
+
Следующий запуск
+

{{ data.schedulerStatus.next_run_time }}

+
+
+
+ {% endif %} + + +
+ + +
+
+
+
+
+
+ + + + - -
{% endblock %} {% block scripts %} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..10577d3 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,99 @@ + + + + + + Авторизация + + + + + + + + +
+
+

Вход

+ + +
+
+ +
+ + + +
+ +
+
+
+ + + + + + \ No newline at end of file diff --git a/templates/medods.html b/templates/medods.html index cc87c2e..56804ea 100644 --- a/templates/medods.html +++ b/templates/medods.html @@ -255,9 +255,6 @@ - - -
{% endblock %} {% block scripts %} diff --git a/templates/posts.html b/templates/posts.html index e6217cf..cca0321 100644 --- a/templates/posts.html +++ b/templates/posts.html @@ -9,7 +9,7 @@
-

Управление постами

+

Управление постами

Создание и планирование публикаций в VK

- -
{% endblock %} {% block scripts %} diff --git a/templates/vk.html b/templates/vk.html index 7a0bc5e..1a9f65b 100644 --- a/templates/vk.html +++ b/templates/vk.html @@ -9,7 +9,7 @@
-

VK Настройки

+

VK Настройки

Настройки для работы с VK API и сообществом

@@ -155,8 +155,6 @@
- -
{% endblock %} {% block scripts %} diff --git a/uv.lock b/uv.lock index b3282d5..cb8e6ae 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,10 @@ version = 1 revision = 3 requires-python = ">=3.13" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version < '3.14'", +] [[package]] name = "apscheduler" @@ -14,6 +18,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/58/9f/d3c76f76c73fcc959d28e9def45b8b1cc3d7722660c5003b19c1022fd7f4/apscheduler-3.11.1-py3-none-any.whl", hash = "sha256:6162cb5683cb09923654fa9bdd3130c4be4bfda6ad8990971c9597ecd52965d2", size = 64278, upload-time = "2025-10-31T18:55:41.186Z" }, ] +[[package]] +name = "argon2-cffi" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f", size = 54393, upload-time = "2025-07-30T10:01:40.97Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b", size = 29328, upload-time = "2025-07-30T10:01:41.916Z" }, + { url = "https://files.pythonhosted.org/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a", size = 31269, upload-time = "2025-07-30T10:01:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44", size = 86558, upload-time = "2025-07-30T10:01:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb", size = 92364, upload-time = "2025-07-30T10:01:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92", size = 85637, upload-time = "2025-07-30T10:01:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85", size = 91934, upload-time = "2025-07-30T10:01:47.203Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f", size = 28158, upload-time = "2025-07-30T10:01:48.341Z" }, + { url = "https://files.pythonhosted.org/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6", size = 32597, upload-time = "2025-07-30T10:01:49.112Z" }, + { url = "https://files.pythonhosted.org/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623", size = 28231, upload-time = "2025-07-30T10:01:49.92Z" }, + { url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" }, + { url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" }, + { url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" }, + { url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" }, + { url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" }, + { url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" }, + { url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" }, +] + [[package]] name = "blinker" version = "1.9.0" @@ -32,6 +79,51 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.4" @@ -243,6 +335,7 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "apscheduler" }, + { name = "argon2-cffi" }, { name = "flask" }, { name = "flask-sqlalchemy" }, { name = "pyjwt" }, @@ -253,6 +346,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "apscheduler", specifier = ">=3.11.1" }, + { name = "argon2-cffi", specifier = ">=25.1.0" }, { name = "flask", specifier = ">=3.1.2" }, { name = "flask-sqlalchemy", specifier = ">=3.1.1" }, { name = "pyjwt", specifier = ">=2.10.1" }, @@ -260,6 +354,15 @@ requires-dist = [ { name = "vk-api", specifier = ">=11.10.0" }, ] +[[package]] +name = "pycparser" +version = "2.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, +] + [[package]] name = "pyjwt" version = "2.10.1" diff --git a/vk_handler.py b/vk_handler.py index 5d3b013..6cb1c9d 100644 --- a/vk_handler.py +++ b/vk_handler.py @@ -1,13 +1,13 @@ -from datetime import datetime +from datetime import datetime, date import vk_api -from db import VkAPI, db +from db import UsersBirthdate, VkAPI, db from medods_handler import setDynamicText def handle_vk_post(): from app import logger - logger.info("Обновление поста") + logger.info("Публикация поста") vkApi = VkAPI.query.first() if not vkApi: @@ -20,33 +20,85 @@ def handle_vk_post(): return if not vkPost.dynamic_text: - # if not vkPost.dynamic_text and not vkPost.post_id: logger.info("Не требуется публикация поста") return vk_session = vk_api.VkApi(token=vkApi.access_token) vk = vk_session.get_api() - new_post = {} + new_post = vk.wall.post( + owner_id=-vkApi.group_id, + from_group=1, + message=f"{vkPost.dynamic_text}\n{vkPost.static_text}".strip(), + attachments=f"photo-{vkApi.group_id}_{vkApi.base_photo_url}", + ) + logger.info(f"Пост #{new_post.get('post_id')} создан") - if vkPost.dynamic_text: + vkPost.dynamic_text = None + vkPost.post_id = new_post.get("post_id") + vkPost.publish_at = datetime.now() + + db.session.commit() + + +def handle_vk_birthdate(): + from app import logger + from sqlalchemy import func, or_, and_ + + logger.info("Публикация поста с днем рождения") + + today = date.today() + day = f"{today.day:02d}" + month = f"{today.month:02d}" + + def is_leap_year(year: int) -> bool: + return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0) + + # Основное условие: совпадение дня и месяца + conditions = [ + and_( + func.strftime("%d", UsersBirthdate.birthdate) == day, + func.strftime("%m", UsersBirthdate.birthdate) == month, + ) + ] + + # Если 28 февраля и год НЕ високосный — добавляем родившихся 29.02 + if today.month == 2 and today.day == 28 and not is_leap_year(today.year): + conditions.append( + and_( + func.strftime("%d", UsersBirthdate.birthdate) == "29", + func.strftime("%m", UsersBirthdate.birthdate) == "02", + ) + ) + conditions.append(UsersBirthdate.enabled == True) + + birthdayUsers = UsersBirthdate.query.filter(or_(*conditions)).all() + + if not birthdayUsers: + logger.info("Нет пользователей с днем рождения") + return + + vkApi = VkAPI.query.first() + if not vkApi: + logger.error("Информация для работы не найдена") + return + + vk_session = vk_api.VkApi(token=vkApi.access_token) + vk = vk_session.get_api() + + for user in birthdayUsers: new_post = vk.wall.post( owner_id=-vkApi.group_id, from_group=1, - message=f"{vkPost.dynamic_text}\n{vkPost.static_text}".strip(), - attachments=f"photo-{vkApi.group_id}_{vkApi.base_photo_url}", + message=user.congratulations.strip(), + attachments=user.photo_link, ) + logger.info(f"Пост #{new_post.get('post_id')} создан") - # if vkPost.post_id: - # logger.info(f"Удаление поста #{vkPost.post_id}") - # vk.wall.delete(owner_id=-vkApi.group_id, post_id=vkPost.post_id) - # vkPost.post_id = None - # vkPost.publish_at = None + user.post_link = ( + f"https://vk.com/wall-{vkApi.group_id}_{new_post.get('post_id')}" + ) + user.publish_at = datetime.now() - if vkPost.dynamic_text: - vkPost.dynamic_text = None - vkPost.post_id = new_post.get("post_id") - vkPost.publish_at = datetime.now() - - db.session.commit() + db.session.commit()