From 6ec4bd00e270bd5ac7ad88f6af368ca3c3f04b88 Mon Sep 17 00:00:00 2001
From: Macbook
Date: Tue, 23 Dec 2025 01:12:10 +0300
Subject: [PATCH] beta 2.0
---
app.db | Bin 36864 -> 49152 bytes
app.py | 191 +++++++++++++-
db.py | 108 ++++++++
medods_handler.py | 77 +++++-
pyproject.toml | 1 +
scheduler.py | 62 ++++-
static/css/birthdate.css | 270 +++++++++++++++++++
static/js/base.js | 87 ++++++
static/js/birthdate.js | 554 +++++++++++++++++++++++++++++++++++++++
static/js/index.js | 11 -
static/js/medods.js | 38 ---
static/js/posts.js | 43 ---
static/js/vk.js | 40 ---
templates/base.html | 44 +++-
templates/birthdate.html | 267 +++++++++++++++++++
templates/index.html | 2 -
templates/login.html | 99 +++++++
templates/medods.html | 3 -
templates/posts.html | 4 +-
templates/vk.html | 4 +-
uv.lock | 103 ++++++++
vk_handler.py | 90 +++++--
22 files changed, 1923 insertions(+), 175 deletions(-)
create mode 100644 static/css/birthdate.css
create mode 100644 static/js/base.js
create mode 100644 static/js/birthdate.js
create mode 100644 templates/birthdate.html
create mode 100644 templates/login.html
diff --git a/app.db b/app.db
index 67f578d22e04c5cbab4a44d52fceac051fa28a20..557a4b46ad7a5f757dd072d8402ef33982e2245b 100644
GIT binary patch
delta 805
zcmZozz|_#dJV9Epf`Ng72Z&)nbfS*2dI>~e`DiBHlN01Zgz2FW5%Z3lEkE()TGR!l8ltZlGOO((Ot3cy&qnLvxIaz>5nm;uUO2Mr(-d5Qj?huy;w;F>FUgP3$;``E2yu-F0SP7N=cN}VmXzismSpDV
z6~o1g3sRFa6LU&3Q;QY6g8lu#+VhJ`5V|LqaK~7Qak7gmDl#^fgF~gDD8D2%8DtQG
z#f9Q_ggD6Ax&?{F#pU@$DPU_A{QN@{{6c+vKz5bnXQ$?&3Qun272o`p^92hd_vUMS
zdV*Zcyip8%Z2a=PQJV!7e0XgdHJP~>go7mo8`&Kl-CaX2EWEJ)!j6lL7xrD)r2wKX
zHeGDE*nDBjg}oPcT-XH^-lPu>%<9_e$x(V%W?Z#K21cg3hDN$ZMhZqoRwf2kCT4nu
zrltlK=D1`mO*XUX{YvEG1tuv*{u~DWoXvs>N&NMVf{d&TjsD3hiACx8c}AHjDrL5Y
zmMXcnW~QdbW;!Ld#ySPICMso)M(I^q;kjvPhM9#~p=KeTx!SIQDruHp{^c1tl`h&5
zr4hm96{f~fZjqU;1x6`ZMZRt&KH-5S1*Ij$MSf+DsktUu$pNlDWkETv5vloURRvj9
r+2PU_VZJ`T`DJM?##NRP{soafhQ1cQAV)F?03Eo2f8qo$HdHPEy&3^%
delta 91
zcmZo@U~X8zG(lQ0kAZ=K6Nq6zV4{w(WFCWFn 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 = `
+
+ `;
+
+ alertContainer.appendChild(alert);
+
+ // Автоматическое удаление через 5 секунд
+ setTimeout(() => {
+ if (alert.parentNode) {
+ alert.classList.remove('show');
+ setTimeout(() => alert.remove(), 150);
+ }
+ }, 5000);
+}
+
+function getAlertIcon(type) {
+ const icons = {
+ 'success': 'bi-check-circle-fill',
+ 'warning': 'bi-exclamation-triangle-fill',
+ 'danger': 'bi-x-circle-fill',
+ 'info': 'bi-info-circle-fill'
+ };
+ return icons[type] || 'bi-info-circle-fill';
+}
+
+document.getElementById("changePasswordForm").addEventListener("submit", async function (e) {
+ e.preventDefault(); // ❌ перезагрузка страницы
+
+ const passwordInput = document.getElementById("newPassword");
+ const newPassword = passwordInput.value.trim();
+
+ if (!newPassword) {
+ showAlert("warning", "Введите новый пароль");
+ return;
+ }
+
+ try {
+ const response = await fetch("/login", {
+ method: "PATCH",
+ headers: {
+ "Content-Type": "application/json"
+ },
+ body: JSON.stringify({ password: newPassword })
+ });
+
+ const data = await response.json();
+
+ if (data.status === "ok") {
+ showAlert("success", "Пароль успешно изменён");
+
+ // очистить поле
+ passwordInput.value = "";
+
+ // закрыть dropdown
+ const btn = document.getElementById("changePasswordBtn");
+ const dropdown = bootstrap.Dropdown.getOrCreateInstance(btn);
+ dropdown.hide();
+ } else {
+ showAlert("danger", data.errorMessage || "Ошибка изменения пароля");
+ }
+ } catch (err) {
+ showAlert("danger", "Ошибка соединения с сервером");
+ }
+});
\ No newline at end of file
diff --git a/static/js/birthdate.js b/static/js/birthdate.js
new file mode 100644
index 0000000..0dfd0c6
--- /dev/null
+++ b/static/js/birthdate.js
@@ -0,0 +1,554 @@
+// Глобальные переменные
+let usersData = [];
+let selectedUserId = null;
+let selectedUserName = '';
+let userFormChanged = false;
+let schedulerFormChanged = false;
+let originalUserData = null;
+let originalSchedulerData = null;
+let pendingUserSwitch = null;
+
+// Инициализация при загрузке страницы
+document.addEventListener('DOMContentLoaded', function () {
+ // Загружаем список сотрудников
+ loadUsersList();
+
+ // Сохраняем оригинальные настройки планировщика
+ saveOriginalSchedulerData();
+
+ // Устанавливаем обработчики событий
+ setupEventListeners();
+});
+
+// Загрузка списка сотрудников
+async function loadUsersList() {
+ try {
+ const response = await fetch('/api/birthdate');
+ const data = await response.json();
+
+ if (data.status === 'ok') {
+ usersData = data.users;
+ renderUsersTable();
+ } else {
+ showAlert('danger', 'Ошибка загрузки списка сотрудников');
+ }
+ } catch (error) {
+ console.error('Ошибка:', error);
+ showAlert('danger', 'Ошибка загрузки списка сотрудников');
+ renderUsersTable(); // Рендерим пустую таблицу
+ }
+}
+
+// Отображение таблицы сотрудников
+function renderUsersTable() {
+ const tbody = document.getElementById('usersTableBody');
+
+ if (!usersData || usersData.length === 0) {
+ tbody.innerHTML = `
+
+
+
+ Нет данных о сотрудниках
+ Нажмите "Обновить список" для загрузки данных
+
+ |
+
+ `;
+ return;
+ }
+
+ // Сортируем по дате рождения (месяц и день)
+ usersData.sort((a, b) => {
+ const dateA = new Date(a.birthdate);
+ const dateB = new Date(b.birthdate);
+
+ // Извлекаем месяц и день
+ const monthA = dateA.getMonth();
+ const dayA = dateA.getDate();
+ const monthB = dateB.getMonth();
+ const dayB = dateB.getDate();
+
+ // Сначала сравниваем месяцы, потом дни
+ return monthA !== monthB ? monthA - monthB : dayA - dayB;
+ });
+
+ let html = '';
+
+ usersData.forEach(user => {
+ const birthdate = new Date(user.birthdate);
+ const now = new Date();
+ const age = now.getFullYear() - birthdate.getFullYear();
+ const month = birthdate.getMonth() + 1;
+ const day = birthdate.getDate();
+ const fullDate = birthdate.toLocaleDateString('ru-RU');
+
+ // Форматируем месяц и день (двузначные)
+ const monthStr = month.toString().padStart(2, '0');
+ const dayStr = day.toString().padStart(2, '0');
+
+ // Определяем пол
+ const sexBadge = user.sex === 'male' ?
+ 'М' :
+ 'Ж';
+
+ // Специальности
+ const specialtiesHtml = user.specialties && user.specialties.length > 0 ?
+ user.specialties.map(s => `${s}`).join('') :
+ '-';
+
+ // Статус enabled
+ const enabledStatus = user.enabled ?
+ '
' :
+ '
';
+
+ // Статус данных (фото и текст)
+ const hasPhoto = user.photoLink && user.photoLink.trim() !== '';
+ const hasText = user.congratulations && user.congratulations.trim() !== '';
+ const hasData = hasPhoto && hasText;
+
+ const dataStatus = hasData ?
+ '
' :
+ '
';
+
+ // Определяем класс строки (выделение выбранного)
+ const isSelected = selectedUserId === user.id ? 'selected' : '';
+
+ html += `
+
+ |
+
+ ${monthStr}.${dayStr}
+
+ |
+
+ ${user.name}
+ |
+ ${user.shortName} |
+
+ ${fullDate}
+ ${age} лет
+ |
+ ${sexBadge} |
+ ${specialtiesHtml} |
+ ${enabledStatus} |
+
+ ${dataStatus}
+ |
+
+ `;
+ });
+
+ tbody.innerHTML = html;
+}
+
+// Выбор сотрудника
+function selectUser(userId) {
+ // Если есть несохраненные изменения
+ if (userFormChanged && selectedUserId !== null) {
+ pendingUserSwitch = userId;
+ document.getElementById('modalUserName').textContent = selectedUserName;
+ const modal = new bootstrap.Modal(document.getElementById('confirmModal'));
+ modal.show();
+ return;
+ }
+
+ // Выполняем переключение
+ performUserSwitch(userId);
+}
+
+// Выполнение переключения сотрудника
+function performUserSwitch(userId) {
+ const user = usersData.find(u => u.id === userId);
+ if (!user) return;
+
+ // Обновляем выделение в таблице
+ document.querySelectorAll('#usersTableBody tr').forEach(row => {
+ row.classList.remove('selected');
+ });
+ document.querySelector(`tr[data-user-id="${userId}"]`).classList.add('selected');
+
+ // Сохраняем данные
+ selectedUserId = userId;
+ selectedUserName = user.name;
+
+ // Обновляем заголовок
+ document.getElementById('selectedUserName').textContent = user.name;
+
+ // Заполняем форму
+ document.getElementById('userId').value = user.id;
+ document.getElementById('user_enabled').checked = user.enabled;
+ document.getElementById('photo_link').value = user.photoLink || '';
+ document.getElementById('congratulations').value = user.congratulations || '';
+
+ // Показываем информацию о посте, если есть
+ const postInfo = document.getElementById('postInfo');
+ const postLinkContainer = document.getElementById('postLinkContainer');
+ const publishTime = document.getElementById('publishTime');
+
+ if (user.postLink) {
+ postInfo.style.display = 'block';
+ postLinkContainer.innerHTML = `
+
+ Перейти к посту в VK
+
+ `;
+ publishTime.innerHTML = `Опубликовано: ${user.publishAt || 'неизвестно'}`;
+ } else {
+ postInfo.style.display = 'none';
+ }
+
+ // Сохраняем оригинальные данные для сравнения
+ saveOriginalUserData();
+
+ // Сбрасываем флаг изменений
+ userFormChanged = false;
+ updateSaveUserButton();
+}
+
+// Отметка изменений в форме сотрудника
+function markUserChanged() {
+ userFormChanged = true;
+ updateSaveUserButton();
+}
+
+// Отметка изменений в форме планировщика
+function markSchedulerChanged() {
+ schedulerFormChanged = true;
+ updateSaveSchedulerButton();
+}
+
+// Обновление кнопки сохранения сотрудника
+function updateSaveUserButton() {
+ const button = document.getElementById('saveUserButton');
+ button.disabled = !userFormChanged;
+}
+
+// Обновление кнопки сохранения планировщика
+function updateSaveSchedulerButton() {
+ const button = document.getElementById('saveSchedulerButton');
+ button.disabled = !schedulerFormChanged;
+}
+
+// Сохранение оригинальных данных сотрудника
+function saveOriginalUserData() {
+ if (!selectedUserId) return;
+
+ originalUserData = {
+ enabled: document.getElementById('user_enabled').checked,
+ photoLink: document.getElementById('photo_link').value,
+ congratulations: document.getElementById('congratulations').value
+ };
+}
+
+// Сохранение оригинальных данных планировщика
+function saveOriginalSchedulerData() {
+ originalSchedulerData = {
+ hour: parseInt(document.getElementById('scheduler_hour').value) || 9,
+ minute: parseInt(document.getElementById('scheduler_minute').value) || 0,
+ enabled: document.getElementById('scheduler_enabled').checked
+ };
+}
+
+// Сброс формы сотрудника
+function resetUserForm() {
+ if (!selectedUserId) return;
+
+ if (userFormChanged && confirm('Отменить изменения?')) {
+ const user = usersData.find(u => u.id === selectedUserId);
+ if (user) {
+ document.getElementById('user_enabled').checked = user.enabled;
+ document.getElementById('photo_link').value = user.photoLink || '';
+ document.getElementById('congratulations').value = user.congratulations || '';
+
+ userFormChanged = false;
+ updateSaveUserButton();
+ saveOriginalUserData();
+ }
+ }
+}
+
+// Сброс формы планировщика
+function resetSchedulerForm() {
+ if (schedulerFormChanged && confirm('Отменить изменения в настройках планировщика?')) {
+ if (originalSchedulerData) {
+ document.getElementById('scheduler_hour').value = originalSchedulerData.hour;
+ document.getElementById('scheduler_minute').value = originalSchedulerData.minute;
+ document.getElementById('scheduler_enabled').checked = originalSchedulerData.enabled;
+
+ schedulerFormChanged = false;
+ updateSaveSchedulerButton();
+ }
+ }
+}
+
+// Сохранение данных сотрудника
+async function saveUserData() {
+ function normalizeVkPhotoLink(link) {
+ if (!link) return link;
+
+ try {
+ const url = new URL(link);
+ const z = url.searchParams.get('z');
+
+ if (z && z.includes('%2F')) {
+ url.searchParams.set('z', z.split('%2F')[0]);
+ }
+
+ return url.toString();
+ } catch (e) {
+ // если это невалидный URL — возвращаем как есть
+ return link;
+ }
+ }
+ if (!selectedUserId) {
+ showAlert('warning', 'Выберите сотрудника для сохранения');
+ return;
+ }
+
+ const userData = {
+ enabled: document.getElementById('user_enabled').checked,
+ photoLink: normalizeVkPhotoLink(
+ document.getElementById('photo_link').value.trim()
+ ),
+ congratulations: document.getElementById('congratulations').value.trim()
+ };
+ // Проверка длины поздравления
+ if (userData.congratulations.length > 2000) {
+ showAlert('warning', 'Длина поздравления не должна превышать 2000 символов, сейчас - ' + userData.congratulations.length);
+ return;
+ }
+
+ // Проверка: если фото или текст заполнены, оба должны быть заполнены
+ if ((userData.photoLink && !userData.congratulations) || (!userData.photoLink && userData.congratulations)) {
+ showAlert('warning', 'Для поздравления должны быть заполнены и фото, и текст');
+ return;
+ }
+
+ const postData = {
+ userUpdate: {
+ userId: selectedUserId,
+ userData: userData
+ }
+ };
+
+ try {
+ const response = await fetch('/api/birthdate', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(postData)
+ });
+
+ const data = await response.json();
+
+ if (response.ok && data.status === 'ok') {
+ showAlert('success', 'Данные сотрудника сохранены!');
+
+ // Обновляем данные в локальном массиве
+ const userIndex = usersData.findIndex(u => u.id === selectedUserId);
+ if (userIndex !== -1) {
+ usersData[userIndex].enabled = userData.enabled;
+ usersData[userIndex].photoLink = userData.photoLink;
+ usersData[userIndex].congratulations = userData.congratulations;
+
+ // Перерисовываем таблицу
+ renderUsersTable();
+ }
+
+ // Сбрасываем флаг изменений
+ userFormChanged = false;
+ updateSaveUserButton();
+ saveOriginalUserData();
+ } else {
+ showAlert('danger', 'Ошибка сохранения данных сотрудника');
+ }
+ } catch (error) {
+ console.error('Ошибка:', error);
+ showAlert('danger', 'Ошибка сохранения данных сотрудника');
+ }
+}
+
+// Сохранение настроек планировщика
+async function saveSchedulerSettings() {
+ const scheduleSettings = {
+ hour: parseInt(document.getElementById('scheduler_hour').value),
+ minute: parseInt(document.getElementById('scheduler_minute').value),
+ enabled: document.getElementById('scheduler_enabled').checked
+ };
+
+ // Валидация
+ if (scheduleSettings.hour < 0 || scheduleSettings.hour > 23) {
+ showAlert('warning', 'Час должен быть от 0 до 23');
+ return;
+ }
+
+ if (scheduleSettings.minute < 0 || scheduleSettings.minute > 59) {
+ showAlert('warning', 'Минута должна быть от 0 до 59');
+ return;
+ }
+
+ const postData = {
+ scheduleSettings: scheduleSettings
+ };
+
+ try {
+ const response = await fetch('/api/birthdate', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(postData)
+ });
+
+ const data = await response.json();
+
+ if (response.ok && data.status === 'ok') {
+ showAlert('success', 'Настройки планировщика сохранены!');
+
+ // Обновляем информацию о следующем запуске
+ if (data.next_run_time) {
+ const schedulerStatus = document.getElementById('schedulerStatus');
+ schedulerStatus.innerHTML = `Активен`;
+
+ // Обновляем отображение следующего запуска (если есть соответствующий элемент)
+ const nextRunAlert = document.querySelector('.alert-success');
+ if (nextRunAlert) {
+ nextRunAlert.querySelector('p').textContent = data.next_run_time;
+ }
+ }
+
+ // Сбрасываем флаг изменений
+ schedulerFormChanged = false;
+ updateSaveSchedulerButton();
+ saveOriginalSchedulerData();
+ } else {
+ showAlert('danger', 'Ошибка сохранения настроек планировщика');
+ }
+ } catch (error) {
+ console.error('Ошибка:', error);
+ showAlert('danger', 'Ошибка сохранения настроек планировщика');
+ }
+}
+
+// Обновление списка сотрудников
+async function refreshUsersList() {
+ try {
+ const response = await fetch('/api/birthdate?action=update', {
+ method: 'PATCH'
+ });
+
+ const data = await response.json();
+
+ if (response.ok && data.status === 'ok') {
+ showAlert('success', 'Список сотрудников обновлен!');
+ // Перезагружаем список
+ await loadUsersList();
+ } else {
+ showAlert('danger', 'Ошибка обновления списка сотрудников');
+ }
+ } catch (error) {
+ console.error('Ошибка:', error);
+ showAlert('danger', 'Ошибка обновления списка сотрудников');
+ }
+}
+
+// Включить всех сотрудников
+async function enableAllUsers() {
+ if (!confirm('Включить поздравления для всех сотрудников?')) return;
+
+ try {
+ // Находим всех сотрудников без данных
+ const usersWithoutData = usersData.filter(u =>
+ !u.enabled || !u.photoLink || !u.congratulations
+ );
+
+ if (usersWithoutData.length === 0) {
+ showAlert('info', 'Все сотрудники уже включены');
+ return;
+ }
+
+ // Показываем уведомление о необходимости заполнения данных
+ showAlert('info', `Включено ${usersWithoutData.length} сотрудников. Не забудьте заполнить данные для поздравлений.`);
+
+ // Обновляем статус в локальном массиве
+ usersData.forEach(user => {
+ user.enabled = true;
+ });
+
+ // Перерисовываем таблицу
+ renderUsersTable();
+
+ } catch (error) {
+ console.error('Ошибка:', error);
+ showAlert('danger', 'Ошибка включения сотрудников');
+ }
+}
+
+// Отключить всех сотрудников
+async function disableAllUsers() {
+ if (!confirm('Отключить поздравления для всех сотрудников?')) return;
+
+ try {
+ // Обновляем статус в локальном массиве
+ usersData.forEach(user => {
+ user.enabled = false;
+ });
+
+ // Перерисовываем таблицу
+ renderUsersTable();
+
+ showAlert('success', 'Все сотрудники отключены');
+
+ } catch (error) {
+ console.error('Ошибка:', error);
+ showAlert('danger', 'Ошибка отключения сотрудников');
+ }
+}
+
+// Обработчики событий для модального окна
+function cancelSwitchUser() {
+ pendingUserSwitch = null;
+}
+
+function discardUserChanges() {
+ userFormChanged = false;
+ updateSaveUserButton();
+ if (pendingUserSwitch) {
+ performUserSwitch(pendingUserSwitch);
+ pendingUserSwitch = null;
+ }
+}
+
+function saveAndSwitchUser() {
+ if (pendingUserSwitch) {
+ saveUserData().then(() => {
+ if (!userFormChanged) {
+ performUserSwitch(pendingUserSwitch);
+ pendingUserSwitch = null;
+ }
+ });
+ }
+}
+
+// Настройка обработчиков событий
+function setupEventListeners() {
+ // Валидация полей времени
+ document.getElementById('scheduler_hour').addEventListener('input', function () {
+ let value = parseInt(this.value);
+ if (value < 0) this.value = 0;
+ if (value > 23) this.value = 23;
+ });
+
+ document.getElementById('scheduler_minute').addEventListener('input', function () {
+ let value = parseInt(this.value);
+ if (value < 0) this.value = 0;
+ if (value > 59) this.value = 59;
+ });
+
+ // Предотвращение закрытия страницы при несохраненных изменениях
+ window.addEventListener('beforeunload', function (e) {
+ if (userFormChanged || schedulerFormChanged) {
+ e.preventDefault();
+ e.returnValue = '';
+ }
+ });
+}
diff --git a/static/js/index.js b/static/js/index.js
index 964a09e..f581f22 100644
--- a/static/js/index.js
+++ b/static/js/index.js
@@ -127,14 +127,3 @@ async function refreshLogs() {
console.error('Ошибка при обновлении логов:', error);
}
}
-
-
-function getAlertIcon(type) {
- const icons = {
- 'success': 'bi-check-circle-fill',
- 'warning': 'bi-exclamation-triangle-fill',
- 'danger': 'bi-x-circle-fill',
- 'info': 'bi-info-circle-fill'
- };
- return icons[type] || 'bi-info-circle-fill';
-}
\ No newline at end of file
diff --git a/static/js/medods.js b/static/js/medods.js
index 739149a..a4f5b1d 100644
--- a/static/js/medods.js
+++ b/static/js/medods.js
@@ -618,44 +618,6 @@ function downloadResponse() {
URL.revokeObjectURL(url);
}
-// Вспомогательные функции
-function showAlert(type, message) {
- const alertContainer = document.getElementById('alertContainer');
-
- const alert = document.createElement('div');
- alert.className = `alert alert-${type} alert-dismissible fade show shadow`;
- alert.style.minWidth = '300px';
- alert.style.maxWidth = '400px';
-
- alert.innerHTML = `
-
- `;
-
- alertContainer.appendChild(alert);
-
- // Автоматическое удаление через 4 секунды
- setTimeout(() => {
- if (alert.parentNode) {
- alert.classList.remove('show');
- setTimeout(() => alert.remove(), 150);
- }
- }, 4000);
-}
-
-function getAlertIcon(type) {
- const icons = {
- 'success': 'bi-check-circle-fill',
- 'warning': 'bi-exclamation-triangle-fill',
- 'danger': 'bi-x-circle-fill',
- 'info': 'bi-info-circle-fill'
- };
- return icons[type] || 'bi-info-circle-fill';
-}
-
function showLoader(show) {
const executeButton = document.getElementById('executeButton');
if (show) {
diff --git a/static/js/posts.js b/static/js/posts.js
index 875f4eb..e16a816 100644
--- a/static/js/posts.js
+++ b/static/js/posts.js
@@ -305,46 +305,3 @@ async function updateSchedulerStatus() {
console.error('Ошибка обновления статуса:', error);
}
}
-
-// Вспомогательные функции для уведомлений
-function showAlert(type, message) {
- const alertContainer = document.getElementById('alertContainer');
-
- // Очищаем старые алерты
- alertContainer.innerHTML = '';
-
- // Создаем новый алерт
- const alert = document.createElement('div');
- alert.className = `alert alert-${type} alert-dismissible fade show shadow`;
-
- // Иконка для типа алерта
- const icon = getAlertIcon(type);
-
- alert.innerHTML = `
-
- `;
-
- alertContainer.appendChild(alert);
-
- // Автоматическое удаление через 5 секунд
- setTimeout(() => {
- if (alert.parentNode) {
- alert.classList.remove('show');
- setTimeout(() => alert.remove(), 150);
- }
- }, 5000);
-}
-
-function getAlertIcon(type) {
- const icons = {
- 'success': 'bi-check-circle-fill',
- 'warning': 'bi-exclamation-triangle-fill',
- 'danger': 'bi-x-circle-fill',
- 'info': 'bi-info-circle-fill'
- };
- return icons[type] || 'bi-info-circle-fill';
-}
\ No newline at end of file
diff --git a/static/js/vk.js b/static/js/vk.js
index d5a142e..cde8583 100644
--- a/static/js/vk.js
+++ b/static/js/vk.js
@@ -101,46 +101,6 @@ async function saveVkSettings() {
}
}
-// Вспомогательные функции для уведомлений
-function showAlert(type, message) {
- const alertContainer = document.getElementById('alertContainer');
-
- // Создаем алерт
- const alert = document.createElement('div');
- alert.className = `alert alert-${type} alert-dismissible fade show shadow`;
-
- // Иконка для типа алерта
- const icon = getAlertIcon(type);
-
- alert.innerHTML = `
-
- `;
-
- alertContainer.appendChild(alert);
-
- // Автоматическое удаление через 5 секунд
- setTimeout(() => {
- if (alert.parentNode) {
- alert.classList.remove('show');
- setTimeout(() => alert.remove(), 150);
- }
- }, 5000);
-}
-
-function getAlertIcon(type) {
- const icons = {
- 'success': 'bi-check-circle-fill',
- 'warning': 'bi-exclamation-triangle-fill',
- 'danger': 'bi-x-circle-fill',
- 'info': 'bi-info-circle-fill'
- };
- return icons[type] || 'bi-info-circle-fill';
-}
-
// Дополнительная проверка при вводе данных
document.getElementById('group_id').addEventListener('input', function (e) {
this.value = this.value.replace(/[^\d]/g, '');
diff --git a/templates/base.html b/templates/base.html
index 89ac764..97bfe7b 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -34,29 +34,62 @@
- Главная
+ Главная
- Medods
+ Medods
- VK
+ VK
- Посты
+ Посты
+
+
+
+ Дни рождения
+
+
+
+
+
+
+
+
@@ -64,8 +97,11 @@
{% block content %}{% endblock %}
+
+
+
{% block scripts %}{% endblock %}
+
+
+
+
+
+
diff --git a/templates/birthdate.html b/templates/birthdate.html
new file mode 100644
index 0000000..ac4746e
--- /dev/null
+++ b/templates/birthdate.html
@@ -0,0 +1,267 @@
+{% extends "base.html" %}
+{% block title %}Дни рождения{% endblock %}
+
+{% block styles %}
+
+{% endblock %}
+
+{% block content %}
+
+
{% 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 @@
+
+
+
+