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 = ` +
Нажмите "Обновить список" для загрузки данных
+ +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 %} + +
Управление поздравлениями сотрудников
++ + +
| Дата рождения | +Имя | +Короткое имя | +Полная дата (возраст) |
+ Пол | +Специальности | +Статус | +Данные | +
|---|---|---|---|---|---|---|---|
|
+
+ Загрузка...
+
+ |
+ |||||||
+ + + +
- -
{% 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 @@ + + + +
+ +
+ + + + + + +
+ +