From b1e99277d1934643b833f9b1e399222de211876b Mon Sep 17 00:00:00 2001 From: Macbook Date: Sat, 20 Dec 2025 12:19:33 +0300 Subject: [PATCH] =?UTF-8?q?=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=20=D1=81?= =?UTF-8?q?=20=D0=BF=D1=83=D0=B1=D0=BB=D0=B8=D0=BA=D0=B0=D1=86=D0=B8=D0=B5?= =?UTF-8?q?=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.db | Bin 20480 -> 28672 bytes app.py | 252 +++++++++++++++++++++--------- config.py | 3 - db.py | 75 +++++++++ git_update.sh | 16 ++ http_client.py | 5 +- medods_handler.py | 177 +++++++++++++++++++++ scheduler.py | 64 +++++++- static/css/posts.css | 177 +++++++++++++++++++++ static/js/medods.js | 12 +- static/js/posts.js | 362 +++++++++++++++++++++++++++++++++++++++++++ static/js/vk.js | 2 +- templates/posts.html | 259 ++++++++++++++++++++++++++++--- templates/vk.html | 24 +-- vk_handler.py | 51 ++++++ vk_worker.py | 30 ---- 16 files changed, 1344 insertions(+), 165 deletions(-) create mode 100644 git_update.sh create mode 100644 medods_handler.py create mode 100644 static/css/posts.css create mode 100644 static/js/posts.js create mode 100644 vk_handler.py delete mode 100644 vk_worker.py diff --git a/app.db b/app.db index 53230bc055392bd3afe4f19ec34869cbbe3166d6..44836ae0ae8273ef08da10e3a1ab1855fb6b38ac 100644 GIT binary patch delta 1072 zcmZXS!B5jr9LKvwnHz2cjX{M-LqZgVxiLxyCpOxR!M4s)-PpmVYaiQVZ|!Vf*~SG1 ziD$7UUi}x8#0wV_4-zjX9z1#QKQJ-zZd=i8`jYm&_kF+j`+a`D_d9syIr!#zHa~FpEBZ6N&L!X2%#7#g9+s*I zv@`>YhFk}ViGV)1^R4o1Qea6TnaZ)`;F(2+#{~mRIu`4isgv1)z-Cx}mJIlTs{E%x z6$5=&LShrDCI-f!y#u07<4cxuw}u)u_5t)8bCnCKoLy?=mxpI$rT*aZ4)8Ub^*>=T+z)ND*ePQvx=Zm z7bQ$Gc7cR!o^`HU@4_o9JvihWo1XR@U31ah0Adp;nqu^?y#q<~omi9MiL}e`H-Wll zS9wqZoxc1)s~^s20U1gNLtwoPub-S=zUlQ`I{6a1>lq0;eKO)U%SHQt@nm%5dpJl0 zLs`NbOcTL$c+!@ZgTse}CqP`y+!#GfTJ-%Fky&y|+La)Zst`fkO=~)*BB%l$$eOM| zwKlc<*8&0S}X$(d!GZn$KC*Ot(NS?t+~jArA6Wo0;D~r zWZNB-vx@B~Hv3z$`-qV+a9mHFOK!vcD>}t1O1jiET+@K{GeWjIqLs&)8Qm5x4~i&Blb-6b_qUaRYk&W=#nU^4BkqSnQ7v#C}& zCI{20hl{ji@;k-7cD#elbiROuimG>)wrlMazoa29E>MdLb*YGB49aIVm1KG2(UW2~ zBk|?zdcFe++tnmpYLxP80_KXqY%S#Gnkh9`DDMbST5ljR_M}qT)M%z%+biW6&}y{z zDlNGYtFl|X0aD#kg^FjQ9AkapZeO2|lCgQaympZzbTqM$So;08?u#B5ho6%dyu`?# F-7iAyLz(~p delta 499 zcmZp8z}T>Wae}maI?+(vyo{G(a z0`5GUck@UwF|uyH#;3(708|bGAR}0TD&F%?{vXfE!Nljuz^}vS2^8Vu6K>*X;$jd@ zcWi26gEE*WYwF8zH6}o~jpjhEf3Q+=PG(+avS*5tm4cy;f>LT;ic4ZiDv+mSWME{f zYiOivWS|6;C@x7XD#0#QTAW%GkXV$MTMW{sH2I{yId@|qBNv0PyQE-a^5j6>DU(I? z)cFjp3=OSJ%=ApnERD>}HW_@9s}J(GFxL&vb*{?IjyDf0&G#?Os`Auz$*3&Nbn~+G z^$sp`OwaQ2aSw=c3^2$GFE$U049_<7%dSZEGBh-Ea|?D3GW1L@HmWoY@eDOBs50>> zbF$1UF$^_KkB>6Vu}n`a⁡g_b_%ha}F^Lbu`s2Pc=-`4K_+P_w+S&3JWgEEQyE; zC@Tm~i-?ahbGOI}@C%MIiO35}HOfeGHnxb&vaAZTOwMt(Obf~@G|12OPmeD%@H6%? qk8(9B4JoSfugofPc1^C*bx(9Ea&h-FDloH{eB57s@)dtUMoR#To`@F! diff --git a/app.py b/app.py index 353fd0f..da4f262 100644 --- a/app.py +++ b/app.py @@ -1,11 +1,14 @@ -from flask import Flask, request, jsonify +from flask import Flask, request, jsonify, render_template from config import Config -from db import VkAPI, db, MedodsAPI, ApiEndpoint -from scheduler import start_scheduler +from db import PostScheduler, UsersMedods, VkAPI, VkPost, db, MedodsAPI, ApiEndpoint +from medods_handler import updateMedodsUsers +from scheduler import enable_publish_job, get_scheduler_status, start_scheduler from http_client import send_request import logging import os +from vk_handler import handle_vk_post + app = Flask(__name__) app.config.from_object(Config) @@ -24,7 +27,13 @@ logging.basicConfig( logger = logging.getLogger(__name__) -from flask import render_template + +@app.before_request +def init(): + db.create_all() + start_scheduler() + enable_publish_job() + logger.info("Приложение запущено") @app.route("/") @@ -47,7 +56,7 @@ def medods(): return render_template("medods.html", data=data) -@app.route("/vk") +@app.route("/vk", methods=["GET"]) def vk(): vkDB = VkAPI.query.first() data = {} @@ -56,63 +65,85 @@ def vk(): return render_template("vk.html", data=data) -@app.route("/posts") +@app.route("/posts", methods=["GET"]) def posts(): - return render_template("posts.html") + medodsUsers = UsersMedods.query.all() + if len(medodsUsers) > 0: + medodsUsers = [user.toDict() for user in medodsUsers] + vkPost = VkPost.query.first() + if vkPost: + vkPost = vkPost.toDict() + schedulerStatus = get_scheduler_status() + schedulerSettings = PostScheduler.query.first() + if schedulerSettings: + schedulerSettings = schedulerSettings.toDict() + return render_template( + "posts.html", + data={ + "medodsUsers": medodsUsers, + "vkPost": vkPost, + "schedulerStatus": schedulerStatus, + "schedulerSettings": schedulerSettings, + }, + ) -@app.before_request -def init(): - db.create_all() - start_scheduler() - logger.info("Приложение запущено") +@app.route("/api/medods", methods=["POST"]) +def api_medods(): + try: + data = request.json + apiKey = data.get("apiKey", None) + url = data.get("url", None) + if url is not None: + logger.info("Получен url") + try: + medodsRecord = MedodsAPI.query.first() + medodsRecord.url = url + db.session.commit() + logger.info("Обновлен url") + except Exception: + db.session.merge(MedodsAPI(url=url)) + db.session.commit() + logger.info("Добавлен url") + + if apiKey: + logger.info("Получены ключи") + try: + medodsRecord = MedodsAPI.query.first() + medodsRecord.identity = apiKey["identity"] + medodsRecord.secretKey = apiKey["secretKey"] + db.session.commit() + logger.info("Обновлены ключи") + except Exception: + db.session.merge( + MedodsAPI( + identity=apiKey["identity"], secretKey=apiKey["secretKey"] + ) + ) + db.session.commit() + logger.info("Добавлены ключи") + return jsonify({"ok": True}) + except Exception as e: + logger.error(f"Ошибка при обновлении ключей: {e}") + return jsonify({"ok": False}), 500 -@app.route("/settings/medods", methods=["POST"]) -def medods_url(): - data = request.json - logger.info(data) - apiKey = data.get("apiKey", None) - url = data.get("url", None) - if url is not None: - logger.info("Получен url") - try: - medodsRecord = MedodsAPI.query.first() - medodsRecord.url = url - db.session.commit() - logger.info("Обновлен url") - except Exception: - db.session.merge(MedodsAPI(url=url)) - db.session.commit() - logger.info("Добавлен url") - - if apiKey: - logger.info("Получены ключи") - try: - medodsRecord = MedodsAPI.query.first() - medodsRecord.identity = apiKey["identity"] - medodsRecord.secretKey = apiKey["secretKey"] - db.session.commit() - logger.info("Обновлены ключи") - except Exception: - db.session.merge( - MedodsAPI(identity=apiKey["identity"], secretKey=apiKey["secretKey"]) - ) - db.session.commit() - logger.info("Добавлены ключи") - return jsonify({"ok": True}) - # return jsonify({"ok": False}), 400 - - -@app.route("/settings/requests", methods=["GET", "POST", "PATCH", "DELETE"]) -def get_requests(): +@app.route("/api/requests", methods=["GET", "POST", "PATCH", "DELETE"]) +def api_requests(): requestData = ( request.json if request.method in ["POST", "PATCH", "DELETE"] else None ) match request.method: case "DELETE": + try: + db.session.execute( + db.delete(ApiEndpoint).where(ApiEndpoint.id == requestData["id"]) + ) + db.session.commit() + except Exception as e: + logger.error(f"Ошибка при удалении запроса {requestData['id']}: {e}") logger.info("Удален запрос") - logger.info(requestData) + return jsonify({"status": "ok"}) case "POST": logger.info("Добавлен/обновлен запрос") @@ -145,28 +176,33 @@ def get_requests(): case "PATCH": logger.info("Выполнен запрос") - requestParams = ApiEndpoint.query.filter_by(id=requestData["id"]).first() - medodsDB = MedodsAPI.query.first() - baseUrl = medodsDB.url - response = send_request( - requestParams.method, - f"{baseUrl}{requestParams.url_path}", - requestParams.payload, - requestParams.query_params, - ) - exitData = {} try: - exitData = response.json() - except: - exitData = response.text - return jsonify(exitData) + requestParams = ApiEndpoint.query.filter_by( + id=requestData["id"] + ).first() + medodsDB = MedodsAPI.query.first() + baseUrl = medodsDB.url + response = send_request( + requestParams.method, + f"{baseUrl}{requestParams.url_path}", + requestParams.payload, + requestParams.query_params, + ) + exitData = {} + try: + exitData = response.json() + except: + exitData = response.text + return jsonify(exitData) + except Exception as e: + logger.error(f"Ошибка при выполнении запроса: {e}") + return jsonify({"status": "error"}), 500 case "GET": logger.info("Получен список запросов") requestsDB = ApiEndpoint.query.all() requestsList = [r.toDict() for r in requestsDB] - logger.info(requestsList) return jsonify( { "status": "ok", @@ -179,10 +215,9 @@ def get_requests(): return jsonify({"status": "error"}), 405 -@app.route("/settings/vk", methods=["POST"]) -def settings_vk(): +@app.route("/api/vk", methods=["POST"]) +def api_vk(): requestData = request.json - logger.info(requestData) if "id" in requestData: logger.info("Обновлен запрос") try: @@ -217,14 +252,77 @@ def settings_vk(): return jsonify({"status": "ok"}) -@app.route("/request", methods=["POST"]) -def make_request(): - data = request.json - response = send_request( - data["method"], data["url"], data.get("payload"), data.get("headers") - ) - return jsonify({"status": response.status_code}) +@app.route("/api/posts", methods=["POST", "GET"]) +def api_posts(): + match request.method: + case "POST": + requestData = request.json + logger.info("Настройки публикации и расписания") + vkPostData = requestData.get("vkPostData", None) + if vkPostData: + selectedUsers = vkPostData.get("selectedUsers", None) + static_text = vkPostData.get("static_text", None) + full_name = vkPostData.get("full_name", None) + logger.info("Обновление настроек публикации") + try: + vkPost = VkPost.query.first() + if vkPost: + if selectedUsers: + vkPost.selected_users = selectedUsers + if static_text: + vkPost.static_text = static_text + if full_name is not None: + vkPost.full_name = full_name + else: + db.session.merge(VkPost(**vkPostData)) + db.session.commit() + except Exception as e: + logger.error(f"Ошибка при обновлении настроек публикации: {e}") + schedulerData = requestData.get("schedulerData", None) + if schedulerData: + logger.info("Обновление расписания публикации") + try: + scheduler = PostScheduler.query.first() + startTime = schedulerData.get("startTime", None) + endTime = schedulerData.get("endTime", None) + interval_minutes = schedulerData.get("interval_minutes", None) + enabled = schedulerData.get("enabled", None) + if startTime: + scheduler.start_hour = int(startTime) + if endTime: + scheduler.end_hour = int(endTime) + if interval_minutes: + scheduler.interval_minutes = int(interval_minutes) + if enabled is not None: + scheduler.enabled = enabled + db.session.commit() + except Exception as e: + logger.error(f"Ошибка при обновлении расписания публикации: {e}") + return jsonify({"status": "ok"}) + + case "GET": + queryParams = request.args.to_dict() + action = queryParams.get("action", None) + if action: + match action: + case "update_users": + logger.info("Обновить список пользователей") + result = updateMedodsUsers() + return jsonify({"ok": result}) + case "handle_posts": + logger.info("Выполнить публикацию") + handle_vk_post() + return jsonify({"ok": True}) + case _: + logger.error("Неверный метод запроса") + return jsonify({"status": "error"}), 405 + return jsonify({"ok": False, "status": "error", "message": "no action"}) + + case _: + logger.error("Неверный метод запроса") + return jsonify({"status": "error"}), 405 if __name__ == "__main__": - app.run(debug=True, host="0.0.0.0", port=5000) + app.run(debug=True) + # app.run(debug=True, host="0.0.0.0", port=80) diff --git a/config.py b/config.py index 6a62984..2879570 100644 --- a/config.py +++ b/config.py @@ -9,6 +9,3 @@ class Config: SQLALCHEMY_TRACK_MODIFICATIONS = False LOG_FILE = os.path.join(BASE_DIR, "logs/app.log") - - VK_GROUP_TOKEN = "GROUP_ACCESS_TOKEN" - VK_GROUP_ID = 123456789 diff --git a/db.py b/db.py index f936789..66cf85e 100644 --- a/db.py +++ b/db.py @@ -65,3 +65,78 @@ class VkAPI(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 UsersMedods(db.Model): + __tablename__ = "users_medods" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.Text) + short_name = db.Column(db.Text) + sex = db.Column(db.Text) + step = db.Column(db.Integer) + specialties = db.Column(db.JSON) + 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, + "name": self.name, + "shortName": self.short_name, + "sex": self.sex, + "step": self.step, + "specialties": self.specialties, + "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 VkPost(db.Model): + __tablename__ = "vk_post" + + id = db.Column(db.Integer, primary_key=True) + dynamic_text = db.Column(db.Text, nullable=True) + static_text = db.Column(db.Text) + selected_users = db.Column(db.JSON) + full_name = db.Column(db.Boolean, default=True) + post_id = db.Column(db.Integer, 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, + "dynamic_text": self.dynamic_text, + "static_text": self.static_text, + "selected_users": self.selected_users, + "full_name": self.full_name, + "post_id": self.post_id, + "publish_at": self.publish_at, + "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 PostScheduler(db.Model): + __tablename__ = "post_scheduler" + + id = db.Column(db.Integer, primary_key=True) + start_hour = db.Column(db.Integer) + end_hour = db.Column(db.Integer) + interval_minutes = 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, + "start_hour": self.start_hour, + "end_hour": self.end_hour, + "interval_minutes": self.interval_minutes, + "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"), + } diff --git a/git_update.sh b/git_update.sh new file mode 100644 index 0000000..046668d --- /dev/null +++ b/git_update.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +# Получаем текущую дату и время +DATETIME=$(date "+%Y-%m-%d %H:%M:%S") + +# Проверка статуса +git status + +# Добавляем все изменения +git add . + +# Коммит с автокомментарием +git commit -m "Обновление БД ${DATETIME}" + +# Отправляем в удалённый репозиторий +git push \ No newline at end of file diff --git a/http_client.py b/http_client.py index 9c69a8a..0c82867 100644 --- a/http_client.py +++ b/http_client.py @@ -10,7 +10,6 @@ def send_request(method, url, json_data=None, params=None): bearer_token = generate_token() if bearer_token: headers["Authorization"] = f"Bearer {bearer_token}" - logger.info(headers) try: response = requests.request( @@ -20,8 +19,6 @@ def send_request(method, url, json_data=None, params=None): logger.error(f"Ошибка при выполнении запроса: {e}") return - logger.info(response.status_code) - logger.info(response.headers) - # logger.info(response.text) + logger.debug(f"Статус код: {response.status_code}") return response diff --git a/medods_handler.py b/medods_handler.py new file mode 100644 index 0000000..8077c3d --- /dev/null +++ b/medods_handler.py @@ -0,0 +1,177 @@ +import datetime +from db import ApiEndpoint, MedodsAPI, UsersMedods, VkPost, db +from http_client import send_request + + +def updateMedodsUsers() -> bool: + from app import logger + + try: + requestParams = ApiEndpoint.query.filter_by(title="Список докторов").first() + if not requestParams: + return False + response = send_request( + requestParams.method, + requestParams.url_path, + params=requestParams.query_params, + ) + if not response: + return False + usersFromDB = [] + for user in response.json(): + if user["availabilityForOnlineRecording"] != "available": + continue + userDict = { + "id": user["id"], + "name": f"{user['surname']} {user['name']} {user['secondName']}", + "short_name": f"{user['surname']} {user['name'][:1]}. {user['secondName'][:1]}.", + "sex": user["sex"], + "step": user["appointmentDuration"], + "specialties": [spec["title"] for spec in user["specialties"]], + } + usersFromDB.append(userDict) + + actualUsersIds = [user["id"] for user in usersFromDB] + allExistingUsers = UsersMedods.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.step != user["step"]: + existingUser.step = user["step"] + changes = True + if existingUser.specialties != user["specialties"]: + existingUser.specialties = user["specialties"] + changes = True + + if changes: + logger.info(f"Обновлен доктор {user['name']} {user['surname']}") + db.session.commit() + else: + newUser = UsersMedods( + id=user["id"], + name=user["name"], + short_name=user["short_name"], + sex=user["sex"], + step=user["step"], + specialties=user["specialties"], + ) + db.session.add(newUser) + db.session.commit() + logger.info(f"Добавлен доктор {user['name']} {user['surname']}") + + return True + except Exception as e: + logger.error(f"Ошибка при обновлении списка докторов: {e}") + return False + + +def getFreeSlots(vkPost) -> dict: + from app import logger + + try: + if not vkPost: + logger.error("Информация для размещения поста не найдена") + return {} + selectedUsersIds = vkPost.selected_users + selectedUsers = UsersMedods.query.filter( + UsersMedods.id.in_(selectedUsersIds) + ).all() + requestParams = ApiEndpoint.query.filter_by( + title="Свободные записи на дату" + ).first() + medodsApi = MedodsAPI.query.first() + + if not requestParams or len(selectedUsers) == 0 or not medodsApi: + logger.error("Ошибка получения необходимых параметров") + return {} + + userParams = [{str(user.id): {"step": user.step}} for user in selectedUsers] + + tomorrow = datetime.date.today() + datetime.timedelta(days=1) + the_day_after_tomorrow = tomorrow + datetime.timedelta(days=1) + startDate = tomorrow.strftime("%Y-%m-%d") + endDate = the_day_after_tomorrow.strftime("%Y-%m-%d") + + json = requestParams.payload + json["startDate"] = startDate + json["endDate"] = endDate + json["userParams"] = userParams + + url = medodsApi.url + requestParams.url_path + + response = send_request( + requestParams.method, + url, + json, + ) + if not response: + return {} + slotsDataFull = response.json() + if len(slotsDataFull.keys()) == 0: + return {} + firstKey = list(slotsDataFull.keys())[0] + slotsData = {"date": firstKey, "slots": slotsDataFull.get(firstKey)} + return slotsData + except Exception as e: + logger.error(f"Ошибка при получении свободных приемов: {e}") + return {} + + +def setDynamicText(): + from app import logger + + try: + vkPost = VkPost.query.first() + if not vkPost: + logger.error("Информация для размещения поста не найдена") + return vkPost + freeSlots = getFreeSlots(vkPost) + if len(freeSlots.keys()) == 0: + logger.error("Нет свободных приемов") + return vkPost + userIds = [int(key) for key in freeSlots["slots"].keys()] + usersMedods = UsersMedods.query.filter(UsersMedods.id.in_(userIds)).all() + if len(usersMedods) == 0: + logger.error("Не найдены доктора с свободными приемами") + return vkPost + + usersMedods.sort(key=lambda x: x.name) + users = [user.toDict() for user in usersMedods] + + dateText = ( + freeSlots["date"][8:] + + "." + + freeSlots["date"][5:7] + + "." + + freeSlots["date"][:4] + ) + + dynamicText = f"📌 Свободная запись на 📅 {dateText}:\n\n" + + for user in users: + sex_icon = "👨‍⚕️" if user["sex"] == "male" else "👩‍⚕️" + slots = freeSlots["slots"][str(user["id"])] + name = user["name"] if vkPost.full_name else user["shortName"] + + dynamicText += f"{sex_icon} {name} ({', '.join(user['specialties'])}):\n" + dynamicText += f"🕒 {', '.join(slots)}\n\n" + + vkPost.dynamic_text = dynamicText + db.session.commit() + + return vkPost + + except Exception as e: + logger.error(f"Ошибка при обновлении списка докторов: {e}") + return diff --git a/scheduler.py b/scheduler.py index 8235edd..880b634 100644 --- a/scheduler.py +++ b/scheduler.py @@ -1,7 +1,65 @@ from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger + +from db import PostScheduler + + +# ===== Scheduler ===== +scheduler: BackgroundScheduler | None = None +JOB_ID = "vk_publish_job" def start_scheduler(): - scheduler = BackgroundScheduler() - # scheduler.add_job(publish_vk_posts, "interval", seconds=30) - scheduler.start() + global scheduler + if scheduler is None: + scheduler = BackgroundScheduler() + scheduler.start() + + +# ===== Добавление задачи ===== +def enable_publish_job(): + """ + Включает выполнение handle_vk_post + """ + from vk_handler import handle_vk_post + + scheduleData = PostScheduler.query.first() + if not scheduleData or not scheduleData.enabled: + return + + start_hour = scheduleData.start_hour + end_hour = scheduleData.end_hour + interval_minutes = scheduleData.interval_minutes + + trigger = CronTrigger( + hour=f"{start_hour}-{end_hour - 1}", minute=f"*/{interval_minutes}" + ) + + scheduler.add_job(handle_vk_post, trigger=trigger, id=JOB_ID, replace_existing=True) + + +# ===== Отключение задачи ===== +def disable_publish_job(): + if scheduler and scheduler.get_job(JOB_ID): + scheduler.remove_job(JOB_ID) + + +def get_scheduler_status() -> dict: + scheduler_running = bool(scheduler and scheduler.running) + + job = scheduler.get_job(JOB_ID) if scheduler_running else None + + status = { + "scheduler": scheduler_running, + "vk_publish_job": job is not None, + "next_run_time": None, + } + + if job: + status["next_run_time"] = ( + job.next_run_time.strftime("%Y-%m-%d %H:%M:%S") + if job.next_run_time + else None + ) + + return status diff --git a/static/css/posts.css b/static/css/posts.css new file mode 100644 index 0000000..1660e34 --- /dev/null +++ b/static/css/posts.css @@ -0,0 +1,177 @@ +/* Карточки */ +.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: 500px; + 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; +} + +.table tbody tr { + transition: all 0.2s ease; +} + +.table tbody tr:hover { + background-color: rgba(13, 110, 253, 0.05); +} + +.table tbody tr.selected { + background-color: rgba(13, 110, 253, 0.1); +} + +/* Чекбоксы */ +.form-check-input { + width: 1.2em; + height: 1.2em; + cursor: pointer; +} + +.form-check-input:checked { + background-color: #0d6efd; + border-color: #0d6efd; +} + +/* Бейджи */ +.badge { + font-weight: 500; + padding: 0.35em 0.65em; +} + +.badge.bg-pink { + background-color: #e83e8c !important; + color: white; +} + +.specialty-badges { + max-width: 200px; +} + +/* Свитч */ +.form-switch .form-check-input { + width: 3em; + height: 1.5em; +} + +/* Поля ввода */ +.form-control:focus, +.form-select:focus { + border-color: #86b7fe; + box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); +} + +/* Кнопки */ +.btn { + border-radius: 6px; + font-weight: 500; + transition: all 0.2s ease; +} + +.btn-lg { + padding: 0.75rem 1.5rem; + font-size: 1.1rem; +} + +/* Уведомления */ +.alert-fixed { + position: fixed; + top: 80px; + right: 20px; + z-index: 1050; + min-width: 300px; + max-width: 400px; +} + +.alert { + border-radius: 8px; + 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; +} + +/* Пользовательские стили */ +.user-row { + cursor: pointer; +} + +.user-row:hover td { + background-color: rgba(13, 110, 253, 0.05); +} + +/* Статус планировщика */ +#schedulerStatus .badge { + font-size: 0.85rem; + padding: 0.4em 0.8em; +} + +/* Адаптивность */ +@media (max-width: 768px) { + .table-responsive { + font-size: 0.9rem; + } + + .specialty-badges .badge { + font-size: 0.7rem; + padding: 0.25em 0.5em; + margin-bottom: 0.25rem; + } + + .btn { + padding: 0.375rem 0.75rem; + font-size: 0.875rem; + } +} + +/* Пустая таблица */ +.text-center.py-5 { + color: #6c757d; +} + +.text-center.py-5 .display-1 { + font-size: 4rem; + margin-bottom: 1rem; +} \ No newline at end of file diff --git a/static/js/medods.js b/static/js/medods.js index f800519..739149a 100644 --- a/static/js/medods.js +++ b/static/js/medods.js @@ -54,7 +54,7 @@ async function saveServerUrl() { } try { - const response = await fetch('/settings/medods', { + const response = await fetch('/api/medods', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: serverUrl }) @@ -107,7 +107,7 @@ async function uploadApiKey() { apiKey[headers[i]] = keyInfo[i]; } - const response = await fetch('/settings/medods', { + const response = await fetch('/api/medods', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apiKey }) @@ -132,7 +132,7 @@ async function uploadApiKey() { // Загрузка списка запросов async function loadRequests() { try { - const response = await fetch('/settings/requests'); + const response = await fetch('/api/requests'); const data = await response.json(); requestsData = data.requests ? data.requests : []; renderRequestsList(); @@ -392,7 +392,7 @@ async function saveRequest() { if (id) requestData.id = parseInt(id); try { - const response = await fetch('/settings/requests', { + const response = await fetch('/api/requests', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestData) @@ -417,7 +417,7 @@ async function deleteRequest(id) { if (!confirm('Вы уверены, что хотите удалить этот запрос?')) return; try { - const response = await fetch(`/settings/requests/${id}`, { + const response = await fetch(`/api/requests/${id}`, { method: 'DELETE' }); @@ -448,7 +448,7 @@ async function executeCurrentRequest() { async function executeRequest(id) { try { showLoader(true); - const response = await fetch('/settings/requests', { + const response = await fetch('/api/requests', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: parseInt(id) }) diff --git a/static/js/posts.js b/static/js/posts.js new file mode 100644 index 0000000..e1fb641 --- /dev/null +++ b/static/js/posts.js @@ -0,0 +1,362 @@ +// Глобальные переменные +let originalSettings = null; +let hasChanges = false; + +// Инициализация при загрузке страницы +document.addEventListener('DOMContentLoaded', function () { + // Сохраняем оригинальные настройки для сравнения + saveOriginalSettings(); + + // Отслеживаем изменения в форме + setupChangeListeners(); + + // Обновляем статус планировщика + updateSchedulerStatus(); +}); + +// Сохранение оригинальных настроек +function saveOriginalSettings() { + originalSettings = { + selected_users: getSelectedUsers(), + static_text: document.getElementById('static_text').value, + full_name: document.getElementById('full_name').checked, + start_hour: parseInt(document.getElementById('start_hour').value), + end_hour: parseInt(document.getElementById('end_hour').value), + interval_minutes: parseInt(document.getElementById('interval_minutes').value), + enabled: document.getElementById('scheduler_enabled').checked + }; +} + +// Настройка отслеживания изменений +function setupChangeListeners() { + // Чекбоксы пользователей + document.querySelectorAll('.user-checkbox').forEach(checkbox => { + checkbox.addEventListener('change', function () { + updateSelectAllCheckbox(); + checkForChanges(); + }); + }); + + // Поля формы + const formFields = ['static_text', 'start_hour', 'end_hour', 'interval_minutes']; + formFields.forEach(field => { + const element = document.getElementById(field); + if (element) { + element.addEventListener('input', checkForChanges); + } + }); + + // Свитчи + document.getElementById('full_name').addEventListener('change', checkForChanges); + document.getElementById('scheduler_enabled').addEventListener('change', checkForChanges); + document.getElementById('selectAll').addEventListener('change', checkForChanges); +} + +// Проверка на изменения +function checkForChanges() { + const currentSettings = { + selected_users: getSelectedUsers(), + static_text: document.getElementById('static_text').value, + full_name: document.getElementById('full_name').checked, + start_hour: parseInt(document.getElementById('start_hour').value), + end_hour: parseInt(document.getElementById('end_hour').value), + interval_minutes: parseInt(document.getElementById('interval_minutes').value), + enabled: document.getElementById('scheduler_enabled').checked + }; + + hasChanges = JSON.stringify(originalSettings) !== JSON.stringify(currentSettings); + + // Можно добавить визуальное отображение изменений + const saveButton = document.querySelector('.btn-success'); + if (hasChanges && saveButton) { + saveButton.innerHTML = 'Сохранить изменения'; + saveButton.classList.add('btn-warning'); + saveButton.classList.remove('btn-success'); + } else if (saveButton) { + saveButton.innerHTML = 'Сохранить все настройки'; + saveButton.classList.remove('btn-warning'); + saveButton.classList.add('btn-success'); + } +} + +// Получение выбранных пользователей +function getSelectedUsers() { + const selectedUsers = []; + document.querySelectorAll('.user-checkbox:checked').forEach(checkbox => { + const userId = parseInt(checkbox.id.replace('user_', '')); + selectedUsers.push(userId); + }); + return selectedUsers; +} + +// Обновление чекбокса "Выбрать все" +function updateSelectAllCheckbox() { + const allCheckboxes = document.querySelectorAll('.user-checkbox'); + const checkedCheckboxes = document.querySelectorAll('.user-checkbox:checked'); + const selectAllCheckbox = document.getElementById('selectAll'); + + if (allCheckboxes.length === checkedCheckboxes.length) { + selectAllCheckbox.checked = true; + selectAllCheckbox.indeterminate = false; + } else if (checkedCheckboxes.length === 0) { + selectAllCheckbox.checked = false; + selectAllCheckbox.indeterminate = false; + } else { + selectAllCheckbox.checked = false; + selectAllCheckbox.indeterminate = true; + } +} + +// Выбрать всех пользователей +function selectAllUsers() { + document.querySelectorAll('.user-checkbox').forEach(checkbox => { + checkbox.checked = true; + }); + updateSelectAllCheckbox(); + checkForChanges(); +} + +// Снять выбор со всех пользователей +function deselectAllUsers() { + document.querySelectorAll('.user-checkbox').forEach(checkbox => { + checkbox.checked = false; + }); + updateSelectAllCheckbox(); + checkForChanges(); +} + +// Переключить выбор всех пользователей +function toggleAllUsers() { + const selectAllCheckbox = document.getElementById('selectAll'); + const isChecked = selectAllCheckbox.checked; + + document.querySelectorAll('.user-checkbox').forEach(checkbox => { + checkbox.checked = isChecked; + }); + + selectAllCheckbox.indeterminate = false; + checkForChanges(); +} + +// Обновление списка пользователей +async function updateUsersList() { + try { + const response = await fetch('/api/posts?action=update_users'); + const data = await response.json(); + + if (data.ok) { + showAlert('success', 'Список пользователей обновлен!'); + // Перезагружаем страницу через 1.5 секунды + setTimeout(() => { + window.location.reload(); + }, 1500); + } else { + showAlert('danger', 'Ошибка обновления списка пользователей'); + } + } catch (error) { + console.error('Ошибка:', error); + showAlert('danger', 'Ошибка обновления списка пользователей'); + } +} + +// Публикация сейчас +async function publishNow() { + const selectedUsers = getSelectedUsers(); + const staticText = document.getElementById('static_text').value.trim(); + + if (selectedUsers.length === 0) { + showAlert('warning', 'Выберите хотя бы одного сотрудника для публикации'); + return; + } + + if (!staticText) { + showAlert('warning', 'Введите текст поста'); + return; + } + + try { + const response = await fetch('/api/posts?action=handle_posts'); + const data = await response.json(); + + if (data.ok) { + showAlert('success', 'Публикация запущена! Проверьте ваше сообщество VK.'); + } else { + showAlert('danger', 'Ошибка при запуске публикации'); + } + } catch (error) { + console.error('Ошибка:', error); + showAlert('danger', 'Ошибка при запуске публикации'); + } +} + +// Сохранение настроек +async function saveSettings() { + const selectedUsers = getSelectedUsers(); + const staticText = document.getElementById('static_text').value.trim(); + const fullName = document.getElementById('full_name').checked; + const startHour = parseInt(document.getElementById('start_hour').value); + const endHour = parseInt(document.getElementById('end_hour').value); + const intervalMinutes = parseInt(document.getElementById('interval_minutes').value); + const schedulerEnabled = document.getElementById('scheduler_enabled').checked; + + // Валидация + if (selectedUsers.length === 0 && hasChanges) { + showAlert('warning', 'Выберите хотя бы одного сотрудника для публикации'); + return; + } + + if (!staticText && hasChanges) { + showAlert('warning', 'Введите текст поста'); + return; + } + + if (startHour < 0 || startHour > 23) { + showAlert('warning', 'Время начала должно быть от 0 до 23 часов'); + return; + } + + if (endHour < 0 || endHour > 23) { + showAlert('warning', 'Время окончания должно быть от 0 до 23 часов'); + return; + } + + if (startHour >= endHour) { + showAlert('warning', 'Время начала должно быть раньше времени окончания'); + return; + } + + if (intervalMinutes < 1 || intervalMinutes > 1440) { + showAlert('warning', 'Интервал должен быть от 1 до 1440 минут'); + return; + } + + // Подготовка данных для отправки + const postData = { + vkPostData: {}, + schedulerData: {} + }; + + // Только измененные данные для vkPostData + if (JSON.stringify(selectedUsers) !== JSON.stringify(originalSettings.selected_users) || + staticText !== originalSettings.static_text || + fullName !== originalSettings.full_name) { + postData.vkPostData = { + selectedUsers: selectedUsers, + static_text: staticText, + full_name: fullName + }; + } + + // Только измененные данные для schedulerData + if (startHour !== originalSettings.start_hour || + endHour !== originalSettings.end_hour || + intervalMinutes !== originalSettings.interval_minutes || + schedulerEnabled !== originalSettings.enabled) { + postData.schedulerData = { + startTime: startHour.toString(), + endTime: endHour.toString(), + interval_minutes: intervalMinutes.toString(), + enabled: schedulerEnabled + }; + } + + // Если нет изменений + if (Object.keys(postData.vkPostData).length === 0 && + Object.keys(postData.schedulerData).length === 0) { + showAlert('info', 'Нет изменений для сохранения'); + return; + } + + try { + const response = await fetch('/api/posts', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(postData) + }); + + const data = await response.json(); + + if (response.ok && data.status === 'ok') { + showAlert('success', 'Настройки успешно сохранены!'); + // Обновляем оригинальные настройки + saveOriginalSettings(); + checkForChanges(); + + // Если менялись настройки планировщика, обновляем статус + if (Object.keys(postData.schedulerData).length > 0) { + setTimeout(updateSchedulerStatus, 1000); + } + } else { + const error = data.message || 'Ошибка сохранения'; + showAlert('danger', error); + } + } catch (error) { + console.error('Ошибка:', error); + showAlert('danger', 'Ошибка сохранения настроек'); + } +} + +// Обновление статуса планировщика +async function updateSchedulerStatus() { + try { + // Здесь можно добавить запрос для получения актуального статуса + // Пока просто обновляем визуально + const enabled = document.getElementById('scheduler_enabled').checked; + const statusBadge = document.getElementById('schedulerStatus'); + + if (statusBadge) { + if (enabled) { + statusBadge.innerHTML = 'Активен'; + } else { + statusBadge.innerHTML = 'Неактивен'; + } + } + } catch (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 = ` +
+ +
${message}
+ +
+ `; + + alertContainer.appendChild(alert); + + // Автоматическое удаление через 5 секунд + setTimeout(() => { + if (alert.parentNode) { + alert.classList.remove('show'); + setTimeout(() => alert.remove(), 150); + } + }, 5000); +} + +function getAlertIcon(type) { + const icons = { + 'success': 'bi-check-circle-fill', + 'warning': 'bi-exclamation-triangle-fill', + 'danger': 'bi-x-circle-fill', + 'info': 'bi-info-circle-fill' + }; + return icons[type] || 'bi-info-circle-fill'; +} \ No newline at end of file diff --git a/static/js/vk.js b/static/js/vk.js index a17a3a5..7d12f09 100644 --- a/static/js/vk.js +++ b/static/js/vk.js @@ -72,7 +72,7 @@ async function saveVkSettings() { } try { - const response = await fetch('/settings/vk', { + const response = await fetch('/api/vk', { method: 'POST', headers: { 'Content-Type': 'application/json' diff --git a/templates/posts.html b/templates/posts.html index b45d3d3..a1817ac 100644 --- a/templates/posts.html +++ b/templates/posts.html @@ -1,29 +1,252 @@ {% extends "base.html" %} {% block title %}Посты{% endblock %} +{% block styles %} + +{% endblock %} + {% block content %} -

📝 Посты

- -
-
Новый пост
-
- - - - - - - + +
+
+

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

+

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

+
+
+ Публикации
-

📅 Запланированные

+ +
+ +
+
+
+
Сотрудники Medods
+
+ + +
+
+
+ +
+ + + + + + + + + + + + + + {% for user in data.medodsUsers %} + + + + + + + + + + {% endfor %} + +
+
+ +
+
IDИмяКороткое имяПолПрием, мин.Специальности
+
+ +
+
{{ user.id }} + {{ user.name }} + {{ user.shortName }} + {% if user.sex == 'male' %} + Муж + {% elif user.sex == 'female' %} + Жен + {% else %} + - + {% endif %} + + {{ user.step }} + + {% if user.specialties %} +
+ {% for specialty in user.specialties %} + {{ specialty }} + {% endfor %} +
+ {% else %} + - + {% endif %} +
+
-
-
-
-{{ posts or "Постов нет" }}
-        
+ + {% if not data.medodsUsers %} +
+ +
Нет сотрудников
+

Загрузите список сотрудников из Medods

+
+ {% endif %} +
+ +
+
+ + +
+ +
+
+
Настройки текста поста
+
+
+
+ + +
+ Будет добавлен в начало поста перед именами сотрудников +
+
+ +
+ +
+ + +
+
+ Если выключено, будут использоваться короткие имена +
+
+ + +
+
+ + +
+
+
Настройки расписания
+ + {% if data.schedulerStatus.scheduler %} + Активен + {% else %} + Неактивен + {% endif %} + +
+
+ +
+
+ +
+ + + :00 +
+
+
+ +
+ + + :00 +
+
+
+ + +
+ + +
+ Через сколько минут публиковать следующий пост +
+
+ + +
+ +
+ + +
+
+ + + {% if data.schedulerStatus.next_run_time %} +
+
+
+ +
+
+
Следующая публикация
+

{{ data.schedulerStatus.next_run_time }}

+
+
+
+ {% endif %} +
+
+ + +
+
+
+
+ +
+ Сохраняются: выбранные сотрудники, текст поста, настройки расписания +
+
+
+
+
+ + +
+{% endblock %} + +{% block scripts %} + {% endblock %} \ No newline at end of file diff --git a/templates/vk.html b/templates/vk.html index b94e9a6..6465436 100644 --- a/templates/vk.html +++ b/templates/vk.html @@ -88,28 +88,6 @@
- -
-
-
- -
-
-
Как получить данные:
-
    -
  • Access Token: Создайте Standalone-приложение в управлении - приложениями VK
  • -
  • ID сообщества: Число в адресе сообщества после - vk.com/public или vk.com/club -
  • -
  • ID Базового фото: Загрузите фото в альбом сообщества и - скопируйте ID из адреса фото
  • -
-
-
-
-
diff --git a/vk_handler.py b/vk_handler.py new file mode 100644 index 0000000..75f0bcf --- /dev/null +++ b/vk_handler.py @@ -0,0 +1,51 @@ +from datetime import datetime +import vk_api +from db import VkAPI, db +from medods_handler import setDynamicText + + +def handle_vk_post(): + from app import logger + + logger.info("Обновление поста") + + vkApi = VkAPI.query.first() + if not vkApi: + logger.error("Информация для работы не найдена") + return + + vkPost = setDynamicText() + if not vkPost: + logger.error("Информация для размещения поста не найдена") + return + + if not vkPost.dynamic_text and not vkPost.post_id: + logger.info("Не требуется публикация или удаление поста") + return + + vk_session = vk_api.VkApi(token=vkApi.access_token) + vk = vk_session.get_api() + + new_post = {} + + if vkPost.dynamic_text: + logger.info("Публикация поста") + new_post = vk.wall.post( + owner_id=-vkApi.group_id, + from_group=1, + message=f"{vkPost.dynamic_text}/n{vkPost.static_text}".strip(), + attachments=f"photo-{vkApi.group_id}_{vkApi.base_photo_url}", + ) + + if vkPost.post_id: + logger.info("Удаление поста") + 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.post_id = new_post.get("post_id") + vkPost.publish_at = datetime.now() + + db.session.commit() diff --git a/vk_worker.py b/vk_worker.py deleted file mode 100644 index 666b02b..0000000 --- a/vk_worker.py +++ /dev/null @@ -1,30 +0,0 @@ -import vk_api -from config import Config -from db import db, VkPost -from datetime import datetime - - -def publish_vk_posts(): - vk_session = vk_api.VkApi(token=Config.VK_GROUP_TOKEN) - vk = vk_session.get_api() - - posts = VkPost.query.filter( - VkPost.published.is_(False), VkPost.publish_at <= datetime.utcnow() - ).all() - - for post in posts: - attachments = [] - - if post.image_path: - upload = vk_api.VkUpload(vk_session) - photo = upload.photo_wall(post.image_path, group_id=Config.VK_GROUP_ID) - attachments.append(f"photo{photo[0]['owner_id']}_{photo[0]['id']}") - - vk.wall.post( - owner_id=-Config.VK_GROUP_ID, - message=post.text, - attachments=",".join(attachments), - ) - - post.published = True - db.session.commit()