This commit is contained in:
Ayrelia 2024-07-25 15:19:15 +03:00
parent f1b9cce5ec
commit d4e736d6a8
69 changed files with 6383 additions and 0 deletions

12
.env.example Normal file
View File

@ -0,0 +1,12 @@
BOT_TOKEN=
DB_HOST=
DB_LOG=
DB_PASS=
DB_NAME=
DB_PORT=
SECRET_KEY_AUTH=
SESSION_EXPIRATION_TIME=

133
.gitignore vendored Normal file
View File

@ -0,0 +1,133 @@
# Custom
*_dev.*
*.sqlite3*
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
# Translations
# *.mo
# *.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/

24
Dockerfile Normal file
View File

@ -0,0 +1,24 @@
FROM python:3.9-slim
# Установка необходимых системных пакетов
RUN apt-get update && apt-get install -y \
gcc \
libpq-dev \
postgresql \
nginx \
&& rm -rf /var/lib/apt/lists/*
# Установка зависимостей Python
COPY requirements.txt .
RUN pip install --upgrade pip
RUN pip install --no-cache-dir -r requirements.txt
# Копирование файлов приложения
COPY . /app
WORKDIR /app
EXPOSE 80
# Запуск Nginx и вашего приложения
CMD service nginx start && tail -f /dev/null

6
bot/__init__.py Normal file
View File

@ -0,0 +1,6 @@
__title__ = "bot"
__path__ = __import__("pkgutil").extend_path(__path__, __name__)
from . import *
ENCODING = "utf-8-sig"

95
bot/__main__.py Normal file
View File

@ -0,0 +1,95 @@
from logging.handlers import RotatingFileHandler
import os
from config import dp, logging, bot, Bot, current_directory, root_path
from aiogram.types import BotCommand, BotCommandScopeDefault
from middlewares import setup
from handlers import routers
import asyncio
from db.models import create_tables
from db.db import engine
from apscheduler.schedulers.asyncio import AsyncIOScheduler
import sys
schedulers = AsyncIOScheduler()
@dp.startup()
async def start_commands(bot: Bot):
commands = [
BotCommand(
command='start',
description='🔄 Главное меню'
)
]
await bot.set_my_commands(commands, BotCommandScopeDefault())
#schedulers.add_job() 123
schedulers.start()
task1 = asyncio.create_task(create_tables()) # создаем базу юзеров если нет
@dp.shutdown()
async def dispose(bot: Bot):
schedulers.shutdown()
engine.dispose()
async def main() -> None: # функция запуска бота
log_dir = '/app/logs'
os.makedirs(log_dir, exist_ok=True) # Создать директорию, если не существует
log_file = os.path.join(log_dir, 'app_bot.log')
# Создать форматтер
formatter = logging.Formatter('%(asctime)s - [%(levelname)s] - %(name)s - '
'(%(filename)s).%(funcName)s(%(lineno)d) - %(message)s')
logging.getLogger("requests").setLevel(logging.WARNING)
logging.getLogger("apscheduler").setLevel(logging.WARNING)
# Настроить основной логгер
logger = logging.getLogger()
logger.setLevel(logging.INFO)
# Создать обработчик для ротации логов
file_handler = RotatingFileHandler(log_file, maxBytes=10*1024*1024, backupCount=5)
file_handler.setLevel(logging.INFO)
file_handler.setFormatter(formatter)
# Создать консольный обработчик
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
console_handler.setFormatter(formatter)
# Добавить обработчики к логгеру
logger.addHandler(file_handler)
logger.addHandler(console_handler)
for router in routers:
dp.include_router(router) # импорт роутеров
setup(dp) # мидлвари
try:
await dp.start_polling(bot) # запуск поллинга
except Exception as ex:
print(ex)
if __name__ == "__main__":
if sys.version_info >= (3, 8) and sys.platform.lower().startswith("win"):
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) # устанавливаем политику для лупа если wind
asyncio.run(main())

52
bot/config.py Normal file
View File

@ -0,0 +1,52 @@
from aiogram import Bot, Dispatcher, F, Router
from aiogram.fsm.context import FSMContext
from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.fsm.state import State, StatesGroup
from aiogram.types.input_file import FSInputFile
from aiogram.types import FSInputFile
from aiogram.types import (
KeyboardButton,
Message,
Update,
CallbackQuery,
ReplyKeyboardMarkup,
ReplyKeyboardRemove,
InlineKeyboardButton,
InlineKeyboardMarkup
)
from aiogram.utils.keyboard import InlineKeyboardBuilder, ReplyKeyboardBuilder
from aiogram.filters.callback_data import CallbackData
from aiogram import types
from aiogram.filters import Filter
import logging
import datetime
import json
import os
from dotenv import load_dotenv
from pydantic_settings import BaseSettings
from pathlib import Path
from aiogram.client.bot import DefaultBotProperties
from aiogram.enums.parse_mode import ParseMode
load_dotenv()
current_directory = os.path.abspath(os.path.dirname(__file__))
root_path = Path(__file__).parent.parent
class Settings(BaseSettings): # создаем settings class
BOT_TOKEN: str = os.getenv("BOT_TOKEN")
settings = Settings()
dp = Dispatcher(storage=MemoryStorage())
bot = Bot(settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))

0
bot/db/__init__.py Normal file
View File

13
bot/db/db.py Normal file
View File

@ -0,0 +1,13 @@
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
import os
sqlalchemy_url = f"postgresql+asyncpg://{os.environ.get('DB_LOG')}:{os.environ.get('DB_PASS')}@{os.environ.get('DB_HOST')}:{os.environ.get('DB_PORT')}/{os.environ.get('DB_NAME')}"
engine = create_async_engine(sqlalchemy_url)
async_session = async_sessionmaker(engine)

89
bot/db/models.py Normal file
View File

@ -0,0 +1,89 @@
from dataclasses import dataclass
from db.db import engine, async_session
from sqlalchemy import BigInteger, text, ForeignKey
from sqlalchemy.orm import relationship, Mapped, mapped_column, DeclarativeBase
from sqlalchemy.ext.asyncio import AsyncAttrs
from sqlalchemy import Integer, String, CheckConstraint, UniqueConstraint, Boolean
class Base(AsyncAttrs, DeclarativeBase):
pass
# Модель для таблицы `users`
class UserTg(Base):
__tablename__ = 'users'
id = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id = mapped_column(String(100), unique=True, index=True)
username = mapped_column(String(100), default=None) # На уровне Python
status = mapped_column(
String(25),
CheckConstraint("status IN ('user', 'oper', 'admin')"), # Позиционный аргумент перед именованными
default='user', # На уровне Python
server_default='user', # На уровне базы данных
)
has_access = mapped_column(
Boolean,
default='False', # На уровне Python
server_default='False', # На уровне базы данных,
nullable=False
)
reg_date = mapped_column(
String(50),
server_default=text("CURRENT_TIMESTAMP"), # На уровне базы данных
)
ban = mapped_column(
String(25),
CheckConstraint("ban IN ('yes', 'no')"),
default='no', # На уровне Python__annotat__annotations__())ons__())
server_default='no', # На уровне базы данных
)
class Otdels(Base):
__tablename__ = 'otdels'
id = mapped_column(Integer, primary_key=True, index=True)
name = mapped_column(String)
questions = relationship("Questions", back_populates="otdel", cascade="all, delete-orphan")
class Questions(Base):
__tablename__ = 'questions'
id = mapped_column(Integer, primary_key=True, index=True)
name = mapped_column(String)
answer = mapped_column(String)
type_answer = mapped_column(String)
file = mapped_column(String)
otdel_id = mapped_column(Integer, ForeignKey('otdels.id'))
otdel = relationship("Otdels", back_populates="questions")
async def create_tables():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
from dataclasses import dataclass
@dataclass
class UserData:
id: int
user_id: str
username: str
status: str
has_access: str
reg_date: str
ban: str

37
bot/filters/filtersbot.py Normal file
View File

@ -0,0 +1,37 @@
from config import CallbackData, Filter
from sql_function import databasework
class AdminCheck(Filter): # фильтр проверка на админа
async def __call__(self, message) -> bool:
return await databasework.check_admin(message.from_user.id)
class PageCallback(CallbackData, prefix="page"):
action: str
page: int
class PageCallbackQuestions(CallbackData, prefix="page"):
action: str
page: int
otdel_id: int
class OtdelsMarkup(CallbackData, prefix="page"):
action: str
id_otdel: int
class BackToQuestions(CallbackData, prefix="page"):
action: str
id_otdel: int
class QuestionsMarkup(CallbackData, prefix="page"):
action: str
id_question: int
id_otdel: int

4
bot/handlers/__init__.py Normal file
View File

@ -0,0 +1,4 @@
from .user import user
from .admin import admin
routers = (user, admin)

27
bot/handlers/admin.py Normal file
View File

@ -0,0 +1,27 @@
from config import Bot, F, Router, FSInputFile, types, FSMContext, State, bot, CallbackData, FSInputFile
from markups.adminmarkup import *
from sql_function import databasework
from filters import filtersbot
router = Router()
router.message.filter(filtersbot.AdminCheck()) # привязываем фильтр к роутеру
router.callback_query.filter(filtersbot.AdminCheck()) # привязываем фильтр к роутеру
@router.message(F.text == '/admin', F.chat.type == 'private')
async def start(message: types.Message, state: FSMContext):
#photo = FSInputFile('kwork8/photo/start_message.jpg')
await state.clear()
markup = admin_markup()
await message.answer(f'💎 Вы попали в админ-панель', reply_markup=markup)
admin = router

154
bot/handlers/user.py Normal file
View File

@ -0,0 +1,154 @@
import base64
from io import BytesIO
from pathlib import Path
from config import Bot, F, Router, FSInputFile, types, FSMContext, State, bot, CallbackData, FSInputFile, settings
from markups.markup import *
from filters import filtersbot
from sql_function import databasework
from states.states import RequestState
from datetime import timedelta
import datetime
from aiogram.types import Chat
from db.models import UserTg
from config import current_directory
# Создайте объект Path из текущего пути
current_path = Path(current_directory)
# Перейдите на одну папку назад
parent_directory = current_path.parent
# Добавьте 'uploads' к родительскому пути
UPLOAD_DIRECTORY = parent_directory / "webadmin/uploads"
router = Router()
router.message.filter(filtersbot.AdminCheck()) # привязываем фильтр к роутеру
router.callback_query.filter(filtersbot.AdminCheck()) # привязываем фильтр к роутеру
# ========= Старт ======== #
@router.message(F.text == '/start', F.chat.type == 'private')
async def start(message: types.Message, state: FSMContext, bot_user: UserTg):
await state.clear()
markup = start_markup()
await message.answer(f'⭐️ Добро пожаловать в бота, используйте кнопки ниже', reply_markup=markup)
# ======= Возвраты назад ===== #
@router.callback_query(filtersbot.BackToQuestions.filter(F.action == 'back'))
async def questions_page_handler(callback: types.CallbackQuery, callback_data: filtersbot.BackToQuestions) -> None:
await bot.delete_message(chat_id=callback.message.chat.id, message_id=callback.message.message_id)
texts, markup = await questions_markup(callback_data.id_otdel)
text_ = '\n'.join(texts)
await bot.send_message(text=f'⭐️ Выберите нужный вам вопрос\n\n{text_}', chat_id=callback.message.chat.id, reply_markup=markup)
@router.callback_query(F.data == 'back_to_otdels')
async def back_to_otdels(callback: types.CallbackQuery, state: FSMContext):
await state.clear()
markup = await otdels_markup()
await bot.edit_message_text(message_id=callback.message.message_id, chat_id=callback.message.chat.id, text='⭐️ Добро пожаловать в бота, используйте кнопки ниже', reply_markup=markup)
# ========= Пагинация ======== #
@router.callback_query(filtersbot.PageCallback.filter(F.action == 'otdel'))
async def otdels_page_handler(callback: types.CallbackQuery, callback_data: filtersbot.PageCallback) -> None:
await callback.message.edit_reply_markup(
reply_markup=await otdels_markup(callback_data.page),
)
await callback.answer()
@router.callback_query(filtersbot.PageCallbackQuestions.filter(F.action == 'question'))
async def questions_page_handler(callback: types.CallbackQuery, callback_data: filtersbot.PageCallbackQuestions) -> None:
texts, markup = await questions_markup(callback_data.otdel_id, callback_data.page)
text_ = '\n'.join(texts)
await callback.message.edit_text(
text=f'⭐️ Выберите нужный вам вопрос\n\n{text_}',
reply_markup=markup,
)
await callback.answer()
# ========= Меню ======== #
@router.callback_query(F.data == 'main_menu')
async def go_main(callback: types.CallbackQuery, state: FSMContext):
await state.clear()
markup = start_markup()
await bot.edit_message_text(message_id=callback.message.message_id, chat_id=callback.message.chat.id, text='⭐️ Добро пожаловать в бота, используйте кнопки ниже', reply_markup=markup)
# ============ Отделы ========== #
@router.callback_query(F.data.in_(['pizza', 'ebi']))
async def otdels(callback: types.CallbackQuery, state: FSMContext):
markup = await otdels_markup()
await bot.edit_message_text(text=f'⭐️ Выберите нужный вам отдел', chat_id=callback.message.chat.id, message_id=callback.message.message_id, reply_markup=markup)
# ============ Вопросы ========== #
@router.callback_query(filtersbot.OtdelsMarkup.filter(F.action == 'open'))
async def questions(callback: types.CallbackQuery, callback_data: filtersbot.OtdelsMarkup) -> None:
texts, markup = await questions_markup(callback_data.id_otdel)
text_ = '\n'.join(texts)
await bot.edit_message_text(text=f'⭐️ Выберите нужный вам вопрос\n\n{text_}', chat_id=callback.message.chat.id, message_id=callback.message.message_id, reply_markup=markup)
# ============ Ответ на вопрос ========== #
@router.callback_query(filtersbot.QuestionsMarkup.filter(F.action == 'open_ques'))
async def question(callback: types.CallbackQuery, callback_data: filtersbot.QuestionsMarkup) -> None:
id_question = callback_data.id_question
question = await databasework.get_question(id_question)
answer = question[2]
filename = question[4]
markup = back_to_questions(callback_data.id_otdel)
if filename:
await bot.delete_message(chat_id=callback.message.chat.id, message_id=callback.message.message_id)
file = FSInputFile(path=f"{UPLOAD_DIRECTORY}/{filename}")
if filename.split('.')[-1] == 'jpg' or filename.split('.')[-1] == 'png':
await bot.send_photo(caption=answer, chat_id=callback.message.chat.id, photo=file, reply_markup=markup)
return
if filename.split('.')[-1] == 'mp4':
await bot.send_video(caption=answer, chat_id=callback.message.chat.id, video=file, reply_markup=markup)
return
await bot.send_document(caption=answer, chat_id=callback.message.chat.id, document=file, reply_markup=markup)
else:
await bot.edit_message_text(text=answer, chat_id=callback.message.chat.id, message_id=callback.message.message_id, reply_markup=markup)
user = router

View File

@ -0,0 +1,31 @@
from config import types, InlineKeyboardBuilder
from filters import filtersbot
from sql_function import databasework
def admin_markup():
markup = (
InlineKeyboardBuilder()
.button(text='⚙️ Выгрузить юзеров', callback_data='users')
.button(text='🛠 Тарифы', callback_data='tarifs_admin')
.button(text='🔴 Заблокировать юзера', callback_data='ban_user')
.button(text='✉️ Рассылка', callback_data='mailing')
.button(text='📊 Статистика', callback_data='statistic')
.button(text='💲 Выдать подписку', callback_data='give_subscribe')
.button(text='📝 Написать пользователю', callback_data='send_message')
.adjust(1, 2, 2, 1, 1, repeat=True)
.as_markup()
)
return markup
def main_admin():
markup = (
InlineKeyboardBuilder()
.button(text='🔙 В главное меню', callback_data='main_admin')
.adjust(2, repeat=True)
.as_markup()
)
return markup

175
bot/markups/markup.py Normal file
View File

@ -0,0 +1,175 @@
from config import types, InlineKeyboardBuilder
from filters import filtersbot
from sql_function import databasework
import math
def start_markup(): # старт кнопки
markup = (
InlineKeyboardBuilder()
.button(text='Тич-Пицца', callback_data='pizza') #
.button(text='Ебидоеби', callback_data='ebi')
.adjust(1, repeat=True)
.as_markup()
)
return markup
async def otdels_markup(current_page: int = 0):
locations = await databasework.get_all_otdels()
markup = InlineKeyboardBuilder()
page_width = 10 # настройка пагинации
page_offset = current_page * page_width
last_page = max(0, math.ceil(len(locations) / page_width) - 1)
prev_page = max(0, current_page - 1)
next_page = min(last_page, current_page + 1)
for i in locations[page_offset : page_offset + page_width]:
name = i[1]
markup.add(
types.InlineKeyboardButton(
text=name,
callback_data=filtersbot.OtdelsMarkup(action='open', id_otdel=i[0]).pack()
)
)
markup.adjust(1)
# Создаем кнопки для пагинации
pagination_buttons = []
if prev_page != current_page: # кнопка "Назад"
pagination_buttons.append(
types.InlineKeyboardButton(
text="◀️",
callback_data=filtersbot.PageCallback(page=prev_page, action='otdel').pack()
)
)
# Кнопка для текущей страницы
pagination_buttons.append(
types.InlineKeyboardButton(
text=f"{current_page + 1}/{last_page + 1}",
callback_data='no_action' # Эта кнопка не будет выполнять действие
)
)
if last_page != current_page: # кнопка "Вперед"
pagination_buttons.append(
types.InlineKeyboardButton(
text="▶️",
callback_data=filtersbot.PageCallback(page=next_page, action='otdel').pack()
)
)
markup.row(*pagination_buttons)
# Кнопка "В главное меню"
markup.row(
types.InlineKeyboardButton(
text='🔙 В главное меню',
callback_data='main_menu'
)
)
return markup.as_markup()
async def questions_markup(otdel_id: int, current_page: int = 0):
locations = await databasework.get_all_questions_where_otdel_id(otdel_id)
markup = InlineKeyboardBuilder()
page_width = 20 # настройка пагинации
page_offset = current_page * page_width
last_page = max(0, math.ceil(len(locations) / page_width) - 1)
prev_page = max(0, current_page - 1)
next_page = min(last_page, current_page + 1)
# Создаем список для хранения текста кнопок с учетом пагинации
texts = []
# Номер первого вопроса на текущей странице
start_index = page_offset + 1
# Добавляем кнопки с вопросами
for index, i in enumerate(locations[page_offset: page_offset + page_width], start=start_index):
name = i[1]
text = f"{index}. {name}" # Форматируем текст с номером вопроса
texts.append(text) # Сохраняем текст кнопки
markup.add(
types.InlineKeyboardButton(
text=text,
callback_data=filtersbot.QuestionsMarkup(action='open_ques', id_question=i[0], id_otdel=otdel_id).pack()
)
)
# Выбираем количество кнопок в строке (2 или 3)
markup.adjust(2, 3)
# Создаем кнопки для пагинации
pagination_buttons = []
if prev_page != current_page: # кнопка "Назад"
pagination_buttons.append(
types.InlineKeyboardButton(
text="◀️",
callback_data=filtersbot.PageCallbackQuestions(otdel_id=otdel_id, page=prev_page, action='question').pack()
)
)
# Кнопка для текущей страницы
pagination_buttons.append(
types.InlineKeyboardButton(
text=f"{current_page + 1}/{last_page + 1}",
callback_data='no_action' # Эта кнопка не будет выполнять действие
)
)
if last_page != current_page: # кнопка "Вперед"
pagination_buttons.append(
types.InlineKeyboardButton(
text="▶️",
callback_data=filtersbot.PageCallbackQuestions(otdel_id=otdel_id, page=next_page, action='question').pack()
)
)
if last_page == current_page:
markup.row(
types.InlineKeyboardButton(
text="Спроси HR",
url="https://docs.google.com/forms/d/1jyqXjKX_MrcboWJiUOJsUIzuq_gwb_iEzwQKWqUoUPQ/edit"
)
)
markup.row(*pagination_buttons)
# Кнопка "Назад"
markup.row(
types.InlineKeyboardButton(
text='🔙 Назад',
callback_data='back_to_otdels'
)
)
return (texts, markup.as_markup())
def back_to_questions(id_otdel: int): # старт кнопки
markup = (
InlineKeyboardBuilder()
.button(text='🔙 Назад', callback_data=filtersbot.BackToQuestions(id_otdel=id_otdel, action='back').pack())
.adjust(1, repeat=True)
.as_markup()
)
return markup

View File

@ -0,0 +1,28 @@
from config import *
from .check_ban import BannedMiddleware
from .create_user import CreateUserMiddleware
from .throttling import ThrottlingMiddleware
from aiogram.utils.callback_answer import CallbackAnswerMiddleware
def setup(dp: Dispatcher):
throttling_middleware = ThrottlingMiddleware()
dp.message.outer_middleware.register(throttling_middleware)
dp.callback_query.outer_middleware.register(throttling_middleware)
create_user_middleware = CreateUserMiddleware()
dp.message.outer_middleware.register(create_user_middleware)
dp.callback_query.outer_middleware.register(create_user_middleware)
banned_middleware = BannedMiddleware()
dp.message.outer_middleware.register(banned_middleware)
dp.callback_query.outer_middleware.register(banned_middleware)
dp.callback_query.middleware(CallbackAnswerMiddleware())

View File

@ -0,0 +1,25 @@
from aiogram.types import Message, CallbackQuery
from aiogram import Bot
from aiogram.dispatcher.middlewares.base import BaseMiddleware
from typing import Any, Awaitable, Callable, Dict
from sql_function import databasework
from db.models import UserData
class BannedMiddleware(BaseMiddleware): # ---- > мидлвар чек на бан
async def __call__(
self,
handler: Callable[[CallbackQuery, Dict[str, Any]], Awaitable[Any]],
event: Message,
data: Dict[str, Any],
) -> Any:
data["bot_user"]: UserData = await databasework.check_user_o(event.from_user.id)
#check_ban = await databasework.check_ban(event.from_user.id) # проверяем на бан
if data["bot_user"].ban == 'yes': # проверка бана
bot: Bot = data["bot"]
return await bot.send_message(event.from_user.id, (f'Banned!'))
return await handler(event, data)

View File

@ -0,0 +1,30 @@
from aiogram.types import Message, CallbackQuery
from config import settings
from aiogram import Bot
from aiogram.dispatcher.middlewares.base import BaseMiddleware
from typing import Any, Awaitable, Callable, Dict
from sql_function import databasework
class CreateUserMiddleware(BaseMiddleware): # ---- > мидлвар создание юзера
async def __call__(
self,
handler: Callable[[CallbackQuery, Dict[str, Any]], Awaitable[Any]],
event: Message,
data: Dict[str, Any],
) -> Any:
check_user = await databasework.check_user(event.from_user.id) # ---> достаем юзера
if check_user == None: # если юзера нет -
await databasework.create_user(event.from_user.id, event.from_user.username) # - создаем
elif check_user[2] != event.from_user.username: # обновляем юзернейм если изменился
await databasework.update_username_user(event.from_user.username, event.from_user.id)
return await handler(event, data)

View File

@ -0,0 +1,22 @@
from aiogram.types import Message, CallbackQuery
from aiogram import Bot
from aiogram.dispatcher.middlewares.base import BaseMiddleware
from typing import Any, Awaitable, Callable, Dict
class CallbackMiddleware(BaseMiddleware): # ---- > мидлвар кнопок
async def __call__(
self,
handler: Callable[[CallbackQuery, Dict[str, Any]], Awaitable[Any]],
event: Message,
data: Dict[str, Any],
) -> Any:
bot: Bot = data["bot"]
await bot.answer_callback_query(event.from_user.id)
return await handler(event, data)

View File

@ -0,0 +1,30 @@
from cachetools import TTLCache
from typing import Any, Awaitable, Callable, Dict
from aiogram import BaseMiddleware
from aiogram.types import Message, User
class ThrottlingMiddleware(BaseMiddleware):
throt = TTLCache(maxsize=10_000, ttl=1)
async def __call__(
self,
handler: Callable[[Message, Dict[str, Any]], Awaitable[Any]],
event: Message,
data: Dict[str, Any],
) -> Any:
user: User = data["event_from_user"]
if user.id in self.throt:
count = self.throt[user.id] + 1
self.throt[user.id] = count
if count == 4:
return await data['bot'].send_message(user.id, ('Не спамьте!'))
elif count > 3:
return
else:
self.throt[user.id] = 0
return await handler(event, data)

122
bot/sql_function.py Normal file
View File

@ -0,0 +1,122 @@
from config import types
from db.db import async_session, engine
import sys, asyncio
from sqlalchemy import text
from sqlalchemy.future import select
from db.models import UserTg, UserData
class databasework:
async def create_user(user_id: str, username: str):
async with async_session() as session:
async with session.begin():
sql = """
INSERT INTO users (user_id, username)
VALUES (:user_id, :username)
"""
await session.execute(text(sql), {"user_id": str(user_id), "username": username})
await session.commit() # Подтверждение транзакции
async def update_username_user(username: str, user_id: str):
async with async_session() as session:
async with session.begin():
sql = "UPDATE users SET username = :username WHERE user_id = :user_id"
await session.execute(text(sql), {"username": username, "user_id": str(user_id)})
await session.commit()
async def check_user(user_id: str):
async with async_session() as session:
async with session.begin():
sql = "SELECT * FROM users WHERE user_id = :user_id"
result = await session.execute(text(sql), {"user_id": str(user_id)})
return result.one_or_none() # Возвращает один результат или None
async def check_user_o(user_id: str):
async with async_session() as session:
async with session.begin():
stmt = select(UserTg).where(UserTg.user_id == str(user_id)) # Используем ORM-запрос
result = await session.execute(stmt) # Выполнение запроса
user = result.scalar_one_or_none() # Получение первого ORM-объекта
user_data = UserData(
id=user.id,
user_id=user.user_id,
username=user.username,
status=user.status,
has_access=user.has_access,
reg_date=user.reg_date,
ban=user.ban,
)
return user_data # Возвращает экземпляр `dataclass`
async def check_ban(user_id: str):
async with async_session() as session:
async with session.begin():
sql = "SELECT * FROM users WHERE user_id = :user_id"
result = await session.execute(text(sql), {"user_id": str(user_id)})
ban_status = result.one_or_none() # Получение значения
return ban_status and ban_status[7] == 'yes'
async def check_admin(user_id: str):
async with async_session() as session:
async with session.begin():
sql = "SELECT * FROM users WHERE user_id = :user_id"
result = await session.execute(text(sql), {"user_id": str(user_id)})
status = result.one_or_none() # Получение статуса пользователя
if status:
return status[4] == True
return
async def get_all_users():
async with async_session() as session:
async with session.begin():
sql = "SELECT * FROM users ORDER BY id ASC;"
result = await session.execute(text(sql)) # Выполнение запроса
return result.all() # Возвращает все строки
async def get_all_otdels():
async with async_session() as session:
async with session.begin():
sql = "SELECT * FROM otdels ORDER BY id ASC;"
result = await session.execute(text(sql)) # Выполнение запроса
return result.all() # Возвращает все строки
async def get_all_questions_where_otdel_id(otdel_id: int):
async with async_session() as session:
async with session.begin():
sql = """
SELECT q.id AS question_id, q.name AS question_name, q.answer, q.type_answer, q.file, o.name AS otdel_name
FROM questions q
JOIN otdels o ON q.otdel_id = o.id
WHERE o.id = :otdel_id
ORDER BY q.id ASC;
"""
result = await session.execute(text(sql), {"otdel_id": otdel_id}) # Выполнение запроса
return result.all() # Возвращает все строки
async def get_question(question_id: int):
async with async_session() as session:
async with session.begin():
sql = "SELECT * FROM questions WHERE id = :id ORDER BY id ASC;"
result = await session.execute(text(sql), {"id": question_id}) # Выполнение запроса
return result.one_or_none() # Возвращает все строки

View File

@ -0,0 +1,4 @@
from config import StatesGroup, State

6
bot/states/states.py Normal file
View File

@ -0,0 +1,6 @@
from config import State, StatesGroup
class RequestState(StatesGroup):
one = State()

43
docker-compose.yml Normal file
View File

@ -0,0 +1,43 @@
services:
postgres:
image: postgres:latest
restart: always
environment:
POSTGRES_USER: ${DB_LOG}
POSTGRES_PASSWORD: ${DB_PASS}
POSTGRES_DB: ${DB_NAME}
volumes:
- postgres-data:/var/lib/postgresql/data
- ./files/postgresql.conf:/etc/postgresql/postgresql.conf
- ./files/pg_hba.conf:/etc/postgresql/pg_hba.conf
ports:
- "5431:5432"
bot:
build:
context: .
dockerfile: Dockerfile
restart: always
command: ["python", "-u", "bot"]
depends_on:
- postgres
volumes:
- .:/app
- ./logs:/app/logs
site:
build:
context: .
dockerfile: Dockerfile
restart: always
command: ["python", "-u", "webadmin"]
depends_on:
- postgres
ports:
- "8004:8004" # Проброс порта 8080 на хосте
volumes:
- .:/app
- ./logs:/app/logs
volumes:
postgres-data:

1
files/pg_hba.conf Normal file
View File

@ -0,0 +1 @@
host all all 0.0.0.0/0 scram-sha-256

2
files/postgresql.conf Normal file
View File

@ -0,0 +1,2 @@
listen_addresses = '*'
include 'pg_hba.conf'

23
nginx/nginx.conf Normal file
View File

@ -0,0 +1,23 @@
# nginx.conf
worker_processes 1;
events {
worker_connections 1024;
}
http {
server {
listen 80;
server_name fckngdev.space;
location / {
proxy_pass http://87.251.87.90:8004/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
}
}
}

2025
poetry.lock generated Normal file

File diff suppressed because it is too large Load Diff

28
pyproject.toml Normal file
View File

@ -0,0 +1,28 @@
[tool.poetry]
name = "tgbot"
version = "0.1.0"
description = ""
authors = ["Ayrelia <nikitakoteqka12@gmail.com>"]
readme = "README.md"
[tool.poetry.dependencies]
python = "^3.10"
aiogram = "^3.10.0"
aiohttp = "^3.9.5"
psycopg2-binary = "^2.9.9"
asyncpg = "^0.29.0"
sqlalchemy = "^2.0.31"
python-dotenv = "^1.0.1"
pydantic-settings = "^2.3.4"
asyncio = "^3.4.3"
apscheduler = "^3.10.4"
cachetools = "^5.3.3"
fastapi = "^0.111.1"
bcrypt = "4.0.1"
passlib = "^1.7.4"
cryptography = "^43.0.0"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

BIN
requirements.txt Normal file

Binary file not shown.

81
webadmin/__main__.py Normal file
View File

@ -0,0 +1,81 @@
import logging
from logging.handlers import RotatingFileHandler
import os
from fastapi import FastAPI, HTTPException, Request, Cookie, Form, Depends
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from exc import NotAuthenticatedException, NotAccessException
from routers import routers
from config import app
from db.models import create_tables
from db.db import engine
from starlette.middleware.cors import CORSMiddleware
# Настройка CORS
orig_origins = [
"http://localhost",
"http://localhost:8000",
"http://localhost:8004",
]
app.add_middleware(
CORSMiddleware,
allow_origins=orig_origins, # список разрешенных доменов
allow_credentials=True,
allow_methods=["POST", "PUT", "GET", "DELETE"], # Разрешить все методы HTTP (GET, POST, PUT, DELETE и т.д.)
allow_headers=["*"], # Разрешить все заголовки
)
@app.exception_handler(NotAuthenticatedException)
async def not_authenticated_exception_handler(request: Request, exc: NotAuthenticatedException):
return RedirectResponse(url="/login", status_code=303)
@app.exception_handler(NotAccessException)
async def not_authenticated_exception_handler(request: Request, exc: NotAuthenticatedException):
return RedirectResponse(url="/", status_code=303)
for router in routers:
app.include_router(router)
if __name__ == "__main__":
# log_dir = '/app/logs'
# os.makedirs(log_dir, exist_ok=True) # Создать директорию, если не существует
# log_file = os.path.join(log_dir, 'app_webadmin.log')
# # Создать форматтер
# formatter = logging.Formatter('%(asctime)s - [%(levelname)s] - %(name)s - '
# '(%(filename)s).%(funcName)s(%(lineno)d) - %(message)s')
# logging.getLogger("requests").setLevel(logging.WARNING)
# logging.getLogger("apscheduler").setLevel(logging.WARNING)
# # Настроить основной логгер
# logger = logging.getLogger()
# logger.setLevel(logging.INFO)
# # Создать обработчик для ротации логов
# file_handler = RotatingFileHandler(log_file, maxBytes=10*1024*1024, backupCount=5)
# file_handler.setLevel(logging.INFO)
# file_handler.setFormatter(formatter)
# # Создать консольный обработчик
# console_handler = logging.StreamHandler()
# console_handler.setLevel(logging.INFO)
# console_handler.setFormatter(formatter)
# # Добавить обработчики к логгеру
# logger.addHandler(file_handler)
# logger.addHandler(console_handler)
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8004)

48
webadmin/config.py Normal file
View File

@ -0,0 +1,48 @@
import os
import sys
from dotenv import load_dotenv
from pydantic_settings import BaseSettings
from fastapi import FastAPI
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from contextlib import asynccontextmanager
from db.db import engine
from db.models import create_tables
import asyncio
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../')))
load_dotenv()
current_directory = os.path.abspath(os.path.dirname(__file__))
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup: создание таблиц
asyncio.create_task(create_tables())
yield # Контроль передается приложению
# Shutdown: закрытие соединений и очистка
await engine.dispose()
app = FastAPI(lifespan=lifespan)
app.mount("/static", StaticFiles(directory=current_directory + "/static"), name="static")
templates = Jinja2Templates(directory=current_directory + "/templates")
class Settings(BaseSettings): # создаем settings class
SESSION_EXPIRATION_TIME: int = int(os.getenv("SESSION_EXPIRATION_TIME", 3600))
settings = Settings()

206
webadmin/db/crud.py Normal file
View File

@ -0,0 +1,206 @@
import os
import sys
from typing import Optional
from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import joinedload
from datetime import datetime, timedelta
from db.models import User, Session
from exc import UpdateQuestionException
from schemas import UserCreate, UserInDB
import secrets
from sqlalchemy import delete
from passlib.context import CryptContext
from models import CreateUpdateOtdel
import config
from bot.db.models import Otdels, Questions
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password, hashed_password):
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password):
return pwd_context.hash(password)
async def create_user(db_session: AsyncSession, user: UserCreate):
hashed_password = get_password_hash(user.password)
new_user = User(username=user.username, password=hashed_password, full_name=user.full_name)
db_session.add(new_user)
await db_session.commit()
return new_user
async def get_user_by_username(db_session: AsyncSession, username: str) -> UserInDB:
result = await db_session.execute(select(User).filter_by(username=username))
user = result.scalars().first()
return user
async def create_session(db_session: AsyncSession, user_id: int, expiration_time: int) -> Session:
session_token = secrets.token_urlsafe(16)
expires_at = datetime.utcnow() + timedelta(seconds=expiration_time)
session = Session(user_id=user_id, session_token=session_token, expires_at=expires_at)
db_session.add(session)
await db_session.commit()
await db_session.refresh(session)
return session
async def get_session_by_token(db_session: AsyncSession, session_token: str) -> Session:
result = await db_session.execute(
select(Session).options(joinedload(Session.user)).filter_by(session_token=session_token)
)
session = result.scalars().first()
if session and session.expires_at < datetime.utcnow():
await db_session.delete(session)
await db_session.commit()
return None
return session
async def delete_session(db_session: AsyncSession, session_token: str) -> None:
result = await db_session.execute(select(Session).filter_by(session_token=session_token))
session_to_delete = result.scalars().first()
if session_to_delete:
await db_session.execute(delete(Session).where(Session.id == session_to_delete.id))
await db_session.commit()
async def create_otdel(db_session: AsyncSession, otdel: CreateUpdateOtdel) -> Otdels:
new_otdel = Otdels(name=otdel.name)
db_session.add(new_otdel)
await db_session.commit()
return new_otdel
async def delete_otdel(db_session: AsyncSession, otdel_id: int):
async with db_session.begin(): # Начало транзакции
try:
query = select(Otdels).where(Otdels.id == otdel_id)
result = await db_session.execute(query)
otdel = result.scalar_one_or_none()
if not otdel:
return JSONResponse(
content={"status": "error", "message": "Otdel not found"},
status_code=404
)
await db_session.delete(otdel) # Удаление объекта
# Не требуется явного commit, так как begin автоматически обрабатывает commit или rollback
except Exception as e:
await db_session.rollback()
return JSONResponse(
content={"status": "error", "message": f"Database error: {str(e)}"},
status_code=500
)
return JSONResponse(
content={"status": "success", "otdel_id": otdel_id},
status_code=200
)
async def update_otdel(db_session: AsyncSession, otdel_id: int, otdel: CreateUpdateOtdel) -> None:
# Создаем запрос для получения объекта отдела по ID
query = select(Otdels).filter(Otdels.id == otdel_id)
try:
# Выполняем запрос для поиска отдела
result = await db_session.execute(query)
existing_otdel = result.scalars().one()
# Обновляем атрибуты отдела
existing_otdel.name = otdel.name
# Фиксируем изменения
await db_session.commit()
return JSONResponse(
content={"status": "success", "otdel_id": otdel_id},
status_code=200
)
except Exception as ex:
# В случае других ошибок откатываем изменения
await db_session.rollback()
return JSONResponse(
content={"status": "error", "message": f"Database error: {str(ex)}"},
status_code=500
)
async def create_question(
db_session: AsyncSession,
title: str,
text: Optional[str],
file_data: Optional[str],
otdel_id: int,
):
new_question = Questions(
name=title,
answer=text,
file=file_data,
otdel_id=otdel_id,
)
db_session.add(new_question)
await db_session.commit()
return new_question
async def update_question(
db_session: AsyncSession,
question_id: int,
title: Optional[str] = None,
text: Optional[str] = None,
file_data: Optional[str] = None,
):
try:
# Получаем существующий вопрос по его ID
result = await db_session.execute(select(Questions).where(Questions.id == question_id))
question = result.scalar_one()
# Обновляем поля, даже если они None
question.name = title
question.answer = text
question.file = file_data
# Сохраняем изменения в базе данных
await db_session.commit()
return question
except Exception as ex:
# Если вопрос с данным ID не найден, можно вернуть None или выбросить исключение
raise UpdateQuestionException(f'ERROR - {ex}')
async def delete_question(db_session: AsyncSession, question_id: int):
async with db_session.begin(): # Начало транзакции
try:
query = select(Questions).where(Questions.id == question_id)
result = await db_session.execute(query)
question = result.scalar_one_or_none()
if not question:
return JSONResponse(
content={"status": "error", "message": "Question not found"},
status_code=404
)
await db_session.delete(question) # Удаление объекта
# Не требуется явного commit, так как begin автоматически обрабатывает commit или rollback
except Exception as e:
await db_session.rollback()
return JSONResponse(
content={"status": "error", "message": f"Database error: {str(e)}"},
status_code=500
)
return JSONResponse(
content={"status": "success", "question_id": question_id},
status_code=200
)

25
webadmin/db/db.py Normal file
View File

@ -0,0 +1,25 @@
# database.py
from sqlalchemy import create_engine, MetaData
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.ext.declarative import declarative_base
import os
from dotenv import load_dotenv
load_dotenv()
#DATABASE_URL = f"postgresql+asyncpg://{os.environ.get('DB_LOG')}:{os.environ.get('DB_PASS')}@{os.environ.get('DB_HOST')}:{os.environ.get('DB_PORT')}/{os.environ.get('DB_NAME')}"
DATABASE_URL = (
f"postgresql+asyncpg://{os.environ.get('DB_LOG')}:"
f"{os.environ.get('DB_PASS')}@{os.environ.get('DB_HOST')}:"
f"{os.environ.get('DB_PORT')}/{os.environ.get('DB_NAME')}"
)
#DATABASE_URL = "sqlite+aiosqlite:///./test.db"
engine = create_async_engine(DATABASE_URL)
async_session = async_sessionmaker(engine)

38
webadmin/db/models.py Normal file
View File

@ -0,0 +1,38 @@
from sqlalchemy import Boolean, Column, Integer, String, ForeignKey, DateTime
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship, DeclarativeBase
from db.db import engine
from sqlalchemy.ext.asyncio import AsyncAttrs
class Base(AsyncAttrs, DeclarativeBase):
pass
class User(Base):
__tablename__ = 'users_site'
id = Column(Integer, primary_key=True, index=True)
username = Column(String, unique=True, index=True)
password = Column(String)
full_name = Column(String)
has_access = Column(Boolean, server_default='False', nullable=False)
sessions = relationship("Session", back_populates="user")
class Session(Base):
__tablename__ = 'sessions'
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey('users_site.id'))
session_token = Column(String, unique=True, index=True)
expires_at = Column(DateTime)
user = relationship("User", back_populates="sessions")
async def create_tables():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)

15
webadmin/exc.py Normal file
View File

@ -0,0 +1,15 @@
from fastapi import HTTPException
class NotAuthenticatedException(HTTPException):
pass
class NotAccessException(HTTPException):
pass
class NewsletterException(HTTPException):
pass
class UpdateQuestionException(HTTPException):
pass

81
webadmin/function.py Normal file
View File

@ -0,0 +1,81 @@
from fastapi import FastAPI, HTTPException, Request, Cookie
from cryptography.fernet import Fernet
from sqlalchemy import select
from db.db import async_session
from db import crud
from db.models import User
from exc import NotAuthenticatedException, NewsletterException, NotAccessException
import logging
import os
import sys
from sqlalchemy.ext.asyncio import AsyncSession
import asyncio
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../')))
from bot.db.models import UserTg
from bot.config import bot
key = os.getenv('SECRET_KEY_AUTH')
cipher_suite = Fernet(key)
def encrypt_token(token: str) -> str:
return cipher_suite.encrypt(token.encode()).decode()
def decrypt_token(encrypted_token: str) -> str:
return cipher_suite.decrypt(encrypted_token.encode()).decode()
async def get_current_user(request: Request, session_token: str = Cookie(None)):
if not session_token:
return None
try:
decrypted_token = decrypt_token(session_token)
except Exception as ex:
logging.error(ex)
return None
async with async_session() as db_session:
session = await crud.get_session_by_token(db_session, decrypted_token)
if not session:
return None
user = await db_session.get(User, session.user_id)
return user
async def get_authenticated_user(request: Request, session_token: str = Cookie(None)) -> User:
user = await get_current_user(request, session_token)
if user is None:
raise NotAuthenticatedException(status_code=401, detail="Not authenticated")
return user
async def get_user_with_access(request: Request, session_token: str = Cookie(None)) -> User:
user = await get_current_user(request, session_token)
if user is None:
raise NotAuthenticatedException(status_code=401, detail="Not authenticated")
if user.has_access == False:
raise NotAccessException(status_code=401, detail="Нет доступа")
return user
async def send_newsletter(message: str, db_session: AsyncSession) -> dict:
try:
# Создание запроса для получения всех пользователей
query = select(UserTg).where(UserTg.has_access == True) # Запрос на выбор всех записей из UserTg
# Выполнение запроса и получение всех пользователей
result = await db_session.execute(query)
users = result.scalars().all() # Получаем все записи
for user in users:
try:
await bot.send_message(chat_id=user.user_id, text=message)
await asyncio.sleep(2.5)
except Exception as ex:
pass
except Exception as ex:
logging.error(ex)
raise NewsletterException(status_code=502, detail=f"Ошибка, рассылка не была начата - {ex}")

28
webadmin/models.py Normal file
View File

@ -0,0 +1,28 @@
from pydantic import BaseModel, field_validator
import re
class NewsletterRequest(BaseModel):
message: str
class CreateUpdateOtdel(BaseModel):
name: str
class UserLogin(BaseModel):
username: str
password: str
@field_validator('username')
def username_validation(cls, v):
if not re.match(r"^[a-zA-Z0-9_-]{4,}$", v):
raise ValueError('Username must be at least 4 characters long and contain only English letters, digits, hyphens, or underscores')
return v
@field_validator('password')
def password_validation(cls, v):
if len(v) < 6:
raise ValueError('Password must be at least 6 characters long')
return v

View File

@ -0,0 +1,5 @@
from .auth_router import auth_router
from .user_router import user_router
from .otdels_router import otdels_router
routers = (auth_router, user_router, otdels_router)

View File

@ -0,0 +1,68 @@
from fastapi import APIRouter, Request, Depends, Form, HTTPException
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.ext.asyncio import AsyncSession
from db import crud
from db.db import async_session
from schemas import UserCreate
from config import settings
from function import encrypt_token, get_current_user
from config import templates
from db.models import User
from schemas import UserLogin
from pydantic import ValidationError
auth_router = APIRouter()
@auth_router.get("/login", response_class=HTMLResponse)
async def get_login(request: Request, user: User = Depends(get_current_user)):
if user:
return RedirectResponse(url="/profile", status_code=303)
return templates.TemplateResponse("login.html", {"request": request})
@auth_router.post("/login", response_class=HTMLResponse)
async def login(request: Request, username: str = Form(...), password: str = Form(...)):
async with async_session() as db_session:
user = await crud.get_user_by_username(db_session, username)
if user is None or not crud.verify_password(password, user.password):
return templates.TemplateResponse("login.html", {"request": request, "error": "Неверный логин или пароль"})
session = await crud.create_session(db_session, user.id, settings.SESSION_EXPIRATION_TIME)
response = RedirectResponse(url="/profile", status_code=303)
encrypted_token = encrypt_token(session.session_token)
response.set_cookie(
key="session_token",
value=encrypted_token,
httponly=True,
secure=False,
samesite="Lax",
max_age=settings.SESSION_EXPIRATION_TIME
)
return response
@auth_router.get("/register", response_class=HTMLResponse)
async def get_register(request: Request, user: User = Depends(get_current_user)):
if user:
return RedirectResponse(url="/profile", status_code=303)
return templates.TemplateResponse("register.html", {"request": request})
@auth_router.post("/register", response_class=HTMLResponse)
async def register(request: Request, username: str = Form(...), password: str = Form(...), full_name: str = Form(...)):
if not username or not password or not full_name:
return templates.TemplateResponse("register.html", {"request": request, "error": "Заполните все поля!"})
try:
user = UserCreate(username=username, password=password, full_name=full_name)
except ValidationError as e:
error_text = e.errors()[0]['msg'].split('Value error, ')[1]
return templates.TemplateResponse("register.html", {"request": request, "error": error_text})
async with async_session() as db_session:
existing_user = await crud.get_user_by_username(db_session, username)
if existing_user:
return templates.TemplateResponse("register.html", {"request": request, "error": "Такой логин уже есть!"})
await crud.create_user(db_session, user)
return RedirectResponse(url="/login", status_code=303)

View File

@ -0,0 +1,298 @@
import base64
import math
from typing import Optional
import uuid
from fastapi import APIRouter, Body, File, Form, Query, Request, Depends, Cookie, HTTPException, UploadFile
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from sqlalchemy import func
from sqlalchemy.ext.asyncio import AsyncSession
from datetime import datetime
from db import crud
from db.db import async_session
from function import decrypt_token, get_authenticated_user, get_user_with_access
from config import templates
from db.models import User
from sqlalchemy.future import select
import os
from models import CreateUpdateOtdel
import logging
from pathlib import Path
from bot.db.models import Otdels, Questions
from config import current_directory
UPLOAD_DIRECTORY = current_directory + "/uploads"
otdels_router = APIRouter()
def generate_unique_filename(original_filename: str) -> str:
"""Генерирует уникальное имя файла, если файл с таким именем уже существует."""
file_extension = Path(original_filename).suffix
base_filename = Path(original_filename).stem
while True:
unique_filename = f"{base_filename}_{uuid.uuid4().hex}{file_extension}"
file_path = os.path.join(UPLOAD_DIRECTORY, unique_filename)
if not os.path.exists(file_path):
return unique_filename
@otdels_router.get("/otdels", response_class=HTMLResponse)
async def users(
request: Request,
user: User = Depends(get_user_with_access),
page: int = Query(1, alias='page', ge=1),
):
ITEMS_PER_PAGE = 9
PAGINATION_BUTTONS = 10
async with async_session() as db_session:
# Подсчитываем общее количество пользователей
total_users = await db_session.execute(select(func.count(Otdels.id)))
total_users = total_users.scalar_one()
total_pages = (total_users + ITEMS_PER_PAGE - 1) // ITEMS_PER_PAGE
# Вычисляем смещение и лимит
offset = (page - 1) * ITEMS_PER_PAGE
# Создаем запрос с сортировкой по id
stmt = select(Otdels).order_by(Otdels.id).offset(offset).limit(ITEMS_PER_PAGE)
result = await db_session.execute(stmt)
otdels = result.scalars().all()
# Определяем страницы для отображения пагинации
start_page = max(1, page - PAGINATION_BUTTONS // 2)
end_page = min(total_pages, start_page + PAGINATION_BUTTONS - 1)
start_page = max(1, end_page - PAGINATION_BUTTONS + 1)
return templates.TemplateResponse(
"otdels.html",
{
"request": request,
"user": user,
"otdels": otdels,
"current_page": page,
"total_pages": total_pages,
"start_page": start_page,
"end_page": end_page
}
)
@otdels_router.post("/create-otdel", response_class=JSONResponse)
async def create_otdel(
request: CreateUpdateOtdel = Body(),
user: User = Depends(get_user_with_access),
):
try:
async with async_session() as db_session:
new_otdel = await crud.create_otdel(db_session, request)
await db_session.refresh(new_otdel) # Обновить объект перед возвратом
except Exception as ex:
await db_session.rollback()
logging.error(ex)
return JSONResponse(content={"status": "error", "message": f"error - {ex}"}, status_code=501)
return JSONResponse(content={"status": "success", "otdel": {"id": new_otdel.id, "name": new_otdel.name}}, status_code=200)
@otdels_router.delete("/delete-otdel/{otdel_id}", response_class=JSONResponse)
async def delete_otdel_endpoint(
request: Request,
otdel_id: int,
user: User = Depends(get_user_with_access)
):
async with async_session() as db_session:
result = await crud.delete_otdel(db_session, otdel_id)
return result
@otdels_router.put("/edit-otdel/{otdel_id}", response_class=JSONResponse)
async def edit_otdel_endpoint(
request: CreateUpdateOtdel,
otdel_id: int,
user: User = Depends(get_user_with_access)
):
async with async_session() as db_session:
otdel = CreateUpdateOtdel(name=request.name)
result = await crud.update_otdel(db_session, otdel_id, otdel)
return result
@otdels_router.get("/questions", response_class=HTMLResponse)
async def questions_page(
request: Request,
user: User = Depends(get_user_with_access),
page: int = Query(1, alias='page', ge=1),
otdel_id: Optional[int] = Query(None, alias='otdel', ge=1),
):
ITEMS_PER_PAGE = 9
async with async_session() as db_session:
# Получаем список отделов
stmt_otdels = select(Otdels).order_by(Otdels.id)
result_otdels = await db_session.execute(stmt_otdels)
otdels = result_otdels.scalars().all()
# Получаем количество вопросов и сами вопросы для выбранного отдела
if otdel_id:
total_questions_stmt = select(func.count(Questions.id)).where(Questions.otdel_id == otdel_id)
total_questions_result = await db_session.execute(total_questions_stmt)
total_questions = total_questions_result.scalar_one()
total_pages = (total_questions + ITEMS_PER_PAGE - 1) // ITEMS_PER_PAGE
offset = (page - 1) * ITEMS_PER_PAGE
stmt_questions = (
select(Questions)
.where(Questions.otdel_id == otdel_id)
.order_by(Questions.id)
.offset(offset)
.limit(ITEMS_PER_PAGE)
)
result_questions = await db_session.execute(stmt_questions)
questions = result_questions.scalars().all()
else:
questions = []
total_questions = 0
total_pages = 0
# Определяем страницы для отображения пагинации
start_page = max(1, page - 5)
end_page = min(total_pages, start_page + 10 - 1)
start_page = max(1, end_page - 10 + 1)
return templates.TemplateResponse(
"questions.html",
{
"request": request,
"user": user,
"otdels": otdels,
"questions": questions,
"current_page": page,
"total_pages": total_pages,
"start_page": start_page,
"end_page": end_page,
"selected_otdel_id": otdel_id
}
)
@otdels_router.post("/create-question", response_class=JSONResponse)
async def create_question(
title: str = Form(...),
text: Optional[str] = Form(None),
file: Optional[UploadFile] = File(None),
otdel_id: int = Form(...),
user: User = Depends(get_user_with_access),
):
try:
# Пример сохранения файла (если файл передан)
file_name = None
if file:
print(file.filename)
print(current_directory)
# Генерация уникального имени файла
file_name = generate_unique_filename(file.filename)
file_path = os.path.join(UPLOAD_DIRECTORY, file_name)
# Сохранение файла на сервере
with open(file_path, "wb") as buffer:
buffer.write(await file.read())
async with async_session() as db_session:
# Вызов CRUD функции для создания вопроса
new_question = await crud.create_question(
db_session=db_session,
title=title,
text=text,
file_data=file_name,
otdel_id=otdel_id
)
await db_session.refresh(new_question) # Обновить объект перед возвратом
except Exception as ex:
logging.error(ex)
return JSONResponse(content={"status": "error", "message": f"error - {ex}"}, status_code=501)
return JSONResponse(content={"status": "success", "question": {"id": new_question.id, "title": new_question.name}}, status_code=200)
@otdels_router.put("/edit-question", response_class=JSONResponse)
async def update_questions(
title: str = Form(...),
text: Optional[str] = Form(None),
file: Optional[UploadFile] = File(None),
id: int = Form(...),
user: User = Depends(get_user_with_access),
):
try:
# Пример сохранения файла (если файл передан)
file_name = None
if file:
print(file.filename)
print(current_directory)
# Генерация уникального имени файла
file_name = generate_unique_filename(file.filename)
file_path = os.path.join(UPLOAD_DIRECTORY, file_name)
# Сохранение файла на сервере
with open(file_path, "wb") as buffer:
buffer.write(await file.read())
async with async_session() as db_session:
# Вызов CRUD функции для создания вопроса
new_question = await crud.update_question(
db_session=db_session,
question_id=id,
title=title,
text=text,
file_data=file_name,
)
await db_session.refresh(new_question) # Обновить объект перед возвратом
except Exception as ex:
logging.error(ex)
return JSONResponse(content={"status": "error", "message": f"error - {ex}"}, status_code=501)
return JSONResponse(content={"status": "success", "question": {"id": new_question.id, "title": new_question.name}}, status_code=200)
@otdels_router.delete("/delete-question/{question_id}", response_class=JSONResponse)
async def delete_otdel_endpoint(
request: Request,
question_id: int,
user: User = Depends(get_user_with_access)
):
async with async_session() as db_session:
result = await crud.delete_question(db_session, question_id)
return result

View File

@ -0,0 +1,136 @@
import math
from fastapi import APIRouter, Body, Form, Query, Request, Depends, Cookie, HTTPException
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from sqlalchemy import func
from sqlalchemy.ext.asyncio import AsyncSession
from datetime import datetime
from db import crud
from db.db import async_session
from function import decrypt_token, get_authenticated_user, send_newsletter, get_user_with_access
from config import templates
from db.models import User
from sqlalchemy.future import select
import asyncio
import logging
from models import NewsletterRequest
from bot.db.models import UserTg, Otdels, Questions
user_router = APIRouter()
@user_router.get("/", response_class=HTMLResponse)
async def read_root(request: Request, user: User = Depends(get_authenticated_user)):
return templates.TemplateResponse("index.html", {"request": request, "user": user})
@user_router.get("/profile", response_class=HTMLResponse)
async def profile(request: Request, user: User = Depends(get_authenticated_user)):
return templates.TemplateResponse("profile.html", {"request": request, "user": user})
@user_router.get("/users", response_class=HTMLResponse)
async def users(
request: Request,
user: User = Depends(get_user_with_access),
page: int = Query(1, alias='page', ge=1)
):
ITEMS_PER_PAGE = 12
PAGINATION_BUTTONS = 10
async with async_session() as db_session:
# Подсчитываем общее количество пользователей
total_users = await db_session.execute(select(func.count(UserTg.id)))
total_users = total_users.scalar_one()
total_pages = (total_users + ITEMS_PER_PAGE - 1) // ITEMS_PER_PAGE
# Вычисляем смещение и лимит
offset = (page - 1) * ITEMS_PER_PAGE
# Создаем запрос с сортировкой по id
stmt = select(UserTg).order_by(UserTg.id).offset(offset).limit(ITEMS_PER_PAGE)
result = await db_session.execute(stmt)
users = result.scalars().all()
# Определяем страницы для отображения пагинации
start_page = max(1, page - PAGINATION_BUTTONS // 2)
end_page = min(total_pages, start_page + PAGINATION_BUTTONS - 1)
start_page = max(1, end_page - PAGINATION_BUTTONS + 1)
return templates.TemplateResponse(
"users.html",
{
"request": request,
"user": user,
"users": users,
"current_page": page,
"total_pages": total_pages,
"start_page": start_page,
"end_page": end_page
}
)
@user_router.post("/update_status/{user_id}", response_class=HTMLResponse)
async def update_status(
request: Request,
user_id: int,
user: User = Depends(get_user_with_access)
):
logging.info(f"Received request to update status for user_id: {user_id}")
async with async_session() as db_session:
result = await db_session.execute(select(UserTg).filter(UserTg.id == user_id))
user_record = result.scalars().first()
if not user_record:
raise HTTPException(status_code=404, detail="User not found")
user_record.has_access = not user_record.has_access
db_session.add(user_record)
await db_session.commit()
logging.info(f"Successfully updated status for user_id: {user_id}")
return JSONResponse(content={"status": "success"})
@user_router.post("/send-newsletter", response_class=JSONResponse)
async def newsletter(
request: NewsletterRequest, # Используем Pydantic модель
user: User = Depends(get_user_with_access)
):
async with async_session() as db_session:
try:
asyncio.create_task(send_newsletter(str(request.message), db_session))
except Exception as ex:
logging.error(ex)
return JSONResponse(content={"status": f"error", "message": f"error - {ex}"}, status_code=501)
return JSONResponse(content={"status": "success"}, status_code=200)
@user_router.get("/logout", response_class=HTMLResponse)
async def logout(request: Request, session_token: str = Cookie(None)):
if not session_token:
raise HTTPException(status_code=401, detail="Not authenticated")
try:
decrypted_token = decrypt_token(session_token)
except Exception:
raise HTTPException(status_code=401, detail="Invalid session token")
async with async_session() as db_session:
await crud.delete_session(db_session, decrypted_token)
response = RedirectResponse(url="/", status_code=303)
response.delete_cookie("session_token")
return response

33
webadmin/schemas.py Normal file
View File

@ -0,0 +1,33 @@
from pydantic import BaseModel
from pydantic import BaseModel, field_validator
class UserBase(BaseModel):
username: str
full_name: str
class UserCreate(UserBase):
password: str
@field_validator('username')
def username_length(cls, v):
if len(v) < 4:
raise ValueError('Имя пользователя должно содержать не менее 4 символов')
return v
@field_validator('password')
def password_length(cls, v):
if len(v) < 6:
raise ValueError('Пароль должен состоять минимум из 6 символов')
return v
class UserLogin(UserBase):
password: str
class UserInDB(UserBase):
id: int
hashed_password: str
class Config:
from_attributes = True

View File

@ -0,0 +1,34 @@
/* Общие стили для страницы */
.page-wrapper {
position: relative;
}
/* Стили для пагинации */
.pagination {
display: flex;
justify-content: center;
gap: 10px;
margin-top: 20px;
margin-bottom: 20px;
}
.pagination-button {
padding: 8px 16px;
background-color: #333;
color: #e0e0e0;
text-decoration: none;
border-radius: 5px;
transition: background-color 0.3s ease, transform 0.3s ease, box-shadow 0.3s ease;
}
.pagination-button:hover {
background-color: #bb86fc;
transform: scale(1.05);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.5);
}
.pagination-button.active {
background-color: #bb86fc;
pointer-events: none;
}

View File

@ -0,0 +1,136 @@
/* Стили модальных окон */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: hidden;
justify-content: center;
align-items: center;
opacity: 0;
transition: opacity 0.3s ease;
}
.modal.show {
display: flex;
opacity: 1;
}
/* Слой фона для размытия */
.modal::before {
content: "";
position: fixed;
left: 0;
top: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.6); /* Полупрозрачный фон */
backdrop-filter: blur(8px); /* Легкий эффект блюра */
z-index: -1; /* Под модальным окном */
}
.modal-content {
background-color: #1e1e1e; /* Тёмно-серый фон для карточки */
padding: 20px;
border: 1px solid #333;
width: calc(100% - 40px); /* Оставить место для отступов */
max-width: 500px;
border-radius: 15px; /* Закругленные углы */
color: #e0e0e0; /* Светлый текст */
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.5);
transform: scale(0.9);
transition: transform 0.3s ease;
backdrop-filter: blur(10px); /* Легкий эффект блюра */
box-sizing: border-box; /* Учесть padding и border в ширине */
}
.modal.show .modal-content {
transform: scale(1);
}
.close-button {
color: #e0e0e0;
float: right;
font-size: 28px;
font-weight: bold;
cursor: pointer;
}
.close-button:hover,
.close-button:focus {
color: #bb86fc;
text-decoration: none;
}
/* Стили для форм */
form {
display: flex;
flex-direction: column;
}
form label {
margin-bottom: 10px;
color: #e0e0e0;
}
form input[type="text"],
form textarea {
width: 100%;
padding: 10px;
margin-bottom: 20px;
border-radius: 5px;
border: 1px solid #333;
background-color: #2c2c2c;
color: #e0e0e0;
resize: none;
box-sizing: border-box; /* Учесть padding и border в ширине */
}
form input[type="text"]:focus,
form textarea:focus {
outline: none;
border-color: #bb86fc;
background-color: #3a3a3a;
}
/* Стили для кнопок */
.save-button {
background-color: #bb86fc;
color: #fff;
border: none;
padding: 10px 15px;
border-radius: 5px;
cursor: pointer;
transition: background-color 0.3s ease, transform 0.3s ease, box-shadow 0.3s ease;
align-self: flex-end;
}
.save-button:hover {
background-color: #9a67ea;
transform: scale(1.05);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.5);
}
/* Стили кнопок для создания отдела и рассылки */
#create-button,
#newsletter-button {
background-color: #6200ea;
color: white;
border: none;
padding: 10px 15px;
border-radius: 5px;
cursor: pointer;
transition: background-color 0.3s ease, transform 0.3s ease, box-shadow 0.3s ease;
}
#create-button:hover,
#newsletter-button:hover {
background-color: #3700b3;
transform: scale(1.05);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
}

View File

@ -0,0 +1,177 @@
/* Общие стили для страницы */
.page-wrapper {
position: relative;
padding: 10px; /* Добавлено внутреннее пространство для предотвращения наложения содержимого на края */
}
/* Контейнер для заголовка и кнопки создания */
.header-container {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
width: 100%;
}
.header-container h1 {
font-size: 2em; /* Основной размер шрифта для заголовка */
margin: 0;
word-break: break-word; /* Предотвращение переполнения текста заголовка */
}
/* Кнопка создания отдела */
.create-button {
background: linear-gradient(135deg, #bb86fc, #9a67ea);
color: #fff;
border: none;
padding: 12px 20px;
border-radius: 8px;
cursor: pointer;
transition: background 0.3s ease, transform 0.3s ease, box-shadow 0.3s ease;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
font-size: 16px;
font-weight: bold;
position: relative;
overflow: hidden;
text-align: center; /* Центрирование текста */
}
.create-button::before {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 300%;
height: 300%;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
transform: translate(-50%, -50%) scale(0);
transition: transform 0.4s ease;
z-index: 0;
}
.create-button:hover::before {
transform: translate(-50%, -50%) scale(1);
}
.create-button:hover {
background: linear-gradient(135deg, #9a67ea, #bb86fc);
transform: scale(1.05);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.4);
}
.create-button span {
position: relative;
z-index: 1;
}
/* Контейнер для карточек отдела */
.card-container {
display: flex;
flex-wrap: wrap; /* Разрешает перенос карточек на новую строку */
gap: 20px; /* Отступы между карточками */
justify-content: center; /* Центрирование карточек по горизонтали */
}
/* Общие стили для карточек отдела */
.user-card {
background: #1e1e1e;
color: #e0e0e0;
border-radius: 10px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.5);
padding: 20px;
transition: transform 0.3s ease, box-shadow 0.3s ease;
overflow: hidden; /* Чтобы контент не выходил за границы карточки */
flex: 1 1 300px; /* Разрешает карточкам быть не меньше 300px в ширину и расти, если есть место */
}
.user-card:hover {
transform: scale(1.02);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.7);
}
.user-card-header {
margin-bottom: 15px;
}
.user-card-header h2 {
margin: 0;
color: #bb86fc;
font-size: 1.2em; /* Увеличен размер шрифта заголовка карточки */
}
/* Стили кнопок */
.user-actions {
display: flex;
gap: 10px;
}
.delete-button, .edit-button {
background: linear-gradient(135deg, #bb86fc, #9a67ea);
color: white;
border: none;
padding: 12px 20px;
border-radius: 8px;
cursor: pointer;
transition: background 0.3s ease, transform 0.3s ease, box-shadow 0.3s ease;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
font-size: 16px;
text-align: center; /* Центрирование текста */
}
.delete-button:hover, .edit-button:hover {
background: linear-gradient(135deg, #9a67ea, #bb86fc);
transform: scale(1.05);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.4);
}
/* Медиазапросы для мобильных устройств */
@media (max-width: 768px) {
.header-container h1 {
font-size: 1.6em;
}
.create-button {
font-size: 14px;
padding: 10px 15px;
}
.user-card {
padding: 15px;
}
.user-card-header h2 {
font-size: 1.1em;
}
.delete-button, .edit-button {
font-size: 14px;
padding: 10px 15px;
}
}
/* Медиазапросы для экстра маленьких экранов (например, телефоны в портретном режиме) */
@media (max-width: 480px) {
.header-container h1 {
font-size: 1.3em;
}
.create-button {
font-size: 12px;
padding: 8px 12px;
}
.user-card {
padding: 10px;
}
.user-card-header h2 {
font-size: 1em;
}
.delete-button, .edit-button {
font-size: 12px;
padding: 8px 12px;
}
}

View File

@ -0,0 +1,231 @@
/* Общие стили для страницы */
.page-wrapper {
position: relative;
padding: 20px; /* Добавлено для отступов от края экрана */
}
/* Контейнер для заголовка и кнопки создания */
.header-container {
display: flex;
flex-wrap: wrap; /* Позволяет элементам переноситься на следующую строку при необходимости */
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
width: 100%;
}
/* Заголовок */
.header-container h1 {
font-size: 2em; /* Основной размер шрифта для заголовка */
margin: 0;
word-break: break-word; /* Предотвращение переполнения текста заголовка */
}
/* Контейнер для выбора отдела */
.select-container {
flex: 1; /* Занимает оставшееся пространство */
display: flex;
justify-content: center; /* Выравнивание по центру */
margin: 0 20px; /* Отступы слева и справа */
}
/* Выпадающий список выбора отдела */
.select-container select {
background: #1e1e1e;
color: #e0e0e0;
border: 1px solid #bb86fc;
border-radius: 8px;
padding: 10px; /* Уменьшены отступы для улучшения удобства */
font-size: 0.9em; /* Уменьшен размер шрифта для лучшего восприятия */
box-sizing: border-box; /* Учитывает отступы в ширине */
}
/* Кнопка создания отдела */
.create-button {
background: linear-gradient(135deg, #bb86fc, #9a67ea);
color: #fff;
border: none;
padding: 10px 15px;
border-radius: 8px;
cursor: pointer;
transition: background 0.3s ease, transform 0.3s ease, box-shadow 0.3s ease;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
font-size: 14px;
font-weight: bold;
position: relative;
overflow: hidden;
text-align: center; /* Центрирование текста */
}
/* Псевдо-элемент для кнопки создания */
.create-button::before {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 300%;
height: 300%;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
transform: translate(-50%, -50%) scale(0);
transition: transform 0.4s ease;
z-index: 0;
}
/* Эффекты при наведении на кнопку */
.create-button:hover::before {
transform: translate(-50%, -50%) scale(1);
}
.create-button:hover {
background: linear-gradient(135deg, #9a67ea, #bb86fc);
transform: scale(1.05);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.4);
}
.create-button span {
position: relative;
z-index: 1;
}
/* Общие стили для карточек вопроса */
.user-card {
background: #1e1e1e;
color: #e0e0e0;
border-radius: 10px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.5);
padding: 15px; /* Уменьшены отступы для карточек */
margin-bottom: 20px;
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
/* Эффекты при наведении на карточки */
.user-card:hover {
transform: scale(1.02);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.7);
}
.user-card-header {
margin-bottom: 10px;
}
.user-card-header h2 {
margin: 0;
color: #bb86fc;
font-size: 1.1em; /* Уменьшен размер шрифта заголовка карточки */
}
/* Стили кнопок */
.user-actions {
display: flex;
gap: 10px;
flex-wrap: wrap; /* Позволяет кнопкам переноситься на следующую строку при необходимости */
}
/* Стили для кнопок */
.delete-button, .edit-button {
background: linear-gradient(135deg, #bb86fc, #9a67ea);
color: white;
border: none;
padding: 10px 15px; /* Уменьшены отступы для кнопок */
border-radius: 8px;
cursor: pointer;
transition: background 0.3s ease, transform 0.3s ease, box-shadow 0.3s ease;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
font-size: 14px; /* Уменьшен размер шрифта для кнопок */
text-align: center; /* Центрирование текста */
}
/* Эффекты при наведении на кнопки */
.delete-button:hover, .edit-button:hover {
background: linear-gradient(135deg, #9a67ea, #bb86fc);
transform: scale(1.05);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.4);
}
/* Медиазапросы для мобильных устройств */
@media (max-width: 768px) {
.header-container {
flex-direction: column;
align-items: center; /* Центрирование элементов */
}
.header-container h1 {
font-size: 1.6em; /* Уменьшен размер шрифта для заголовка */
text-align: center;
margin-bottom: 10px;
}
.select-container {
margin: 10px 0; /* Отступы сверху и снизу для контейнера выбора отдела */
width: 100%; /* Ширина контейнера выбора отдела */
justify-content: center; /* Центрирование выбора */
}
.select-container select {
width: 100%; /* Ширина селекта на мобильных устройствах */
box-sizing: border-box;
}
.create-button {
font-size: 14px;
padding: 8px 12px;
width: 100%; /* Ширина кнопки на мобильных устройствах */
}
.user-card {
padding: 15px;
}
.user-card-header h2 {
font-size: 1.1em;
}
.delete-button, .edit-button {
font-size: 14px;
padding: 10px 15px;
}
}
/* Медиазапросы для экстра маленьких экранов (например, телефоны в портретном режиме) */
@media (max-width: 480px) {
.header-container {
flex-direction: column;
align-items: center; /* Центрирование элементов */
padding: 10px 0; /* Уменьшены отступы */
}
.header-container h1 {
font-size: 1.4em; /* Уменьшенный размер шрифта */
text-align: center;
margin-bottom: 10px;
}
.select-container {
margin: 10px 0; /* Отступы сверху и снизу */
}
.select-container select {
padding: 6px;
font-size: 0.8em;
}
.create-button {
font-size: 12px;
padding: 6px 10px;
width: 100%; /* Ширина кнопки на экстра маленьких экранах */
}
.user-card {
padding: 10px;
}
.user-card-header h2 {
font-size: 1em;
}
.delete-button, .edit-button {
font-size: 12px;
padding: 8px 12px;
}
}

View File

@ -0,0 +1,394 @@
/* Общие стили для страницы */
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 0;
padding: 0;
color: #e0e0e0;
background-color: #121212;
overflow-x: hidden; /* Предотвращение горизонтального скролла */
display: flex;
flex-direction: column;
min-height: 100vh; /* Обеспечивает минимальную высоту страницы */
box-sizing: border-box; /* Учитывает паддинги и границы в размерах */
}
/* Стили для заголовка */
header {
background: #1e1e1e;
color: #e0e0e0;
padding: 20px 0; /* Увеличенное паддинг */
position: fixed;
width: 100%;
top: 0;
left: 0;
z-index: 1000;
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.4); /* Увеличенная тень */
box-sizing: border-box; /* Учитывает паддинги и границы в размерах */
}
header .container {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 30px; /* Увеличенное паддинг */
box-sizing: border-box; /* Учитывает паддинги и границы в размерах */
}
header h1 {
margin: 0;
font-size: 1.8em; /* Увеличенный размер шрифта */
}
.menu-toggle {
display: none;
font-size: 28px; /* Увеличенный размер шрифта */
background: none;
border: none;
color: #e0e0e0;
cursor: pointer;
}
header nav {
display: flex;
align-items: center;
}
header nav a {
color: #e0e0e0;
text-decoration: none;
margin: 0 20px; /* Увеличенный отступ */
font-weight: bold;
font-size: 1.1em; /* Увеличенный размер шрифта */
transition: color 0.3s ease;
}
header nav a:hover {
color: #bb86fc;
}
header nav.open {
display: block;
}
@media (max-width: 768px) {
.menu-toggle {
display: block;
}
header nav {
display: none;
flex-direction: column;
align-items: flex-start;
width: 100%;
}
header nav.open {
display: flex;
position: absolute;
top: 80px; /* Отрегулированное положение */
left: 0;
background: #1e1e1e;
width: 100%;
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.4); /* Увеличенная тень */
}
header nav a {
margin: 15px 0; /* Увеличенный отступ */
width: 100%;
padding: 15px 20px; /* Увеличенные паддинги */
box-sizing: border-box;
font-size: 1.2em; /* Увеличенный размер шрифта */
}
}
/* Стили для основного контента */
main {
padding: 100px 30px 30px; /* Увеличенное паддинг */
flex: 1; /* Позволяет основному контенту занимать оставшееся пространство */
box-sizing: border-box; /* Учитывает паддинги и границы в размерах */
}
/* Контейнер для контента */
.content-container {
max-width: 1060px; /* Увеличенная максимальная ширина */
margin: auto;
padding: 30px; /* Увеличенные паддинги */
display: flex;
flex-direction: column;
align-items: center;
position: relative;
}
/* Стили для формы и профиля */
.form-container, .profile-container {
background: #1e1e1e;
padding: 30px; /* Увеличенные паддинги */
border-radius: 12px; /* Увеличенный радиус скругления */
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.6); /* Увеличенная тень */
min-width: 50%;
position: relative;
animation: fadeIn 0.5s ease-in;
}
/* Стили для заголовков */
h2 {
margin-top: 0;
color: #bb86fc;
font-size: 1.5em; /* Увеличенный размер шрифта */
}
/* Стили для форм */
form {
display: flex;
flex-direction: column;
}
form label {
margin: 15px 0 10px; /* Увеличенный отступ */
color: #e0e0e0;
font-size: 1.1em; /* Увеличенный размер шрифта */
}
form input {
padding: 15px; /* Увеличенные паддинги */
margin-bottom: 15px; /* Увеличенный отступ */
border: 1px solid #333;
border-radius: 8px; /* Увеличенный радиус скругления */
background: #333;
color: #e0e0e0;
font-size: 1em; /* Увеличенный размер шрифта */
transition: border-color 0.3s ease;
}
form input:focus {
border-color: #bb86fc;
outline: none;
}
form button {
padding: 15px; /* Увеличенные паддинги */
background-color: #bb86fc;
border: none;
color: #fff;
border-radius: 8px; /* Увеличенный радиус скругления */
cursor: pointer;
font-size: 18px; /* Увеличенный размер шрифта */
transition: background-color 0.3s ease, transform 0.3s ease, box-shadow 0.3s ease;
}
form button:hover {
background-color: #9a67ea;
transform: scale(1.05);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.5); /* Увеличенная тень */
}
.alert {
padding: 20px; /* Увеличенные паддинги */
margin-bottom: 25px; /* Увеличенный отступ */
border: 1px solid transparent;
border-radius: 6px; /* Увеличенный радиус скругления */
display: none; /* По умолчанию скрыт */
}
/* Стили для сообщений об ошибках */
.alert.error {
color: #a94442; /* Цвет текста */
background-color: #f2dede; /* Цвет фона */
border-color: #ebccd1; /* Цвет границы */
}
/* Показать блок с ошибкой */
.alert.show {
display: block;
}
/* Стили для информации профиля */
.profile-info p {
margin: 20px 0; /* Увеличенный отступ */
color: #e0e0e0;
font-size: 1.2em; /* Увеличенный размер шрифта */
}
.profile-action {
margin-top: 30px; /* Увеличенный отступ */
}
.logout-button {
display: inline-block;
padding: 15px 25px; /* Увеличенные паддинги */
background-color: #bb86fc;
color: #fff;
text-decoration: none;
border-radius: 8px; /* Увеличенный радиус скругления */
transition: background-color 0.3s ease, transform 0.3s ease, box-shadow 0.3s ease;
}
.logout-button:hover {
background-color: #9a67ea;
transform: scale(1.05);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.5); /* Увеличенная тень */
}
/* Стили для карточек профиля */
.profile-card {
background: #333;
padding: 30px; /* Увеличенные паддинги */
border-radius: 12px; /* Увеличенный радиус скругления */
margin-top: 30px; /* Увеличенный отступ */
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.6); /* Увеличенная тень */
color: #e0e0e0;
}
/* Стили для футера */
footer {
background: #1e1e1e;
color: #e0e0e0;
padding: 20px 0; /* Увеличенные паддинги */
text-align: center;
width: 100%;
font-size: 1.2em; /* Увеличенный размер шрифта */
}
/* Стили для страницы индекса */
.index-container {
text-align: center;
color: #e0e0e0;
}
.index-container h2 {
color: #bb86fc;
font-size: 2em; /* Увеличенный размер шрифта */
}
.index-container p {
margin: 15px 0; /* Увеличенный отступ */
font-size: 1.2em; /* Увеличенный размер шрифта */
}
/* Анимация появления */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.alert-danger {
background-color: #f44336;
color: #ffffff;
}
/* Стили для карточек пользователей */
.user-cards-container {
display: flex;
flex-wrap: wrap;
gap: 30px; /* Увеличенный интервал */
justify-content: center;
}
.user-card {
background: #1e1e1e;
color: #e0e0e0;
border-radius: 12px; /* Увеличенный радиус скругления */
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.6); /* Увеличенная тень */
padding: 30px; /* Увеличенные паддинги */
width: 320px; /* Увеличенная ширина */
transition: transform 0.3s ease, box-shadow 0.3s ease;
box-sizing: border-box; /* Учитывает паддинги и границы в размерах */
}
.user-card:hover {
transform: scale(1.03);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.7); /* Увеличенная тень */
}
.user-card-header {
margin-bottom: 20px; /* Увеличенный отступ */
}
.user-card-header h2 {
margin: 0;
color: #bb86fc;
font-size: 1.4em; /* Увеличенный размер шрифта */
}
.user-id {
font-size: 1em; /* Увеличенный размер шрифта */
}
.user-status {
display: flex;
align-items: center;
gap: 20px; /* Увеличенный интервал */
}
.switch {
position: relative;
display: inline-block;
width: 40px; /* Увеличенная ширина */
height: 24px; /* Увеличенная высота */
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: .4s;
border-radius: 24px; /* Увеличенный радиус скругления */
}
.slider:before {
position: absolute;
content: "";
height: 16px; /* Увеличенная высота */
width: 16px; /* Увеличенная ширина */
border-radius: 50%;
left: 4px;
bottom: 4px;
background-color: white;
transition: .4s;
}
input:checked + .slider {
background-color: #bb86fc;
}
input:checked + .slider:before {
transform: translateX(16px); /* Отрегулированное значение */
}
.status-label {
margin: 0;
color: #e0e0e0;
}
/* Обертка страницы */
.page-wrapper {
display: flex;
flex-direction: column;
min-height: 100vh; /* Обеспечивает минимальную высоту страницы */
}
/* Класс для анимации появления */
.fade-in {
opacity: 0;
animation: fadeIn 0.5s ease-in forwards;
}
.show {
opacity: 1;
}

View File

@ -0,0 +1,161 @@
/* Подключите общий файл стилей */
@import url('common.css');
/* Основной контейнер карточек пользователей */
.user-cards-container {
display: grid;
grid-template-columns: repeat(3, 1fr); /* Три колонки */
gap: 20px;
justify-content: center;
}
/* Стили карточек пользователей */
.user-card {
background: linear-gradient(135deg, #2e2e2e, #1e1e1e);
border-radius: 15px;
padding: 20px;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
color: #e0e0e0;
display: flex;
flex-direction: column;
align-items: center;
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.user-card:hover {
transform: scale(1.05);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.4);
}
/* Заголовок карточки */
.user-card-header {
margin-bottom: 15px;
text-align: center;
}
.user-id {
font-size: 14px;
color: #bb86fc;
}
.user-username {
font-size: 22px;
margin: 5px 0;
font-weight: bold;
}
/* Статус пользователя */
.user-status {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
justify-content: center;
}
.switch {
position: relative;
display: inline-block;
width: 60px;
height: 30px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: .4s;
border-radius: 15px;
}
.slider:before {
position: absolute;
content: "";
height: 22px;
width: 22px;
border-radius: 50%;
left: 4px;
bottom: 4px;
background-color: white;
transition: .4s;
}
input:checked + .slider {
background-color: #bb86fc;
}
input:checked + .slider:before {
transform: translateX(30px);
}
.status-label {
font-size: 16px;
color: #e0e0e0;
}
/* Медиа-запросы для мобильных устройств */
@media (max-width: 768px) {
.user-cards-container {
grid-template-columns: repeat(2, 1fr); /* Две колонки на планшетах */
}
.user-card {
padding: 15px;
}
}
/* Медиа-запросы для очень маленьких устройств */
@media (max-width: 480px) {
.user-cards-container {
grid-template-columns: 1fr; /* Одна колонка на мобильных устройствах */
}
.user-card {
padding: 10px;
}
.user-card-header {
margin-bottom: 10px;
}
.user-id {
font-size: 12px;
}
.user-username {
font-size: 18px;
}
.status-label {
font-size: 14px;
}
.switch {
width: 50px;
height: 25px;
}
.slider:before {
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
}
input:checked + .slider:before {
transform: translateX(25px);
}
}

BIN
webadmin/static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -0,0 +1,143 @@
document.addEventListener("DOMContentLoaded", () => {
const createButton = document.getElementById("create-button");
const editModal = document.getElementById("edit-modal");
const createModal = document.getElementById("create-modal");
const closeButtons = document.querySelectorAll(".close-button");
const editForm = document.getElementById("edit-form");
const createForm = document.getElementById("create-form");
const editNameInput = document.getElementById("edit-name");
const editIdInput = document.getElementById("edit-id");
const createNameInput = document.getElementById("create-name");
const modalTitle = document.getElementById("modal-title");
const createTitle = document.getElementById("create-title");
// Делегирование событий для кнопок "Удалить" и "Изменить"
document.querySelector(".user-cards-container").addEventListener("click", async (event) => {
if (event.target.classList.contains("delete-button")) {
const otdelId = event.target.getAttribute("data-otdel-id");
if (confirm("Вы уверены, что хотите удалить этот отдел?")) {
const response = await fetch(`/delete-otdel/${otdelId}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json"
}
});
const result = await response.json();
if (response.ok) {
event.target.closest(".user-card").remove();
} else {
const errorMessage = result.message || "Произошла ошибка";
alert(`Ошибка: ${errorMessage}`);
}
}
}
if (event.target.classList.contains("edit-button")) {
const otdelId = event.target.getAttribute("data-otdel-id");
const otdelName = event.target.getAttribute("data-otdel-name");
editIdInput.value = otdelId;
editNameInput.value = otdelName;
modalTitle.textContent = `Изменить отдел ${otdelName} (ID: ${otdelId})`;
editModal.classList.add("show");
}
});
// Обработчик для кнопки создания нового отдела
if (createButton) {
createButton.addEventListener("click", () => {
createNameInput.value = "";
createTitle.textContent = "Создать новый отдел";
createModal.classList.add("show");
});
}
// Обработчики для кнопок закрытия модальных окон
closeButtons.forEach(button => {
button.addEventListener("click", (event) => {
const modalId = event.target.getAttribute("data-modal-id");
const modal = document.getElementById(modalId);
if (modal) {
modal.classList.remove("show");
}
});
});
function closeModal(event) {
if (event.target === editModal || event.target === createModal) {
event.target.classList.remove("show");
}
}
window.addEventListener("click", closeModal);
window.addEventListener("touchend", closeModal);
// Обработчик отправки формы редактирования
if (editForm) {
editForm.addEventListener("submit", async (event) => {
event.preventDefault();
const otdelId = editIdInput.value;
const newName = editNameInput.value;
const response = await fetch(`/edit-otdel/${otdelId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ name: newName })
});
if (response.ok) {
const card = document.querySelector(`[data-otdel-id='${otdelId}']`).closest(".user-card");
if (card) {
card.querySelector(".user-username").textContent = newName;
}
editModal.classList.remove("show");
} else {
console.error("Failed to edit otdel");
}
});
}
// Обработчик отправки формы создания нового отдела
if (createForm) {
createForm.addEventListener("submit", async (event) => {
event.preventDefault();
const newName = createNameInput.value;
const response = await fetch(`/create-otdel`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ name: newName })
});
const result = await response.json();
if (response.ok) {
const newOtdel = result.otdel;
const newCard = document.createElement("div");
newCard.classList.add("user-card");
newCard.innerHTML = `
<div class="user-card-header">
<h3 class="user-id">ID: ${newOtdel.id}</h3>
<h2 class="user-username">${newOtdel.name}</h2>
</div>
<div class="user-actions">
<button class="edit-button" data-otdel-id="${newOtdel.id}" data-otdel-name="${newOtdel.name}">Изменить</button>
<button class="delete-button" data-otdel-id="${newOtdel.id}">Удалить</button>
</div>
`;
document.querySelector(".user-cards-container").appendChild(newCard);
createModal.classList.remove("show");
} else {
const errorMessage = result.message || "Произошла ошибка";
alert(`Ошибка: ${errorMessage}`);
}
});
}
});

View File

@ -0,0 +1,201 @@
document.addEventListener("DOMContentLoaded", () => {
const createButton = document.getElementById("create-button");
const selectOtdel = document.getElementById("select-otdel");
const userCardsContainer = document.querySelector(".user-cards-container");
const paginationContainer = document.querySelector(".pagination");
const questionCreateModal = document.getElementById("question-create-modal");
const questionEditModal = document.getElementById("question-edit-modal");
const closeButton = document.querySelectorAll(".close-button");
const questionCreateForm = document.getElementById("question-create-form");
const questionEditForm = document.getElementById("question-edit-form");
const questionTitleInput = document.getElementById("question-title");
const questionTextInput = document.getElementById("question-text");
const questionFileInput = document.getElementById("question-file");
const questionOtdelIdDisplay = document.getElementById("question-otdel-id");
const editQuestionIdInput = document.getElementById("edit-question-id");
const editQuestionTitleInput = document.getElementById("edit-question-title");
const editQuestionTextInput = document.getElementById("edit-question-text");
const editQuestionFileInput = document.getElementById("edit-question-file");
const editQuestionOtdelIdDisplay = document.getElementById("edit-question-otdel-id");
let currentPage = new URLSearchParams(window.location.search).get('page') || 1;
let selectedOtdelId = new URLSearchParams(window.location.search).get('otdel') || null;
// Обновляем страницу при изменении отдела
selectOtdel.addEventListener("change", (event) => {
currentPage = 1; // Сбрасываем страницу на 1
selectedOtdelId = event.target.value;
updateUrl();
});
// Обновляем URL при нажатии на кнопку пагинации
paginationContainer.addEventListener("click", (event) => {
if (event.target.classList.contains("pagination-button")) {
const page = event.target.getAttribute("data-page");
if (page) {
currentPage = page;
updateUrl();
}
}
});
// Обновляем URL с новыми параметрами
function updateUrl() {
const params = new URLSearchParams();
params.set('page', currentPage); // Устанавливаем страницу первым
if (selectedOtdelId) {
params.set('otdel', selectedOtdelId); // Устанавливаем отдел вторым
}
window.history.replaceState({}, '', `${window.location.pathname}?${params}`);
window.location.reload(); // Перезагрузить страницу с новыми параметрами
}
// Открытие модального окна для создания вопроса
if (createButton) {
createButton.addEventListener("click", () => {
if (selectedOtdelId) {
questionOtdelIdDisplay.textContent = `ID отдела: ${selectedOtdelId}`;
questionCreateModal.classList.add("show"); // Показать модальное окно
}
});
}
// Открытие модального окна для редактирования вопроса
function openEditModal(questionId, title, text, fileName, otdelId) {
editQuestionIdInput.value = questionId;
editQuestionTitleInput.value = title;
editQuestionTextInput.value = text;
editQuestionOtdelIdDisplay.textContent = `ID отдела: ${otdelId}`;
editQuestionFileInput.value = ''; // Файловый input не поддерживает установки значения через JS
questionEditModal.classList.add("show"); // Показать модальное окно
}
// Обработчик клика по кнопкам редактирования
userCardsContainer.addEventListener("click", (event) => {
if (event.target.classList.contains("edit-button")) {
const questionId = event.target.getAttribute("data-question-id");
const title = event.target.getAttribute("data-question-name");
const text = event.target.getAttribute("data-question-text") || "";
const fileName = event.target.getAttribute("data-question-file") || "";
const otdelId = event.target.getAttribute("data-question-otdel-id");
openEditModal(questionId, title, text, fileName, otdelId);
} else if (event.target.classList.contains("delete-button")) {
const questionId = event.target.getAttribute("data-question-id");
if (confirm("Вы уверены, что хотите удалить этот вопрос?")) {
deleteQuestion(questionId);
}
}
});
// Функция для удаления вопроса
async function deleteQuestion(questionId) {
const response = await fetch(`/delete-question/${questionId}`, {
method: "DELETE"
});
if (response.ok) {
updateUrl(); // Обновляем URL и перезагружаем страницу
} else {
console.error("Failed to delete question");
}
}
// Обработчик закрытия модальных окон
closeButton.forEach(button => {
button.addEventListener("click", (event) => {
const modalId = event.target.getAttribute("data-modal-id");
const modal = document.getElementById(modalId);
if (modal) {
modal.classList.remove("show"); // Скрыть соответствующее модальное окно
}
});
});
window.addEventListener("click", (event) => {
if (event.target === questionCreateModal || event.target === questionEditModal) {
event.target.classList.remove("show"); // Скрыть модальное окно при клике вне его
}
});
// Обработка отправки формы для создания вопроса
if (questionCreateForm) {
questionCreateForm.addEventListener("submit", async (event) => {
event.preventDefault();
const title = questionTitleInput.value;
const text = questionTextInput.value || "";
const file = questionFileInput.files[0] || null;
if (!text && !file) {
alert("Пожалуйста, заполните хотя бы одно поле: текст или файл.");
return;
}
const formData = new FormData();
formData.append("title", title);
formData.append("text", text);
if (file) {
formData.append("file", file);
}
formData.append("otdel_id", selectedOtdelId);
const response = await fetch(`/create-question`, {
method: "POST",
body: formData
});
if (response.ok) {
updateUrl(); // Обновляем URL и перезагружаем страницу
questionCreateModal.classList.remove("show"); // Скрыть модальное окно
} else {
console.error("Failed to create question");
}
});
}
// Обработка отправки формы для редактирования вопроса
if (questionEditForm) {
questionEditForm.addEventListener("submit", async (event) => {
event.preventDefault();
const questionId = editQuestionIdInput.value;
const title = editQuestionTitleInput.value;
const text = editQuestionTextInput.value || "";
const file = editQuestionFileInput.files[0] || null;
if (!text && !file) {
alert("Пожалуйста, заполните хотя бы одно поле: текст или файл.");
return;
}
const formData = new FormData();
formData.append("id", questionId);
formData.append("title", title);
formData.append("text", text);
if (file) {
formData.append("file", file);
}
formData.append("otdel_id", editQuestionOtdelIdDisplay.textContent.split(': ')[1]);
const response = await fetch(`/edit-question`, {
method: "PUT",
body: formData
});
if (response.ok) {
updateUrl(); // Обновляем URL и перезагружаем страницу
questionEditModal.classList.remove("show"); // Скрыть модальное окно
} else {
console.error("Failed to edit question");
}
});
}
// Инициализация на основе параметров URL
if (selectedOtdelId) {
selectOtdel.value = selectedOtdelId;
}
});

View File

@ -0,0 +1,35 @@
document.addEventListener("DOMContentLoaded", function() {
const alerts = document.querySelectorAll('.alert');
alerts.forEach(alert => {
alert.classList.add('show');
setTimeout(() => {
alert.classList.remove('show');
}, 5000); // Display alerts for 5 seconds
});
// Handle logout button
const logoutButton = document.querySelector('.logout-button');
if (logoutButton) {
logoutButton.addEventListener('click', function(event) {
event.preventDefault();
const form = document.createElement('form');
form.method = 'POST';
form.action = logoutButton.href;
const input = document.createElement('input');
input.type = 'hidden';
input.name = '_method';
input.value = 'POST';
form.appendChild(input);
document.body.appendChild(form);
form.submit();
});
}
});

View File

@ -0,0 +1,39 @@
function updateTime() {
const now = new Date();
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
const timeString = `${hours}:${minutes}:${seconds}`;
document.getElementById('current-time').textContent = timeString;
}
updateTime();
// Update the time every second
setInterval(updateTime, 1000);
// Initial call to display time immediately
// scripts.js
document.addEventListener('DOMContentLoaded', function () {
function updateTime() {
const now = new Date();
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
const formattedTime = `${hours}:${minutes}:${seconds}`;
document.getElementById('current-time').textContent = formattedTime;
}
// Update the time immediately
updateTime();
// Update the time every second
setInterval(updateTime, 1000);
});

125
webadmin/static/js/users.js Normal file
View File

@ -0,0 +1,125 @@
document.addEventListener('DOMContentLoaded', function() {
const newsletterTitle = document.getElementById("newsletter-title");
const newsletterForm = document.getElementById("newsletter-form");
const newsletterMessageInput = document.getElementById("newsletter-message");
const newsletterButton = document.getElementById("newsletter-button");
const newsletterModal = document.getElementById("newsletter-modal");
const closeButton = document.querySelectorAll(".close-button");
// Проверка существования элементов перед добавлением обработчиков
if (newsletterButton) {
newsletterButton.addEventListener("click", () => {
if (newsletterTitle && newsletterMessageInput && newsletterModal) {
newsletterMessageInput.value = ""; // Очистить поле ввода
newsletterTitle.textContent = "Рассылка";
newsletterModal.classList.add("show"); // Показать модальное окно
}
});
}
if (newsletterForm) {
newsletterForm.addEventListener("submit", async (event) => {
event.preventDefault();
const message = newsletterMessageInput.value;
try {
const response = await fetch(`/send-newsletter`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ message: message })
});
// Парсим JSON-ответ
const result = await response.json();
if (response.ok) {
alert("Рассылка отправлена успешно!");
if (newsletterModal) {
newsletterModal.classList.remove("show"); // Скрыть модальное окно
}
} else {
// Показать сообщение об ошибке, если оно есть
const errorMessage = result.message || "Произошла ошибка, рассылка не была начата";
alert(`Ошибка: ${errorMessage}`);
if (newsletterModal) {
newsletterModal.classList.remove("show"); // Скрыть модальное окно
}
}
} catch (error) {
console.error("Error:", error);
alert("Произошла ошибка при отправке запроса.");
}
});
}
if (closeButton.length > 0) {
closeButton.forEach(button => {
button.addEventListener("click", (event) => {
const modalId = event.target.getAttribute("data-modal-id");
const modal = document.getElementById(modalId);
if (modal) {
modal.classList.remove("show"); // Скрыть соответствующее модальное окно
}
});
});
}
function closeModal(event) {
if (newsletterModal && event.target === newsletterModal) {
newsletterModal.classList.remove("show"); // Скрыть модальное окно при клике вне его
}
}
window.addEventListener("click", closeModal);
window.addEventListener("touchend", closeModal); // Обработка касания на мобильных устройствах
const statusToggles = document.querySelectorAll('.status-toggle');
statusToggles.forEach(toggle => {
toggle.addEventListener('change', async function() {
const userId = this.getAttribute('data-user-id');
const status = this.checked ? 'enabled' : 'disabled';
try {
const response = await fetch(`/update_status/${userId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCookie('csrftoken') // Получение CSRF токена
},
body: JSON.stringify({ status: status })
});
if (response.ok) {
const statusLabel = this.closest('.user-status').querySelector('.status-label');
if (statusLabel) {
statusLabel.textContent = this.checked ? 'Доступ открыт' : 'Доступ закрыт';
}
console.log('Status updated successfully');
} else {
console.error('Failed to update status');
}
} catch (error) {
console.error('Error:', error);
}
});
});
});
// Функция для получения CSRF токена из cookie
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}

View File

@ -0,0 +1,141 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Admin Panel{% endblock %}</title>
<link rel="stylesheet" href="/static/css/modal.css">
<link rel="stylesheet" href="/static/css/styles.css">
<link rel="icon" href="/static/favicon.png" type="image/x-icon">
{% block extra_head %}
{% endblock %}
<script>
document.addEventListener("DOMContentLoaded", () => {
const contentContainer = document.querySelector(".content-container");
const menuToggle = document.querySelector(".menu-toggle");
const navLinks = document.querySelector("nav");
setTimeout(() => {
contentContainer.classList.add("show");
}, 100);
menuToggle.addEventListener("click", () => {
navLinks.classList.toggle("open");
});
});
</script>
</head>
<body>
<div class="page-wrapper">
<header>
<div class="container">
<h1>Admin Panel</h1>
<button class="menu-toggle">&#9776;</button>
<nav>
{% if user %}
{% if user.has_access %}
<a href="/questions" class="nav-link">Вопросы</a>
<a href="/otdels" class="nav-link">Отделы</a>
<a href="/users" class="nav-link">Пользователи</a>
{% endif %}
<a href="/profile" class="nav-link">Профиль</a>
<a href="/logout" class="nav-link">Выйти</a>
{% else %}
<a href="/login" class="nav-link">Войти</a>
<a href="/register" class="nav-link">Зарегистрироваться</a>
{% endif %}
</nav>
</div>
</header>
<main>
<div class="content-container fade-in">
{% block content %}{% endblock %}
</div>
</main>
<footer>
<div class="container">
<p>&copy; 2024 Admin Panel. All rights reserved.</p>
</div>
</footer>
<!-- Модальное окно для редактирования отдела -->
<div id="edit-modal" class="modal">
<div class="modal-content">
<span class="close-button" data-modal-id="edit-modal">&times;</span>
<h2 id="modal-title">Изменить отдел</h2>
<form id="edit-form">
<label for="edit-name">Название отдела:</label>
<input type="text" id="edit-name" name="name" required>
<input type="hidden" id="edit-id" name="id">
<button type="submit" class="save-button">Сохранить</button>
</form>
</div>
</div>
<!-- Модальное окно для создания нового отдела -->
<div id="create-modal" class="modal">
<div class="modal-content">
<span class="close-button" data-modal-id="create-modal">&times;</span>
<h2 id="create-title">Создать новый отдел</h2>
<form id="create-form">
<label for="create-name">Название отдела:</label>
<input type="text" id="create-name" name="name" required>
<button type="submit" class="save-button">Создать</button>
</form>
</div>
</div>
<!-- Модальное окно для рассылки -->
<div id="newsletter-modal" class="modal">
<div class="modal-content">
<span class="close-button" data-modal-id="newsletter-modal">&times;</span>
<h2 id="newsletter-title">Рассылка</h2>
<form id="newsletter-form">
<label for="newsletter-message">Сообщение:</label>
<textarea id="newsletter-message" name="message" rows="4" required></textarea>
<button type="submit" class="save-button">Отправить</button>
</form>
</div>
</div>
<!-- Модальное окно для создания нового вопроса -->
<div id="question-create-modal" class="modal">
<div class="modal-content">
<span class="close-button" data-modal-id="question-create-modal">&times;</span>
<h2 id="question-create-title">Создать новый вопрос</h2>
<form id="question-create-form">
<label for="question-title">Название вопроса:</label>
<input type="text" id="question-title" name="title" required>
<label for="question-text">Ответ (необязательно):</label>
<textarea id="question-text" name="text" data-required></textarea>
<label for="question-file">Файл (необязательно):</label>
<input type="file" id="question-file" name="file" data-required>
<p id="question-otdel-id"></p> <!-- Строка для отображения ID отдела -->
<button type="submit" class="save-button">Создать</button>
</form>
</div>
</div>
<!-- Модальное окно для редактирования вопроса -->
<div id="question-edit-modal" class="modal">
<div class="modal-content">
<span class="close-button" data-modal-id="question-edit-modal">&times;</span>
<h2 id="question-edit-title">Редактировать вопрос</h2>
<form id="question-edit-form">
<input type="hidden" id="edit-question-id" name="question_id"> <!-- Скрытое поле для хранения ID вопроса -->
<label for="edit-question-title">Название вопроса:</label>
<input type="text" id="edit-question-title" name="title" required>
<label for="edit-question-text">Ответ текстом (необязательно):</label>
<textarea id="edit-question-text" name="text" data-required></textarea>
<label for="edit-question-file">Файл (необязательно):</label>
<input type="file" id="edit-question-file" name="file" data-required>
<p id="edit-question-otdel-id"></p> <!-- Строка для отображения ID отдела -->
<button type="submit" class="save-button">Сохранить изменения</button>
</form>
</div>
</div>
<script src="/static/js/scripts.js"></script>
</body>
</html>

View File

@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block title %}Home{% endblock %}
{% block content %}
<div class="index-container">
<h2>Welcome to Admin Panel</h2>
<p>Explore our features and enjoy a seamless experience with our platform.</p>
<p>Current time: <span id="current-time"></span>
<p>Check out our latest updates and news below!</p>
<!-- Add additional content or features here -->
</div>
<script src="/static/js/time.js" defer></script>
{% endblock %}

View File

@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block title %}Login{% endblock %}
{% block content %}
<div class="form-container">
<h2>Login</h2>
{% if error %}
<div class="alert error show">
{{ error }}
</div>
{% endif %}
<form method="post" action="/login">
<label for="username">Username:</label>
<input type="text" id="username" name="username" required>
<label for="password">Password:</label>
<input type="password" id="password" name="password" required>
<button type="submit">Login</button>
</form>
<p>Don't have an account? <a href="/register">Register here</a></p>
</div>
{% endblock %}

View File

@ -0,0 +1,41 @@
{% extends "base.html" %}
{% block title %}Отделы{% endblock %}
{% block extra_head %}
<script src="/static/js/otdels.js" defer></script>
<script src="/static/js/users.js" defer></script>
<link rel="stylesheet" href="/static/css/common.css">
<link rel="stylesheet" href="/static/css/otdels.css">
<link rel="stylesheet" href="/static/css/users.css">
{% endblock %}
{% block content %}
<div class="header-container">
<h1>Отделы</h1>
<button id="create-button" class="create-button">Создать отдел</button>
</div>
<div class="user-cards-container">
{% for otdel in otdels %}
<div class="user-card">
<div class="user-card-header">
<h3 class="user-id">ID: {{ otdel.id }}</h3>
<h2 class="user-username">{{ otdel.name }}</h2>
</div>
<div class="user-actions">
<button class="edit-button" data-otdel-id="{{ otdel.id }}" data-otdel-name="{{ otdel.name }}">Изменить</button>
<button class="delete-button" data-otdel-id="{{ otdel.id }}">Удалить</button>
</div>
</div>
{% endfor %}
</div>
{% include 'pagination.html' %}
{% endblock %}

View File

@ -0,0 +1,27 @@
<div class="pagination">
{% if current_page > 1 %}
<a href="?page={{ current_page - 1 }}{% if selected_otdel_id %}&otdel={{ selected_otdel_id }}{% endif %}" class="pagination-button">Назад</a>
{% endif %}
{% if start_page > 1 %}
<a href="?page=1{% if selected_otdel_id %}&otdel={{ selected_otdel_id }}{% endif %}" class="pagination-button">1</a>
{% if start_page > 2 %}
<span class="pagination-ellipsis">...</span>
{% endif %}
{% endif %}
{% for page in range(start_page, end_page + 1) %}
<a href="?page={{ page }}{% if selected_otdel_id %}&otdel={{ selected_otdel_id }}{% endif %}" class="pagination-button {% if page == current_page %}active{% endif %}">{{ page }}</a>
{% endfor %}
{% if end_page < total_pages %}
{% if end_page < total_pages - 1 %}
<span class="pagination-ellipsis">...</span>
{% endif %}
<a href="?page={{ total_pages }}{% if selected_otdel_id %}&otdel={{ selected_otdel_id }}{% endif %}" class="pagination-button">{{ total_pages }}</a>
{% endif %}
{% if current_page < total_pages %}
<a href="?page={{ current_page + 1 }}{% if selected_otdel_id %}&otdel={{ selected_otdel_id }}{% endif %}" class="pagination-button">Вперед</a>
{% endif %}
</div>

View File

@ -0,0 +1,16 @@
{% extends "base.html" %}
{% block title %}Profile{% endblock %}
{% block extra_head %}
<script src="/static/js/time.js" defer></script>
{% endblock %}
{% block content %}
<div class="content-container">
<h2>Welcome, {{ user.full_name }}</h2>
<p>Username: {{ user.username }}</p>
<p>Current Time: <span id="current-time"></span></p>
<!-- Any other profile details can be added here -->
</div>
{% endblock %}

View File

@ -0,0 +1,57 @@
{% extends "base.html" %}
{% block title %}Вопросы{% endblock %}
{% block extra_head %}
<script src="/static/js/questions.js" defer></script>
<script src="/static/js/users.js" defer></script>
<link rel="stylesheet" href="/static/css/common.css">
<link rel="stylesheet" href="/static/css/questions.css">
<link rel="stylesheet" href="/static/css/users.css">
{% endblock %}
{% block content %}
<div class="header-container">
<h1>Вопросы</h1>
<div class="select-container">
<select id="select-otdel" name="otdel">
<option value="" {% if not selected_otdel_id %}selected{% endif %}>-- Выберите отдел --</option>
{% for otdel in otdels %}
<option value="{{ otdel.id }}" {% if otdel.id == selected_otdel_id %}selected{% endif %}>
{{ otdel.name }}
</option>
{% endfor %}
</select>
</div>
<button id="create-button" class="create-button" style="display: {% if not selected_otdel_id %}none{% else %}inline{% endif %};">Создать вопрос</button>
</div>
<div class="user-cards-container">
{% if questions %}
{% for question in questions %}
<div class="user-card">
<div class="user-card-header">
<h3 class="user-id">ID: {{ question.id }}</h3>
<h2 class="user-username">{{ question.name }}</h2>
</div>
<div class="user-actions">
<button class="edit-button"
data-question-id="{{ question.id }}"
data-question-name="{{ question.name }}"
data-question-text="{{ question.answer }}"
data-question-file="{{ question.file }}"
data-question-otdel-id="{{ question.otdel_id }}">
Изменить
</button>
<button class="delete-button" data-question-id="{{ question.id }}">Удалить</button>
</div>
</div>
{% endfor %}
{% else %}
<p>Пожалуйста, выберите отдел для отображения вопросов.</p>
{% endif %}
</div>
{% include 'pagination.html' %}
{% endblock %}

View File

@ -0,0 +1,24 @@
{% extends "base.html" %}
{% block title %}Register{% endblock %}
{% block content %}
<div class="form-container">
<h2>Register</h2>
{% if error %}
<div class="alert error show">
{{ error }}
</div>
{% endif %}
<form method="post" action="/register">
<label for="username">Username:</label>
<input type="text" id="username" name="username" required>
<label for="password">Password:</label>
<input type="password" id="password" name="password" required>
<label for="full_name">Full Name:</label>
<input type="text" id="full_name" name="full_name" required>
<button type="submit">Register</button>
</form>
<p>Already have an account? <a href="/login">Login here</a></p>
</div>
{% endblock %}

View File

@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block title %}User Management{% endblock %}
{% block extra_head %}
<script src="/static/js/otdels.js" defer></script>
<script src="/static/js/users.js" defer></script>
<link rel="stylesheet" href="/static/css/common.css">
<link rel="stylesheet" href="/static/css/otdels.css">
<link rel="stylesheet" href="/static/css/users.css">
{% endblock %}
{% block content %}
<div class="header-container">
<h1>Пользователи</h1>
<button id="newsletter-button" class="create-button">Рассылка</button>
</div>
<div class="user-cards-container">
{% for user in users %}
<div class="user-card">
<div class="user-card-header">
<h3 class="user-id">ID: {{ user.id }}</h3>
<h2 class="user-username">{{ user.username }}</h2>
</div>
<div class="user-status">
<label class="switch">
<input type="checkbox" class="status-toggle" data-user-id="{{ user.id }}" {% if user.has_access %}checked{% endif %}>
<span class="slider"></span>
</label>
<p class="status-label">
{% if user.has_access %}
Доступ открыт
{% else %}
Доступ закрыт
{% endif %}
</p>
</div>
</div>
{% endfor %}
</div>
{% include 'pagination.html' %}
{% endblock %}

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

View File

@ -0,0 +1,16 @@
[Unit]
Description=Example - Telegram Bot
After=syslog.target
After=network.target
[Service]
WorkingDirectory=/home/tgbot/
ExecStart=/home/tgbot/myenv/bin/python3 /home/tgbot/bot.py
RestartSec=60
StandardError=file:/home/tgbot/logs.log
[Install]
WantedBy=multi-user.target