комментарии на инструментах

This commit is contained in:
2025-12-20 23:22:51 +03:00
parent d7bfdb69a6
commit 83ecf65abf
11 changed files with 336 additions and 65 deletions
Binary file not shown.
Binary file not shown.
+18
View File
@@ -82,6 +82,24 @@ async def toolkit_request(
return response return response
@router.post("/comment", summary="Добавление комментария")
async def add_comment(reqData: dict = Depends(requestDict)):
logger.info(f"Добавление комментария")
response = {"status": "error"}
try:
logger.info(f"Добавление комментария")
logger.info(reqData.get("body"))
toolkitId = int(reqData.get("body").get("toolkitId"))
comment = reqData.get("body").get("commentText")
userId = int(reqData.get("body").get("userId"))
result = await ToolkitHandler.addComment(toolkitId, userId, comment)
response = handleResult(result, response)
except Exception as e:
logger.error(e)
finally:
return response
@router.post("/fill_prepare", summary="Подготовка заполнения ящика") @router.post("/fill_prepare", summary="Подготовка заполнения ящика")
async def fill_toolbox(): async def fill_toolbox():
logger.info(f"Подготовка заполнения ящика") logger.info(f"Подготовка заполнения ящика")
+147
View File
@@ -3823,6 +3823,7 @@ async function getToolkitStocks(toolkitId) {
return resp.data; return resp.data;
} }
// Функция показа модального окна с деталями инструмента
// Функция показа модального окна с деталями инструмента // Функция показа модального окна с деталями инструмента
async function showToolkitDetailsModal(toolkitId) { async function showToolkitDetailsModal(toolkitId) {
const modalId = 'toolkitDetailsModal'; const modalId = 'toolkitDetailsModal';
@@ -3920,6 +3921,36 @@ async function showToolkitDetailsModal(toolkitId) {
let toolkitStocksData = null; let toolkitStocksData = null;
let isStocksLoading = false; let isStocksLoading = false;
// Форматирование даты комментария
let commentDateInfo = '';
if (toolkitData.comment_at) {
const commentDate = new Date(toolkitData.comment_at);
commentDateInfo = `
<div class="text-muted small">
<i class="bi bi-clock me-1"></i>
Последнее изменение: ${commentDate.toLocaleDateString('ru-RU')} ${commentDate.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
</div>
`;
} else {
commentDateInfo = `
<div class="text-muted small">
<i class="bi bi-info-circle me-1"></i>
Комментарии еще не оставляли
</div>
`;
}
// Информация о пользователе, оставившем комментарий
let commentUserInfo = '';
if (toolkitData.comment_user_data && toolkitData.comment_user_data.username) {
commentUserInfo = `
<div class="text-muted small">
<i class="bi bi-person me-1"></i>
Автор: ${toolkitData.comment_user_data.username}
</div>
`;
}
modal.innerHTML = ` modal.innerHTML = `
<div class="modal-dialog modal-lg"> <div class="modal-dialog modal-lg">
<div class="modal-content"> <div class="modal-content">
@@ -3993,6 +4024,27 @@ async function showToolkitDetailsModal(toolkitId) {
` : ''} ` : ''}
</div> </div>
</div> </div>
<!-- Секция комментариев -->
<div class="border-top pt-3 mt-3">
<h6><i class="bi bi-chat-left-text me-1"></i>Комментарий</h6>
<div class="mb-2">
${commentDateInfo}
${commentUserInfo}
</div>
<div class="mb-2">
<textarea class="form-control" id="toolkitComment" rows="3"
placeholder="Введите комментарий...">${toolkitData.comment_text || ''}</textarea>
</div>
<div class="d-flex justify-content-between align-items-center">
<div class="form-text">
Комментарий будет сохранен для этого инструмента
</div>
<button type="button" class="btn btn-primary btn-sm" id="saveCommentBtn" disabled>
<i class="bi bi-save me-1"></i>Сохранить
</button>
</div>
</div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
@@ -4100,6 +4152,101 @@ async function showToolkitDetailsModal(toolkitId) {
const bsModal = new bootstrap.Modal(modal); const bsModal = new bootstrap.Modal(modal);
bsModal.show(); bsModal.show();
// Элементы для работы с комментарием
const commentTextarea = modal.querySelector('#toolkitComment');
const saveCommentBtn = modal.querySelector('#saveCommentBtn');
const originalComment = toolkitData.comment_text || '';
// Проверка изменения комментария
const checkCommentChanged = () => {
const currentComment = commentTextarea.value.trim();
const isChanged = currentComment !== originalComment;
saveCommentBtn.disabled = !isChanged || currentComment === '';
};
// Слушатель изменений в текстовом поле
commentTextarea.addEventListener('input', checkCommentChanged);
// Обработчик сохранения комментария
saveCommentBtn.addEventListener('click', async () => {
const commentText = commentTextarea.value.trim();
if (!commentText) {
showInfo('Комментарий не может быть пустым', 'warning');
return;
}
try {
// Блокируем кнопку на время отправки
saveCommentBtn.disabled = true;
saveCommentBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>Сохранение...';
// Отправляем комментарий на сервер
const response = await apiRequest('/toolkit/comment', {
toolkitId: toolkitData.id,
userId: userData.id,
commentText: commentText
}, 'POST');
if (response.status === 'ok') {
// Обновляем информацию о комментарии без перезагрузки страницы
const now = new Date();
// Обновляем информацию о дате
const dateInfoDiv = modal.querySelector('.border-top .text-muted.small:first-child');
if (dateInfoDiv) {
dateInfoDiv.innerHTML = `
<i class="bi bi-clock me-1"></i>
Последнее изменение: ${now.toLocaleDateString('ru-RU')} ${now.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
`;
}
// Обновляем информацию о пользователе
const userInfoDiv = modal.querySelector('.border-top .text-muted.small:nth-child(2)');
if (userInfoDiv) {
userInfoDiv.innerHTML = `
<i class="bi bi-person me-1"></i>
Автор: ${userData.username || 'Текущий пользователь'}
`;
} else if (userData.username) {
// Если элемента с информацией о пользователе не было, создаем его
const commentInfoDiv = modal.querySelector('.border-top .mb-2');
if (commentInfoDiv) {
const newUserInfo = document.createElement('div');
newUserInfo.className = 'text-muted small';
newUserInfo.innerHTML = `
<i class="bi bi-person me-1"></i>
Автор: ${userData.username}
`;
commentInfoDiv.appendChild(newUserInfo);
}
}
// Обновляем оригинальный комментарий для дальнейших проверок
toolkitData.comment_text = commentText;
toolkitData.comment_at = now.toISOString();
toolkitData.comment_user_data = { username: userData.username };
// Показываем успешное сообщение
showInfo('Комментарий успешно сохранен', 'success');
// Кнопка становится неактивной, так как изменения сохранены
saveCommentBtn.innerHTML = '<i class="bi bi-save me-1"></i>Сохранено';
setTimeout(() => {
saveCommentBtn.innerHTML = '<i class="bi bi-save me-1"></i>Сохранить';
checkCommentChanged();
}, 1500);
} else {
throw new Error(response.message || 'Ошибка сохранения комментария');
}
} catch (error) {
console.error('Ошибка при сохранении комментария:', error);
showInfo(error.message || 'Произошла ошибка при сохранении комментария', 'danger');
saveCommentBtn.disabled = false;
saveCommentBtn.innerHTML = '<i class="bi bi-save me-1"></i>Сохранить';
}
});
// Функция для загрузки данных об остатках // Функция для загрузки данных об остатках
const loadToolkitStocks = async () => { const loadToolkitStocks = async () => {
if (isStocksLoading) return; if (isStocksLoading) return;
Binary file not shown.
Binary file not shown.
Binary file not shown.
+26 -1
View File
@@ -1,5 +1,6 @@
from datetime import datetime from datetime import datetime
from db.handlers.stock import StockHandler from db.handlers.stock import StockHandler
from db.handlers.user import UserHandler
from utils import logger, saveImage, safeFilename from utils import logger, saveImage, safeFilename
from db import CRUD from db import CRUD
from db.schemas.toolkit import Toolkit from db.schemas.toolkit import Toolkit
@@ -175,7 +176,12 @@ class ToolkitHandler:
if not toolkit: if not toolkit:
logger.error("Инструмент не найден") logger.error("Инструмент не найден")
return {} return {}
return toolkit.toDict() if toolkit.comment_user_id:
user_data = await UserHandler.get(toolkit.comment_user_id)
data = toolkit.toDict()
data["comment_user_data"] = user_data
logger.info(data)
return data
async def getSeveral(toolkitIds: list[int]) -> list[dict]: async def getSeveral(toolkitIds: list[int]) -> list[dict]:
query = select(Toolkit).where(Toolkit.id.in_(toolkitIds)) query = select(Toolkit).where(Toolkit.id.in_(toolkitIds))
@@ -211,6 +217,25 @@ class ToolkitHandler:
) )
return {"status": "ok"} if result else {"errorMessage": "Инструмент не удален"} return {"status": "ok"} if result else {"errorMessage": "Инструмент не удален"}
async def addComment(toolkitId: int, user_id: int, comment: str):
logger.info(f"Добавление комментария к инструменту {toolkitId}...")
logger.info(f"Комментарий: {comment}")
logger.info(f"Пользователь: {user_id}")
query = select(Toolkit).where(Toolkit.id == toolkitId)
toolkit = await CRUD.read(query)
if not toolkit:
logger.error("Инструмент не найден")
return {"errorMessage": "Инструмент не найден"}
try:
await toolkit.edit(
comment_text=comment, comment_user_id=user_id, comment_at=datetime.now()
)
except Exception as e:
logger.error(f"Ошибка добавления комментария: {str(e)}")
return {"errorMessage": f"Ошибка добавления комментария: {str(e)}"}
logger.info(f"Комментарий к инструменту {toolkit.title} успешно добавлен")
return {"status": "ok"}
async def initialize(): async def initialize():
from .categories import CategoryHandler from .categories import CategoryHandler
+140 -57
View File
@@ -1,3 +1,12 @@
from typing import Optional
from sqlalchemy import inspect, text
from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine
from sqlalchemy.schema import CreateTable
from sqlalchemy.dialects.postgresql import dialect as pg_dialect
from utils import logger
from db.handlers.access import AccessLevelHandler from db.handlers.access import AccessLevelHandler
from db.handlers.user import UserHandler from db.handlers.user import UserHandler
from db.handlers.toolbox import ToolboxHandler from db.handlers.toolbox import ToolboxHandler
@@ -5,12 +14,6 @@ from db.handlers.categories import CategoryHandler
from db.handlers.toolkit import ToolkitHandler from db.handlers.toolkit import ToolkitHandler
from db.handlers.actions import StocksActions from db.handlers.actions import StocksActions
from typing import Optional
from sqlalchemy import inspect, text
from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine
from sqlalchemy.schema import CreateTable
from utils import logger
class DatabaseInitializer: class DatabaseInitializer:
existing_tables: Optional[list[str]] = None existing_tables: Optional[list[str]] = None
@@ -22,6 +25,10 @@ class DatabaseInitializer:
self.engine: Optional[AsyncEngine] = None self.engine: Optional[AsyncEngine] = None
self.metadata = Base.metadata self.metadata = Base.metadata
# ==========================================================
# PUBLIC
# ==========================================================
async def initialize(self, force: bool = False, reNewDB: bool = False): async def initialize(self, force: bool = False, reNewDB: bool = False):
"""Main database initialization method""" """Main database initialization method"""
try: try:
@@ -29,32 +36,39 @@ class DatabaseInitializer:
async with self.engine.begin() as conn: async with self.engine.begin() as conn:
if force: if force:
logger.info("Принудительное удаление и создание баз...") logger.warning("Принудительное удаление и создание БД...")
await self._drop_all() await self._drop_all()
await self._create_tables_directly() await self._create_tables_directly()
await self._initialize_data(reNewDB) await self._initialize_data(reNewDB)
elif not await self._check_tables_exist(conn): return
logger.info("Не все необходимые таблицы существуют. Создаем...")
tables_exist = await self._check_tables_exist(conn)
if not tables_exist:
logger.warning("Не все таблицы существуют. Создаем недостающие...")
await self._create_tables_directly() await self._create_tables_directly()
# Проверяем после создания # 🔥 СИНХРОНИЗАЦИЯ СХЕМЫ
async with self.engine.begin() as conn: logger.info("Синхронизация схемы БД...")
if not await self._check_tables_exist(conn): await self._sync_schema(conn)
raise RuntimeError("Не все необходимые таблицы существуют!")
if reNewDB: if reNewDB:
logger.info("Принудительная загрузка данных...") logger.warning("Принудительная загрузка данных...")
await self._initialize_data(reNewDB) await self._initialize_data(reNewDB)
else:
logger.info("Все необходимые таблицы существуют. Пропускаем...") logger.info("Инициализация БД завершена успешно")
except Exception as e: except Exception as e:
logger.error(f"Инициализация базы завершилась ошибкой: {str(e)}") logger.exception("Инициализация базы завершилась ошибкой")
raise raise
finally: finally:
if self.engine: if self.engine:
await self.engine.dispose() await self.engine.dispose()
# ==========================================================
# CHECK
# ==========================================================
async def _check_tables_exist(self, conn) -> bool: async def _check_tables_exist(self, conn) -> bool:
"""Check if all tables from metadata exist""" """Check if all tables from metadata exist"""
try: try:
@@ -70,12 +84,8 @@ class DatabaseInitializer:
missing_tables = required_tables - set(DatabaseInitializer.existing_tables) missing_tables = required_tables - set(DatabaseInitializer.existing_tables)
if missing_tables: if missing_tables:
logger.warning("Существующие таблицы:")
logger.info(DatabaseInitializer.existing_tables)
logger.warning("Необходимые таблицы:")
logger.info(required_tables)
logger.warning("Отсутствующие таблицы:") logger.warning("Отсутствующие таблицы:")
logger.info(missing_tables) logger.warning(missing_tables)
return False return False
return True return True
@@ -83,62 +93,135 @@ class DatabaseInitializer:
logger.warning(f"Проверка таблиц завершилась ошибкой: {str(e)}") logger.warning(f"Проверка таблиц завершилась ошибкой: {str(e)}")
return False return False
# ==========================================================
# CREATE / DROP
# ==========================================================
async def _create_tables_directly(self): async def _create_tables_directly(self):
"""Create tables directly using SQLAlchemy (bypass Alembic)""" """Create tables directly using SQLAlchemy (bypass Alembic)"""
async with self.engine.begin() as conn: async with self.engine.begin() as conn:
# Создаем все таблицы из метаданных
for table in self.metadata.sorted_tables: for table in self.metadata.sorted_tables:
try: if (
if ( DatabaseInitializer.existing_tables
DatabaseInitializer.existing_tables and table.name in DatabaseInitializer.existing_tables
and table.name in DatabaseInitializer.existing_tables ):
): logger.debug(f"Таблица {table.name} уже существует")
logger.debug( continue
f"Таблица {table.name} уже существует. Пропускаем..."
) logger.info(f"Создаем таблицу: {table.name}")
continue await conn.execute(CreateTable(table))
create_stmt = CreateTable(table)
logger.info(f"Создаем таблицу: {table.name}")
await conn.execute(create_stmt)
except Exception as e:
logger.error(f"Ошибка создания таблицы: {str(e)}")
logger.warning("Все таблицы успешно созданы")
async def _drop_all(self): async def _drop_all(self):
"""Drop all tables""" """Drop all tables"""
async with self.engine.begin() as conn: async with self.engine.begin() as conn:
# Получаем список всех таблиц
existing_tables = await conn.run_sync( existing_tables = await conn.run_sync(
lambda sync_conn: inspect(sync_conn).get_table_names() lambda sync_conn: inspect(sync_conn).get_table_names()
) )
for table in existing_tables: for table in existing_tables:
drop_stmt = text( logger.warning(f"Удаляем таблицу: {table}")
f'DROP TABLE "{table}" CASCADE' await conn.execute(text(f'DROP TABLE "{table}" CASCADE'))
) # Кавычки на случай спец. символов
logger.info(f"Удаляем таблицу: {table}")
await conn.execute(drop_stmt)
logger.warning("Все таблицы успешно удалены") # ==========================================================
# SCHEMA SYNC
# ==========================================================
async def _sync_schema(self, conn):
await self._add_missing_columns(conn)
await self._add_missing_foreign_keys(conn)
async def _add_missing_columns(self, conn):
for table_name, table in self.metadata.tables.items():
db_columns = await conn.run_sync(
lambda sync_conn: {
col["name"] for col in inspect(sync_conn).get_columns(table_name)
}
)
for column in table.columns:
if column.name in db_columns:
continue
ddl = (
f"ALTER TABLE {table_name} "
f"ADD COLUMN {self._compile_column(column)}"
)
logger.warning(f"[ADD COLUMN] {table_name}.{column.name}")
await conn.execute(text(ddl))
async def _add_missing_foreign_keys(self, conn):
for table_name, table in self.metadata.tables.items():
db_fks = await conn.run_sync(
lambda sync_conn: inspect(sync_conn).get_foreign_keys(table_name)
)
db_fk_pairs = {
(
tuple(fk["constrained_columns"]),
fk["referred_table"],
)
for fk in db_fks
}
for fk in table.foreign_keys:
col = fk.parent.name
ref_table = fk.column.table.name
ref_col = fk.column.name
key = ((col,), ref_table)
if key in db_fk_pairs:
continue
constraint_name = f"fk_{table_name}_{col}"
ddl = f"""
ALTER TABLE {table_name}
ADD CONSTRAINT {constraint_name}
FOREIGN KEY ({col})
REFERENCES {ref_table}({ref_col})
"""
if fk.ondelete:
ddl += f" ON DELETE {fk.ondelete}"
logger.warning(f"[ADD FK] {table_name}.{col}{ref_table}.{ref_col}")
await conn.execute(text(ddl))
# ==========================================================
# HELPERS
# ==========================================================
def _compile_column(self, column) -> str:
"""Compile SQLAlchemy Column to SQL"""
ddl = f"{column.name} {column.type.compile(dialect=pg_dialect())}"
if not column.nullable:
ddl += " NULL" # безопасно, NOT NULL позже вручную
if column.server_default is not None:
ddl += f" DEFAULT {column.server_default.arg}"
return ddl
# ==========================================================
# DATA INIT
# ==========================================================
async def _initialize_data(self, waiting: bool = False): async def _initialize_data(self, waiting: bool = False):
"""Initialize required data"""
try: try:
logger.info("Инициализация данных...") logger.info("Инициализация данных...")
logger.warning("Инициализация Прав доступа...")
await AccessLevelHandler.initialize() await AccessLevelHandler.initialize()
logger.warning("Инициализация Пользователей...")
await UserHandler.initialize() await UserHandler.initialize()
logger.warning("Инициализация Туллбоксов...")
await ToolboxHandler.initialize() await ToolboxHandler.initialize()
logger.warning("Инициализация Категорий...")
await CategoryHandler.initialize() await CategoryHandler.initialize()
logger.warning("Инициализация Инструментов...")
await ToolkitHandler.initialize() await ToolkitHandler.initialize()
logger.warning("Инициализация Складов...")
await StocksActions.initialize() await StocksActions.initialize()
logger.info("Данные успешно инициализированы") logger.info("Данные успешно инициализированы")
except Exception as e: except Exception:
logger.error(f"Инициализация данных завершилась ошибкой: {str(e)}") logger.exception("Ошибка инициализации данных")
raise raise
Binary file not shown.
+5 -7
View File
@@ -14,18 +14,16 @@ class Toolkit(Base):
description = Column(Text, nullable=True) description = Column(Text, nullable=True)
specifications = Column(JSONB, default={}) specifications = Column(JSONB, default={})
category_id = Column(Integer, ForeignKey("categories.id", ondelete="CASCADE")) category_id = Column(Integer, ForeignKey("categories.id", ondelete="CASCADE"))
category_data = relationship(
"Category",
cascade="all, delete-orphan",
lazy="joined",
uselist=False,
single_parent=True,
)
image = Column(JSONB) image = Column(JSONB)
quantity_min = Column(Integer, nullable=True) quantity_min = Column(Integer, nullable=True)
quantity_min_extra = Column(Integer, nullable=True) quantity_min_extra = Column(Integer, nullable=True)
external_link = Column(String, nullable=True) external_link = Column(String, nullable=True)
hidden = Column(Boolean, default=False) hidden = Column(Boolean, default=False)
comment_text = Column(Text, nullable=True)
comment_user_id = Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True
)
comment_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.now) created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
refilled_at = Column(DateTime, default=datetime.now) refilled_at = Column(DateTime, default=datetime.now)