This commit is contained in:
2025-12-23 01:12:10 +03:00
parent 69706d0cb7
commit 6ec4bd00e2
22 changed files with 1923 additions and 175 deletions
BIN
View File
Binary file not shown.
+183 -6
View File
@@ -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 config import Config
from db import PostScheduler, UsersMedods, VkAPI, VkPost, db, MedodsAPI, ApiEndpoint from db import (
from medods_handler import updateMedodsUsers BirthdateScheduler,
from scheduler import get_scheduler_status, init_scheduler, enable_publish_job 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 from http_client import send_request
import logging import logging
import os import os
@@ -139,7 +156,7 @@ def vk():
@app.route("/posts", methods=["GET"]) @app.route("/posts", methods=["GET"])
def posts(): def posts():
medodsUsers = UsersMedods.query.all() medodsUsers = UsersMedods.query.all()
if len(medodsUsers) > 0: if medodsUsers:
medodsUsers = [user.toDict() for user in medodsUsers] medodsUsers = [user.toDict() for user in medodsUsers]
vkPost = VkPost.query.first() vkPost = VkPost.query.first()
if vkPost: 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"]) @app.route("/api/medods", methods=["POST"])
def api_medods(): def api_medods():
try: try:
@@ -273,6 +305,7 @@ def api_requests():
logger.info("Получен список запросов") logger.info("Получен список запросов")
requestsDB = ApiEndpoint.query.all() requestsDB = ApiEndpoint.query.all()
if requestsDB:
requestsList = [r.toDict() for r in requestsDB] requestsList = [r.toDict() for r in requestsDB]
return jsonify( return jsonify(
{ {
@@ -286,6 +319,79 @@ def api_requests():
return jsonify({"status": "error"}), 405 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"]) @app.route("/api/vk", methods=["POST"])
def api_vk(): def api_vk():
requestData = request.json requestData = request.json
@@ -410,13 +516,84 @@ def api_posts():
return jsonify({"status": "error"}), 405 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(): def init_app():
with app.app_context(): with app.app_context():
db.create_all() db.create_all()
enable_publish_job() enable_publish_job()
enable_birthdate_job()
logger.info("Приложение запущено") 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__": if __name__ == "__main__":
init_app() 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)
+108
View File
@@ -1,7 +1,11 @@
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from datetime import datetime from datetime import datetime
import secrets
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
db = SQLAlchemy() db = SQLAlchemy()
ph = PasswordHasher()
class MedodsAPI(db.Model): 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): class UsersMedods(db.Model):
__tablename__ = "users_medods" __tablename__ = "users_medods"
@@ -138,3 +202,47 @@ class PostScheduler(db.Model):
"created_at": self.created_at.strftime("%Y-%m-%d %H:%M:%S"), "created_at": self.created_at.strftime("%Y-%m-%d %H:%M:%S"),
"updated_at": self.updated_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)
+76 -1
View File
@@ -1,8 +1,83 @@
import datetime 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 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: def updateMedodsUsers() -> bool:
from app import logger from app import logger
+1
View File
@@ -6,6 +6,7 @@ readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = [ dependencies = [
"apscheduler>=3.11.1", "apscheduler>=3.11.1",
"argon2-cffi>=25.1.0",
"flask>=3.1.2", "flask>=3.1.2",
"flask-sqlalchemy>=3.1.1", "flask-sqlalchemy>=3.1.1",
"pyjwt>=2.10.1", "pyjwt>=2.10.1",
+61 -1
View File
@@ -1,7 +1,7 @@
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.cron import CronTrigger
from db import PostScheduler from db import BirthdateScheduler, PostScheduler
# ========================= # =========================
# Flask app (будет установлен из app.py) # Flask app (будет установлен из app.py)
@@ -13,6 +13,7 @@ flask_app = None
# ========================= # =========================
scheduler: BackgroundScheduler | None = None scheduler: BackgroundScheduler | None = None
JOB_ID = "vk_publish_job" JOB_ID = "vk_publish_job"
BIRTHDATE_JOB_ID = "vk_birthdate_job"
def clearLog(): def clearLog():
@@ -60,6 +61,19 @@ def vk_publish_job():
handle_vk_post() 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 # 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 # Disable job
# ========================= # =========================
@@ -97,6 +137,11 @@ def disable_publish_job():
scheduler.remove_job(JOB_ID) 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 # Status
# ========================= # =========================
@@ -113,3 +158,18 @@ def get_scheduler_status() -> dict:
else None 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
),
}
+270
View File
@@ -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;
}
+87
View File
@@ -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 = `
<div class="d-flex align-items-center">
<i class="bi ${icon} me-2 fs-5"></i>
<div class="flex-grow-1">${message}</div>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
`;
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", "Ошибка соединения с сервером");
}
});
+554
View File
@@ -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 = `
<tr>
<td colspan="8" class="text-center py-5">
<i class="bi bi-people display-1 text-muted mb-3 d-block"></i>
<h5 class="text-muted">Нет данных о сотрудниках</h5>
<p class="text-muted">Нажмите "Обновить список" для загрузки данных</p>
<button type="button" class="btn btn-outline-primary" onclick="refreshUsersList()">
<i class="bi bi-arrow-clockwise me-1"></i>Обновить список
</button>
</td>
</tr>
`;
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' ?
'<span class="badge sex-badge sex-male">М</span>' :
'<span class="badge sex-badge sex-female">Ж</span>';
// Специальности
const specialtiesHtml = user.specialties && user.specialties.length > 0 ?
user.specialties.map(s => `<span class="badge bg-light text-dark specialty-badge">${s}</span>`).join('') :
'<span class="text-muted">-</span>';
// Статус enabled
const enabledStatus = user.enabled ?
'<div class="status-icon status-enabled" title="Включен"><i class="bi bi-check-lg"></i></div>' :
'<div class="status-icon status-disabled" title="Отключен"><i class="bi bi-x-lg"></i></div>';
// Статус данных (фото и текст)
const hasPhoto = user.photoLink && user.photoLink.trim() !== '';
const hasText = user.congratulations && user.congratulations.trim() !== '';
const hasData = hasPhoto && hasText;
const dataStatus = hasData ?
'<div class="status-icon status-data" title="Данные для поздравления заполнены"><i class="bi bi-check-lg"></i></div>' :
'<div class="status-icon status-nodata" title="Нет данных для поздравления"><i class="bi bi-x-lg"></i></div>';
// Определяем класс строки (выделение выбранного)
const isSelected = selectedUserId === user.id ? 'selected' : '';
html += `
<tr class="${isSelected}" onclick="selectUser(${user.id})" data-user-id="${user.id}">
<td>
<div class="birthdate-cell">
<span class="month-day">${monthStr}.${dayStr}</span>
</div>
</td>
<td>
<span class="fw-semibold">${user.name}</span>
</td>
<td>${user.shortName}</td>
<td>
<div>${fullDate}</div>
<small class="age-badge">${age} лет</small>
</td>
<td>${sexBadge}</td>
<td>${specialtiesHtml}</td>
<td class="text-center">${enabledStatus}</td>
<td class="text-center">
<div class="icon-group">${dataStatus}</div>
</td>
</tr>
`;
});
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 = `
<a href="${user.postLink}" target="_blank" class="btn btn-sm btn-outline-info">
<i class="bi bi-box-arrow-up-right me-1"></i>Перейти к посту в VK
</a>
`;
publishTime.innerHTML = `<i class="bi bi-clock me-1"></i>Опубликовано: ${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 = `<span class="badge bg-success"><i class="bi bi-play-circle me-1"></i>Активен</span>`;
// Обновляем отображение следующего запуска (если есть соответствующий элемент)
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 = '';
}
});
}
-11
View File
@@ -127,14 +127,3 @@ async function refreshLogs() {
console.error('Ошибка при обновлении логов:', error); 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';
}
-38
View File
@@ -618,44 +618,6 @@ function downloadResponse() {
URL.revokeObjectURL(url); 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 = `
<div class="d-flex align-items-center">
<i class="bi ${getAlertIcon(type)} me-2"></i>
<div class="flex-grow-1">${message}</div>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
`;
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) { function showLoader(show) {
const executeButton = document.getElementById('executeButton'); const executeButton = document.getElementById('executeButton');
if (show) { if (show) {
-43
View File
@@ -305,46 +305,3 @@ async function updateSchedulerStatus() {
console.error('Ошибка обновления статуса:', error); 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 = `
<div class="d-flex align-items-center">
<i class="bi ${icon} me-2 fs-5"></i>
<div class="flex-grow-1">${message}</div>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
`;
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';
}
-40
View File
@@ -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 = `
<div class="d-flex align-items-center">
<i class="bi ${icon} me-2 fs-5"></i>
<div class="flex-grow-1">${message}</div>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
`;
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) { document.getElementById('group_id').addEventListener('input', function (e) {
this.value = this.value.replace(/[^\d]/g, ''); this.value = this.value.replace(/[^\d]/g, '');
+40 -4
View File
@@ -34,29 +34,62 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if request.path == '/' %}active{% endif %}" href="/"> <a class="nav-link {% if request.path == '/' %}active{% endif %}" href="/">
<i class="bi bi-speedometer2 me-1"></i> Главная <i class="bi bi-speedometer2 me-2"></i>Главная
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if request.path.startswith('/medods') %}active{% endif %}" href="/medods"> <a class="nav-link {% if request.path.startswith('/medods') %}active{% endif %}" href="/medods">
<i class="bi bi-diagram-3 me-1"></i> Medods <i class="bi bi-plug me-2"></i>Medods
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if request.path.startswith('/vk') %}active{% endif %}" href="/vk"> <a class="nav-link {% if request.path.startswith('/vk') %}active{% endif %}" href="/vk">
<i class="bi bi-chat-dots me-1"></i> VK <i class="bi bi-chat-dots me-2"></i>VK
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if request.path.startswith('/posts') %}active{% endif %}" href="/posts"> <a class="nav-link {% if request.path.startswith('/posts') %}active{% endif %}" href="/posts">
<i class="bi bi-file-earmark-text me-1"></i> Посты <i class="bi bi-file-earmark-text me-2"></i>Посты
</a> </a>
</li> </li>
<li class="nav-item">
<a class="nav-link {% if request.path.startswith('/birthdate') %}active{% endif %}"
href="/birthdate">
<i class="bi bi-balloon-heart-fill text-pink me-2"></i>Дни рождения
</a>
</li>
</ul> </ul>
<div class="dropstart">
<button type="button" class="btn btn-danger dropdown-toggle" id="changePasswordBtn"
data-bs-toggle="dropdown" aria-expanded="false" data-bs-auto-close="outside">
<i class="bi bi-shield-lock-fill me-1"></i>
</button>
<div class="dropdown-menu p-4" style="width: 200px;">
<button type="button" class="btn btn-danger" onclick="deleteAuthToken()">
<i class="bi bi-box-arrow-right me-1"></i>Выйти
</button>
<div class="dropdown-divider"></div>
<form id="changePasswordForm">
<div class="mb-3">
<label for="newPassword" class="form-label">Новый пароль</label>
<input type="password" class="form-control" id="newPassword" placeholder="Новый пароль"
required>
</div>
<button type="submit" class="btn btn-outline-danger"><i
class="bi bi-key-fill me-1"></i>Изменить</button>
</form>
</div>
</div>
</div> </div>
</div> </div>
</nav> </nav>
@@ -64,8 +97,11 @@
<main class="container-fluid pt-2"> <main class="container-fluid pt-2">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
<!-- Контейнер для уведомлений -->
<div id="alertContainer" class="alert-fixed"></div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="/static/js/base.js"></script>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
</body> </body>
+267
View File
@@ -0,0 +1,267 @@
{% extends "base.html" %}
{% block title %}Дни рождения{% endblock %}
{% block styles %}
<link href="/static/css/birthdate.css" rel="stylesheet">
{% endblock %}
{% block content %}
<!-- Заголовок -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h2 class="mb-1"><i class="bi bi-balloon-heart-fill text-pink me-2"></i>Дни рождения</h2>
<p class="text-muted mb-0">Управление поздравлениями сотрудников</p>
</div>
<div class="badge bg-pink fs-6 px-3 py-2">
<i class="bi bi-calendar-heart me-1"></i>Поздравления
</div>
</div>
<!-- Основной контент -->
<div class="row g-4 mb-3">
<!-- Левая колонка: Список сотрудников -->
<div class="col-lg-7">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-people-fill me-2"></i>Сотрудники</h5>
<button type="button" class="btn btn-sm btn-outline-primary" onclick="refreshUsersList()">
<i class="bi bi-arrow-clockwise me-1"></i>Обновить список
</button>
</div>
<div class="card-body p-0">
<!-- Таблица сотрудников -->
<div class="table-responsive">
<table class="table table-hover mb-0" id="usersTable">
<thead class="table-light">
<tr>
<th width="120">Дата рождения</th>
<th>Имя</th>
<th>Короткое имя</th>
<th>Полная дата<br><small>(возраст)</small></th>
<th>Пол</th>
<th>Специальности</th>
<th width="100" class="text-center">Статус</th>
<th width="80" class="text-center">Данные</th>
</tr>
</thead>
<tbody id="usersTableBody">
<!-- Данные будут загружены через JS -->
<tr>
<td colspan="8" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Загрузка...</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="card-footer">
<div class="row g-2">
<div class="col-md-6">
<button type="button" class="btn btn-sm btn-outline-success w-100" onclick="enableAllUsers()">
<i class="bi bi-check-circle me-1"></i>Включить всех
</button>
</div>
<div class="col-md-6">
<button type="button" class="btn btn-sm btn-outline-secondary w-100"
onclick="disableAllUsers()">
<i class="bi bi-x-circle me-1"></i>Отключить всех
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Правая колонка: Данные сотрудника и планировщик -->
<div class="col-lg-5">
<!-- Данные выбранного сотрудника -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">
<i class="bi bi-person-badge me-2"></i>
<span id="selectedUserName">Выберите сотрудника</span>
</h5>
</div>
<div class="card-body">
<form id="userForm">
<input type="hidden" id="userId">
<!-- Переключатель enabled -->
<div class="mb-4">
<div class="form-check form-switch d-flex align-items-center gap-2 p-0">
<input class="form-check-input m-0" type="checkbox" role="switch" id="user_enabled"
onchange="markUserChanged()">
<label class="form-check-label fw-semibold mb-0" for="user_enabled">
Включить поздравление для этого сотрудника
</label>
</div>
</div>
<!-- Ссылка на фото -->
<div class="mb-4">
<label class="form-label fw-semibold">
<i class="bi bi-image me-2"></i>Ссылка на фото для поздравления
</label>
<input type="url" class="form-control" id="photo_link"
placeholder="https://vk.com/groupname?z=photo-27937673_457248154"
oninput="markUserChanged()">
<div class="form-text">
Ссылка на изображение для поздравления. Должна быть доступна для всех. Лишние символы в
ссылке будут удалены автоматически.
</div>
</div>
<!-- Текст поздравления -->
<div class="mb-4">
<label class="form-label fw-semibold">
<i class="bi bi-chat-heart me-2"></i>Текст поздравления
</label>
<textarea class="form-control" id="congratulations" rows="5"
placeholder="Дорогой(ая) [Имя], поздравляем с днем рождения! 🎉"
oninput="markUserChanged()"></textarea>
<div class="form-text">
Не поддерживается никакая разметка. Только текст и эмоджи. Максимум 2000 символов.
</div>
</div>
<!-- Информация о посте -->
<div id="postInfo" style="display: none;">
<div class="alert alert-info">
<h6 class="alert-heading"><i class="bi bi-info-circle me-2"></i>Информация о посте</h6>
<div class="mb-2" id="postLinkContainer">
<!-- Ссылка на пост будет здесь -->
</div>
<div class="small" id="publishTime">
<!-- Время публикации будет здесь -->
</div>
</div>
</div>
<!-- Кнопка сохранения -->
<div class="d-flex gap-2 pt-3 border-top">
<button type="button" class="btn btn-outline-secondary" onclick="resetUserForm()">
<i class="bi bi-x-lg me-1"></i>Отмена
</button>
<button type="button" class="btn btn-primary" id="saveUserButton" onclick="saveUserData()"
disabled>
<i class="bi bi-save me-1"></i>Сохранить данные сотрудника
</button>
</div>
</form>
</div>
</div>
<!-- Настройки планировщика -->
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-clock me-2"></i>Настройки планировщика</h5>
<span id="schedulerStatus" class="badge">
{% if data.schedulerStatus.scheduler %}
<span class="badge bg-success"><i class="bi bi-play-circle me-1"></i>Активен</span>
{% else %}
<span class="badge bg-secondary"><i class="bi bi-stop-circle me-1"></i>Неактивен</span>
{% endif %}
</span>
</div>
<div class="card-body">
<form id="schedulerForm">
<!-- Время запуска -->
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label fw-semibold">Час запуска</label>
<input type="number" class="form-control" id="scheduler_hour" min="0" max="23"
placeholder="9"
value="{{ data.schedulerSettings.hour if data.schedulerSettings else 9 }}"
oninput="markSchedulerChanged()">
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">Минута запуска</label>
<input type="number" class="form-control" id="scheduler_minute" min="0" max="59"
placeholder="0"
value="{{ data.schedulerSettings.minute if data.schedulerSettings else 0 }}"
oninput="markSchedulerChanged()">
</div>
</div>
<!-- Включение планировщика -->
<div class="mb-4">
<div class="form-check form-switch d-flex align-items-center gap-2 p-0">
<input class="form-check-input m-0" type="checkbox" role="switch" id="scheduler_enabled" {%
if data.schedulerSettings and data.schedulerSettings.enabled %}checked{% endif %}
onchange="markSchedulerChanged()">
<label class="form-check-label fw-semibold mb-0" for="scheduler_enabled">
Включить автоматическую публикацию
</label>
</div>
<div class="form-text">
Планировщик будет проверять дни рождения и публиковать поздравления ежедневно в
указанное время.
</div>
</div>
<!-- Информация о следующем запуске -->
{% if data.schedulerStatus.next_run_time %}
<div class="alert alert-success">
<div class="d-flex align-items-center">
<i class="bi bi-calendar-event fs-5 me-3"></i>
<div>
<h6 class="alert-heading mb-1">Следующий запуск</h6>
<p class="mb-0">{{ data.schedulerStatus.next_run_time }}</p>
</div>
</div>
</div>
{% endif %}
<!-- Кнопка сохранения -->
<div class="d-flex gap-2 pt-3 border-top">
<button type="button" class="btn btn-outline-secondary" onclick="resetSchedulerForm()">
<i class="bi bi-x-lg me-1"></i>Сбросить
</button>
<button type="button" class="btn btn-warning" id="saveSchedulerButton"
onclick="saveSchedulerSettings()" disabled>
<i class="bi bi-save me-1"></i>Сохранить настройки планировщика
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<!-- Модальное окно для подтверждения сохранения -->
<div class="modal fade" id="confirmModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-exclamation-triangle text-warning me-2"></i>Несохраненные
изменения</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>У вас есть несохраненные изменения для сотрудника <span id="modalUserName"
class="fw-semibold"></span>.</p>
<p>Вы хотите сохранить изменения перед переходом к другому сотруднику?</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" onclick="cancelSwitchUser()">
<i class="bi bi-x-circle me-1"></i>Отмена
</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal"
onclick="discardUserChanges()">
<i class="bi bi-trash me-1"></i>Не сохранять
</button>
<button type="button" class="btn btn-primary" data-bs-dismiss="modal" onclick="saveAndSwitchUser()">
<i class="bi bi-save me-1"></i>Сохранить и перейти
</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/birthdate.js"></script>
{% endblock %}
-2
View File
@@ -421,8 +421,6 @@
</div> </div>
</div> </div>
<!-- Контейнер для уведомлений -->
<div id="alertContainer" class="alert-fixed"></div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
+99
View File
@@ -0,0 +1,99 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Авторизация</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #f8f9fa;
}
.login-card {
width: 100%;
max-width: 360px;
}
</style>
</head>
<body>
<div class="card login-card shadow">
<div class="card-body">
<h4 class="text-center mb-4">Вход</h4>
<!-- ВАЖНО: form -->
<form id="loginForm">
<div class="mb-3">
<input type="password" class="form-control" id="password" placeholder="Введите пароль" autofocus>
</div>
<!-- Кнопка может остаться, но теперь не обязательна -->
<button type="submit" class="btn btn-success w-100">
Войти
</button>
</form>
<div id="error" class="alert alert-danger mt-3 d-none"></div>
</div>
</div>
<script>
document.getElementById("loginForm").addEventListener("submit", function (e) {
e.preventDefault(); // НЕ перезагружать страницу
login();
});
async function login() {
const password = document.getElementById("password").value;
const errorBox = document.getElementById("error");
errorBox.classList.add("d-none");
if (!password) {
errorBox.textContent = "Введите пароль";
errorBox.classList.remove("d-none");
return;
}
try {
const response = await fetch("/login", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ password })
});
const data = await response.json();
if (data.status === "ok" && data.token) {
setCookie("auth_token", data.token, 365);
window.location.href = "/";
} else {
errorBox.textContent = data.errorMessage || "Неверный пароль";
errorBox.classList.remove("d-none");
}
} catch (e) {
errorBox.textContent = "Ошибка соединения";
errorBox.classList.remove("d-none");
}
}
function setCookie(name, value, days) {
const date = new Date();
date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
document.cookie = `${name}=${value}; expires=${date.toUTCString()}; path=/`;
}
</script>
</body>
</html>
-3
View File
@@ -255,9 +255,6 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Всплывающие уведомления -->
<div id="alertContainer" style="position: fixed; top: 80px; right: 20px; z-index: 1050;"></div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
+1 -3
View File
@@ -9,7 +9,7 @@
<!-- Заголовок --> <!-- Заголовок -->
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<div> <div>
<h2 class="mb-1"><i class="bi bi-megaphone-fill text-primary me-2"></i>Управление постами</h2> <h2 class="mb-1"><i class="bi bi-file-earmark-text text-primary me-2"></i>Управление постами</h2>
<p class="text-muted mb-0">Создание и планирование публикаций в VK</p> <p class="text-muted mb-0">Создание и планирование публикаций в VK</p>
</div> </div>
<button type="button" class="btn btn-success btn-lg px-5" onclick="saveSettings()"> <button type="button" class="btn btn-success btn-lg px-5" onclick="saveSettings()">
@@ -215,8 +215,6 @@
</div> </div>
</div> </div>
<!-- Контейнер для уведомлений -->
<div id="alertContainer" class="alert-fixed"></div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
+1 -3
View File
@@ -9,7 +9,7 @@
<!-- Заголовок --> <!-- Заголовок -->
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<div> <div>
<h2 class="mb-1"><i class="bi bi-megaphone vk-icon me-2"></i>VK Настройки</h2> <h2 class="mb-1"><i class="bi bi-chat-dots vk-icon me-2"></i>VK Настройки</h2>
<p class="text-muted mb-0">Настройки для работы с VK API и сообществом</p> <p class="text-muted mb-0">Настройки для работы с VK API и сообществом</p>
</div> </div>
<div class="badge bg-primary fs-6 px-3 py-2"> <div class="badge bg-primary fs-6 px-3 py-2">
@@ -155,8 +155,6 @@
</div> </div>
</div> </div>
<!-- Контейнер для уведомлений -->
<div id="alertContainer" class="alert-fixed"></div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
Generated
+103
View File
@@ -1,6 +1,10 @@
version = 1 version = 1
revision = 3 revision = 3
requires-python = ">=3.13" requires-python = ">=3.13"
resolution-markers = [
"python_full_version >= '3.14'",
"python_full_version < '3.14'",
]
[[package]] [[package]]
name = "apscheduler" 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" }, { 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]] [[package]]
name = "blinker" name = "blinker"
version = "1.9.0" 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" }, { 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]] [[package]]
name = "charset-normalizer" name = "charset-normalizer"
version = "3.4.4" version = "3.4.4"
@@ -243,6 +335,7 @@ version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "apscheduler" }, { name = "apscheduler" },
{ name = "argon2-cffi" },
{ name = "flask" }, { name = "flask" },
{ name = "flask-sqlalchemy" }, { name = "flask-sqlalchemy" },
{ name = "pyjwt" }, { name = "pyjwt" },
@@ -253,6 +346,7 @@ dependencies = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "apscheduler", specifier = ">=3.11.1" }, { name = "apscheduler", specifier = ">=3.11.1" },
{ name = "argon2-cffi", specifier = ">=25.1.0" },
{ name = "flask", specifier = ">=3.1.2" }, { name = "flask", specifier = ">=3.1.2" },
{ name = "flask-sqlalchemy", specifier = ">=3.1.1" }, { name = "flask-sqlalchemy", specifier = ">=3.1.1" },
{ name = "pyjwt", specifier = ">=2.10.1" }, { name = "pyjwt", specifier = ">=2.10.1" },
@@ -260,6 +354,15 @@ requires-dist = [
{ name = "vk-api", specifier = ">=11.10.0" }, { 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]] [[package]]
name = "pyjwt" name = "pyjwt"
version = "2.10.1" version = "2.10.1"
+66 -14
View File
@@ -1,13 +1,13 @@
from datetime import datetime from datetime import datetime, date
import vk_api import vk_api
from db import VkAPI, db from db import UsersBirthdate, VkAPI, db
from medods_handler import setDynamicText from medods_handler import setDynamicText
def handle_vk_post(): def handle_vk_post():
from app import logger from app import logger
logger.info("Обновление поста") logger.info("Публикация поста")
vkApi = VkAPI.query.first() vkApi = VkAPI.query.first()
if not vkApi: if not vkApi:
@@ -20,16 +20,12 @@ def handle_vk_post():
return return
if not vkPost.dynamic_text: if not vkPost.dynamic_text:
# if not vkPost.dynamic_text and not vkPost.post_id:
logger.info("Не требуется публикация поста") logger.info("Не требуется публикация поста")
return return
vk_session = vk_api.VkApi(token=vkApi.access_token) vk_session = vk_api.VkApi(token=vkApi.access_token)
vk = vk_session.get_api() vk = vk_session.get_api()
new_post = {}
if vkPost.dynamic_text:
new_post = vk.wall.post( new_post = vk.wall.post(
owner_id=-vkApi.group_id, owner_id=-vkApi.group_id,
from_group=1, from_group=1,
@@ -38,15 +34,71 @@ def handle_vk_post():
) )
logger.info(f"Пост #{new_post.get('post_id')} создан") 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
if vkPost.dynamic_text:
vkPost.dynamic_text = None vkPost.dynamic_text = None
vkPost.post_id = new_post.get("post_id") vkPost.post_id = new_post.get("post_id")
vkPost.publish_at = datetime.now() vkPost.publish_at = datetime.now()
db.session.commit() 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=user.congratulations.strip(),
attachments=user.photo_link,
)
logger.info(f"Пост #{new_post.get('post_id')} создан")
user.post_link = (
f"https://vk.com/wall-{vkApi.group_id}_{new_post.get('post_id')}"
)
user.publish_at = datetime.now()
db.session.commit()