upd
This commit is contained in:
parent
f1b9cce5ec
commit
d4e736d6a8
12
.env.example
Normal file
12
.env.example
Normal 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
133
.gitignore
vendored
Normal 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
24
Dockerfile
Normal 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
6
bot/__init__.py
Normal 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
95
bot/__main__.py
Normal 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
52
bot/config.py
Normal 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
0
bot/db/__init__.py
Normal file
13
bot/db/db.py
Normal file
13
bot/db/db.py
Normal 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
89
bot/db/models.py
Normal 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
37
bot/filters/filtersbot.py
Normal 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
4
bot/handlers/__init__.py
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
from .user import user
|
||||||
|
from .admin import admin
|
||||||
|
|
||||||
|
routers = (user, admin)
|
27
bot/handlers/admin.py
Normal file
27
bot/handlers/admin.py
Normal 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
154
bot/handlers/user.py
Normal 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
|
31
bot/markups/adminmarkup.py
Normal file
31
bot/markups/adminmarkup.py
Normal 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
175
bot/markups/markup.py
Normal 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
|
28
bot/middlewares/__init__.py
Normal file
28
bot/middlewares/__init__.py
Normal 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())
|
||||||
|
|
||||||
|
|
25
bot/middlewares/check_ban.py
Normal file
25
bot/middlewares/check_ban.py
Normal 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)
|
30
bot/middlewares/create_user.py
Normal file
30
bot/middlewares/create_user.py
Normal 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)
|
||||||
|
|
22
bot/middlewares/edit_callback.py
Normal file
22
bot/middlewares/edit_callback.py
Normal 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)
|
||||||
|
|
30
bot/middlewares/throttling.py
Normal file
30
bot/middlewares/throttling.py
Normal 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
122
bot/sql_function.py
Normal 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() # Возвращает все строки
|
4
bot/states/adminstates.py
Normal file
4
bot/states/adminstates.py
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
from config import StatesGroup, State
|
||||||
|
|
||||||
|
|
||||||
|
|
6
bot/states/states.py
Normal file
6
bot/states/states.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from config import State, StatesGroup
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class RequestState(StatesGroup):
|
||||||
|
one = State()
|
43
docker-compose.yml
Normal file
43
docker-compose.yml
Normal 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
1
files/pg_hba.conf
Normal file
@ -0,0 +1 @@
|
|||||||
|
host all all 0.0.0.0/0 scram-sha-256
|
2
files/postgresql.conf
Normal file
2
files/postgresql.conf
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
listen_addresses = '*'
|
||||||
|
include 'pg_hba.conf'
|
23
nginx/nginx.conf
Normal file
23
nginx/nginx.conf
Normal 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
2025
poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
pyproject.toml
Normal file
28
pyproject.toml
Normal 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
BIN
requirements.txt
Normal file
Binary file not shown.
81
webadmin/__main__.py
Normal file
81
webadmin/__main__.py
Normal 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
48
webadmin/config.py
Normal 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
206
webadmin/db/crud.py
Normal 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
25
webadmin/db/db.py
Normal 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
38
webadmin/db/models.py
Normal 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
15
webadmin/exc.py
Normal 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
81
webadmin/function.py
Normal 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
28
webadmin/models.py
Normal 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
|
5
webadmin/routers/__init__.py
Normal file
5
webadmin/routers/__init__.py
Normal 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)
|
68
webadmin/routers/auth_router.py
Normal file
68
webadmin/routers/auth_router.py
Normal 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)
|
298
webadmin/routers/otdels_router.py
Normal file
298
webadmin/routers/otdels_router.py
Normal 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
|
136
webadmin/routers/user_router.py
Normal file
136
webadmin/routers/user_router.py
Normal 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
33
webadmin/schemas.py
Normal 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
|
34
webadmin/static/css/common.css
Normal file
34
webadmin/static/css/common.css
Normal 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;
|
||||||
|
}
|
136
webadmin/static/css/modal.css
Normal file
136
webadmin/static/css/modal.css
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
177
webadmin/static/css/otdels.css
Normal file
177
webadmin/static/css/otdels.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
231
webadmin/static/css/questions.css
Normal file
231
webadmin/static/css/questions.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
394
webadmin/static/css/styles.css
Normal file
394
webadmin/static/css/styles.css
Normal 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;
|
||||||
|
}
|
161
webadmin/static/css/users.css
Normal file
161
webadmin/static/css/users.css
Normal 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
BIN
webadmin/static/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.6 KiB |
143
webadmin/static/js/otdels.js
Normal file
143
webadmin/static/js/otdels.js
Normal 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}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
201
webadmin/static/js/questions.js
Normal file
201
webadmin/static/js/questions.js
Normal 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;
|
||||||
|
}
|
||||||
|
});
|
35
webadmin/static/js/scripts.js
Normal file
35
webadmin/static/js/scripts.js
Normal 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
39
webadmin/static/js/time.js
Normal file
39
webadmin/static/js/time.js
Normal 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
125
webadmin/static/js/users.js
Normal 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;
|
||||||
|
}
|
141
webadmin/templates/base.html
Normal file
141
webadmin/templates/base.html
Normal 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">☰</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>© 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">×</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">×</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">×</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">×</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">×</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>
|
14
webadmin/templates/index.html
Normal file
14
webadmin/templates/index.html
Normal 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 %}
|
22
webadmin/templates/login.html
Normal file
22
webadmin/templates/login.html
Normal 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 %}
|
41
webadmin/templates/otdels.html
Normal file
41
webadmin/templates/otdels.html
Normal 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 %}
|
||||||
|
|
||||||
|
|
27
webadmin/templates/pagination.html
Normal file
27
webadmin/templates/pagination.html
Normal 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>
|
16
webadmin/templates/profile.html
Normal file
16
webadmin/templates/profile.html
Normal 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 %}
|
57
webadmin/templates/questions.html
Normal file
57
webadmin/templates/questions.html
Normal 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 %}
|
24
webadmin/templates/register.html
Normal file
24
webadmin/templates/register.html
Normal 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 %}
|
46
webadmin/templates/users.html
Normal file
46
webadmin/templates/users.html
Normal 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 %}
|
BIN
webadmin/uploads/123_5e327b983b3a4613b754daee7a82861e.docx
Normal file
BIN
webadmin/uploads/123_5e327b983b3a4613b754daee7a82861e.docx
Normal file
Binary file not shown.
Binary file not shown.
After Width: | Height: | Size: 203 KiB |
BIN
webadmin/uploads/900_908dca7b1ea546998e07594abc709b17.jpg
Normal file
BIN
webadmin/uploads/900_908dca7b1ea546998e07594abc709b17.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 167 KiB |
BIN
webadmin/uploads/900_dd9bb24d2f4d4ec089795e4182da7a07.jpg
Normal file
BIN
webadmin/uploads/900_dd9bb24d2f4d4ec089795e4182da7a07.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 167 KiB |
@ -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
|
Loading…
Reference in New Issue
Block a user