diff --git a/config/log.ini b/config/log.ini index 49b8c67..5faff9e 100644 --- a/config/log.ini +++ b/config/log.ini @@ -1,20 +1,38 @@ [loggers] -keys=root +keys=root, tools_app, tools_db, tools_ui [handlers] keys=logconsole [formatters] keys=formatter -encoding=utf-8 [logger_root] level=INFO handlers=logconsole +[logger_tools_app] +level=INFO +handlers=logconsole +qualname=tools.app +propagate=0 + +[logger_tools_db] +level=INFO +handlers=logconsole +qualname=tools.db +propagate=0 + +[logger_tools_ui] +level=INFO +handlers=logconsole +qualname=tools.ui +propagate=0 + + [formatter_formatter] class=colorlog.ColoredFormatter -format=%(log_color)s%(asctime)s: [%(levelname)s] %(message)s [%(module)s.%(funcName)s():%(lineno)d] +format=%(log_color)s%(asctime)s: [%(levelname)s] %(message)s [%(filename)s:%(lineno)d '%(funcName)s'] datefmt=%Y-%m-%d %H:%M:%S [handler_logconsole] diff --git a/db/__init__.py b/db/__init__.py index 7462397..d1cb303 100644 --- a/db/__init__.py +++ b/db/__init__.py @@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker from sqlalchemy.exc import InvalidRequestError from sqlalchemy.pool import NullPool import config -from utils import loggerDB +from utils import logger DATABASE_URL = f"postgresql+asyncpg://{config.DB_USER}:{config.DB_PASS}@{config.DB_HOST}:{config.DB_PORT}/{config.DB_NAME}" @@ -22,43 +22,43 @@ class CRUD: is_lst = isinstance(db_data, list) async with SessionLocal() as db: if is_lst: - loggerDB.info(f"Создаю {len(db_data)} записей") + logger.info(f"Создаю {len(db_data)} записей") try: db.add_all(db_data) except InvalidRequestError: for data in db_data: await db.merge(data) else: - loggerDB.info("Создаю запись") + logger.info("Создаю запись") db.add(db_data) await db.commit() if refresh: if is_lst: - loggerDB.info(f"Обновляю {len(db_data)} записей") + logger.info(f"Обновляю {len(db_data)} записей") for data in db_data: await db.refresh(data) else: - loggerDB.info("Обновляю запись") + logger.info("Обновляю запись") await db.refresh(db_data) - loggerDB.info("Запись создана") + logger.info("Запись создана") return db_data if refresh else None except Exception as e: - loggerDB.error(f"Ошибка создания: {str(e)}", exc_info=True) + logger.error(f"Ошибка создания: {str(e)}", exc_info=True) return None async def read(query, all: bool = False): try: async with SessionLocal() as db: - loggerDB.info(f"Чтение записей. Все: {all}") + logger.info(f"Чтение записей. Все: {all}") results = await db.execute(query) - loggerDB.info(f"Чтение завершено") + logger.info(f"Чтение завершено") return ( results.unique().scalars().all() if all else results.unique().scalars().first() ) except Exception as e: - loggerDB.error(f"Ошибка чтения: {str(e)}", exc_info=True) + logger.error(f"Ошибка чтения: {str(e)}", exc_info=True) return None async def delete(db_data) -> bool: @@ -87,18 +87,18 @@ class CRUD: async with SessionLocal() as db: try: if isinstance(db_data, list): - loggerDB.info(f"Удаляю записей: {len(db_data)}") + logger.info(f"Удаляю записей: {len(db_data)}") for data in db_data: await deleteFromDB(data, db) else: - loggerDB.info("Удаляю запись") + logger.info("Удаляю запись") await deleteFromDB(db_data, db) await db.commit() - loggerDB.info("Запись удалена") + logger.info("Запись удалена") return True except Exception as e: await db.rollback() - loggerDB.error(f"Ошибка удаления: {str(e)}", exc_info=True) + logger.error(f"Ошибка удаления: {str(e)}", exc_info=True) return False async def update(db_data, id, **kwargs): @@ -107,9 +107,9 @@ class CRUD: query = update(db_data).where(db_data.id == id).values(**kwargs) item = await db.execute(query) await db.commit() - loggerDB.info("Запись обновлена") + logger.info("Запись обновлена") return item except Exception as e: await db.rollback() - loggerDB.error(f"Ошибка обновления: {str(e)}", exc_info=True) + logger.error(f"Ошибка обновления: {str(e)}", exc_info=True) return None diff --git a/db/handlers/__init__.py b/db/handlers/__init__.py new file mode 100644 index 0000000..82861c7 --- /dev/null +++ b/db/handlers/__init__.py @@ -0,0 +1,39 @@ +from .user import * +from .access import * +from .toolbox import * +from .categories import * +from .stock import * +from .toolkit import * +from .records import * + + +class InitializeDatabase: + def __init__(self): + self.userHandler = UserHandler() + self.accessHandler = AccessLevelHandler() + self.toolboxHandler = ToolboxHandler() + self.categoryHandler = CategoryHandler() + self.stockHandler = StockHandler() + self.toolkitHandler = ToolkitHandler() + self.stocksRecordHandler = StocksRecordsHandler() + self.servicesRecordHandler = ServiceRecordsHandler() + + async def initialize(self): + await self.accessHandler.initialize() + await self.userHandler.initialize() + await self.toolboxHandler.initialize() + await self.categoryHandler.initialize() + await self.toolkitHandler.initialize() + await self.stockHandler.initialize() + + +__all__ = [ + "UserHandler", + "AccessLevelHandler", + "ToolboxHandler", + "CategoryHandler", + "StockHandler", + "ToolkitHandler", + "StocksRecords", + "ServicesRecords", +] diff --git a/db/handlers/access.py b/db/handlers/access.py index 17345e1..3a1b70b 100644 --- a/db/handlers/access.py +++ b/db/handlers/access.py @@ -2,25 +2,153 @@ from sqlalchemy import select from utils import logger from db import CRUD from db.schemas import AccessLevel +from db.handlers import ServiceRecordsHandler -async def getAccessdata(accessId: int) -> dict: - query = select(AccessLevel).where(AccessLevel.id == accessId) - accessData = await CRUD.read(query) - if not accessData: - logger.error("Уровень доступа не найден") - return {} - return accessData.toDict() +class AccessLevelHandler: + async def add(**kwargs): + title = kwargs.get("title", None) + if not title: + logger.error("Не указано название уровня доступа") + return {} + exists = await CRUD.read(select(AccessLevel).where(AccessLevel.title == title)) + if exists: + logger.error("Уровень доступа с таким названием уже существует") + return {} + try: + logger.info(f"Создание уровня доступа {title}") + user_id = kwargs.pop("user_id", None) + accessData = await AccessLevel(**kwargs).save() + await ServiceRecordsHandler.add( + user_id, {"Добавлен уровень доступа": accessData.toDict()} + ) + except Exception as e: + logger.error(f"Ошибка создания уровня доступа: {str(e)}") + return {} + logger.info(f"Уровень доступа {accessData.title} успешно создан") + return accessData.toDict() + async def get(accessId: int) -> dict: + query = select(AccessLevel).where(AccessLevel.id == accessId) + accessData = await CRUD.read(query) + if not accessData: + logger.error("Уровень доступа не найден") + return {} + return accessData.toDict() -async def editAccessData(accessId: int, **kwargs): - query = select(AccessLevel).where(AccessLevel.id == accessId) - accessData = await CRUD.read(query) - if not accessData: - logger.error("Уровень доступа не найден") - return {} - editedAccessData = await accessData.edit(**kwargs) - logger.info( - f"Уровень доступа {editedAccessData.title} успешно обновлен, изменены данные: {kwargs.keys()}" - ) - return editedAccessData.toDict() + async def edit(accessId: int, **kwargs): + query = select(AccessLevel).where(AccessLevel.id == accessId) + accessData = await CRUD.read(query) + if not accessData: + logger.error("Уровень доступа не найден") + return {} + try: + user_id = kwargs.pop("user_id", None) + editedAccessData = await accessData.edit(**kwargs) + await ServiceRecordsHandler.add( + user_id, {"Обновлен уровень доступа": editedAccessData.toDict()} + ) + except Exception as e: + logger.error(f"Ошибка обновления уровня доступа: {str(e)}") + return {} + logger.info( + f"Уровень доступа {editedAccessData.title} успешно обновлен, изменены данные: {kwargs.keys()}" + ) + return editedAccessData.toDict() + + async def getAll() -> list: + query = select(AccessLevel) + accessLevels = await CRUD.read(query, True) + return ( + [accessLevel.toDict() for accessLevel in accessLevels] + if accessLevels + else [] + ) + + async def delete(accessId: int, user_id: int = None): + query = select(AccessLevel).where(AccessLevel.id == accessId) + accessData = await CRUD.read(query) + if not accessData: + logger.error("Уровень доступа не найден") + return False + try: + title = accessData.title + result = await CRUD.delete(accessData) + await ServiceRecordsHandler.add( + user_id, {"Удален уровень доступа": f"Название: {title}"} + ) + except Exception as e: + logger.error(f"Ошибка удаления уровня доступа: {str(e)}") + return False + logger.info( + f"Уровень доступа {accessData.title} {'успешно удален' if result else 'не удален'}" + ) + return result + + async def initialize(self): + baseAcessLevels = { + "admin": { + "title": "Администратор", + "description": "Администратор. Полный доступ", + "receiving_edit": True, + "refund_request_edit": True, + "refund_request_confirm": True, + "debit_request_edit": True, + "debit_request_confirm": True, + "tools_creation": True, + "tools_registration": True, + "tools_registration_edit": True, + "tools_edit": True, + "tools_delete": True, + "users_creation": True, + "users_edit": True, + "users_disabling": True, + "users_view": True, + "available_own_toolbox": False, + "view_all_toolboxes": True, + "view_requests": True, + "view_services": True, + "access_level_view": True, + "access_level_edit": True, + "manage_toolboxes": True, + }, + "manager": { + "title": "Менеджер", + "description": "Менеджер. Доступ к просмотру и редактированию рабочей информации", + "refund_request_confirm": True, + "debit_request_confirm": True, + "tools_creation": True, + "tools_registration": True, + "tools_edit": True, + "users_disabling": True, + "users_view": True, + "view_all_toolboxes": True, + "view_requests": True, + "view_services": True, + "access_level_view": True, + "manage_toolboxes": True, + }, + "storekeeper": { + "title": "Кладовщик", + "description": "Кладовщик. Доступ к управлению складом", + "refund_request_confirm": True, + "debit_request_confirm": True, + "tools_creation": True, + "tools_registration": True, + "tools_edit": True, + "users_view": True, + "view_requests": True, + "view_all_toolboxes": True, + }, + "employee": { + "title": "Сотрудник", + "description": "Сотрудник. Управление собственной рабочей информацией", + "available_own_toolbox": True, + }, + } + + for accessLevel in baseAcessLevels.values(): + await self.add(**accessLevel) + + logger.info("Уровни доступа успешно инициализированы") + return diff --git a/db/handlers/categories.py b/db/handlers/categories.py new file mode 100644 index 0000000..014adcf --- /dev/null +++ b/db/handlers/categories.py @@ -0,0 +1,92 @@ +from sqlalchemy import select +from utils import logger +from db import CRUD +from db.schemas import Category +from db.handlers import ServiceRecordsHandler + + +class CategoryHandler: + async def add(newCategoryData: dict, user_id: int = None): + title = newCategoryData.get("title", None) + if not title: + logger.error("Не указано название категории") + return {} + query = select(Category).where(Category.title == title) + category = await CRUD.read(query) + if category: + logger.error("Категория с таким названием уже существует") + return {} + try: + newCategory = await Category(**newCategoryData).save() + except Exception as e: + logger.error(f"Ошибка сохранения категории: {str(e)}") + return {} + if not newCategory: + logger.error("Категория не сохранена") + return {} + await ServiceRecordsHandler.add( + user_id, {"Добавлена категория": newCategory.toDict()} + ) + logger.info( + f"Категория {newCategory.title} успешно добавлена, id: {newCategory.id}" + ) + return newCategory.toDict() + + async def edit(categoryId: int, **kwargs): + query = select(Category).where(Category.id == categoryId) + category = await CRUD.read(query) + if not category: + logger.error("Категория не найдена") + return {} + try: + user_id = kwargs.get("user_id", None) + editedCategory = await category.edit(**kwargs) + except Exception as e: + logger.error(f"Ошибка обновления категории: {str(e)}") + return {} + if not editedCategory: + logger.error("Категория не обновлена") + return {} + await ServiceRecordsHandler.add( + user_id, {f"Обновлена категория {category.title}": editedCategory.toDict()} + ) + logger.info(f"Категория {editedCategory.title} успешно обновлена") + return editedCategory.toDict() + + async def getAll() -> list[dict]: + query = select(Category) + categories = await CRUD.read(query, True) + return [category.toDict() for category in categories] if categories else [] + + async def delete(categoryId: int, user_id: int = None): + query = select(Category).where(Category.id == categoryId) + category = await CRUD.read(query) + if not category: + logger.error("Категория не найдена") + return False + try: + categoryTitle = category.title + result = await CRUD.delete(category) + except Exception as e: + logger.error(f"Ошибка удаления категории: {str(e)}") + return False + await ServiceRecordsHandler.add( + user_id, {"Удалена категория": f"Название: {categoryTitle}"} + ) + logger.info( + f"Категория {categoryTitle} {'успешно удалена' if result else 'не удалена'}" + ) + return result + + async def initialize(): + baseCategories = [ + {"title": "Фрезеровка", "description": "Инструмент для фрезерного цеха"}, + {"title": "Токарка", "description": "Инструмент для токарного цеха"}, + {"title": "Слесарка", "description": "Инструмент для слесарного цеха"}, + ] + + for categoryData in baseCategories: + await CategoryHandler.add(categoryData) + + logger.info("Категории успешно созданы") + return diff --git a/db/handlers/records.py b/db/handlers/records.py new file mode 100644 index 0000000..2fad8d5 --- /dev/null +++ b/db/handlers/records.py @@ -0,0 +1,118 @@ +from datetime import datetime, timedelta + +from sqlalchemy import select +from db.schemas import StocksRecords, ServicesRecords +from utils import logger + + +class StocksRecordsHandler: + async def add( + action: str, + source_toolbox_id: int, + target_toolbox_id: int, + toolkit_id: int, + init_user_id: int, + reason: str, + quantity: int, + ): + recordData = { + "action": action, + "source_toolbox_id": source_toolbox_id, + "toolkit_id": toolkit_id, + "init_user_id": init_user_id, + "reason": reason, + "quantity": quantity, + } + if target_toolbox_id: + recordData["target_toolbox_id"] = target_toolbox_id + try: + logger.info(f"Создание записи: {action} от {init_user_id}") + logger.debug(recordData) + record = StocksRecords(**recordData) + await record.save() + logger.info(f"Запись успешно создана, id: {record.id}") + return True + except Exception as e: + logger.error(f"Ошибка создания записи: {str(e)}") + return False + + async def accept(record_id: int, accept_user_id: int): + try: + logger.info(f"Принятие записи {record_id} от {accept_user_id}") + record = await StocksRecords.get(id=record_id) + record.accept_user_id = accept_user_id + record.accepted_at = datetime.now() + await record.save() + logger.info( + f"Запись {record_id} успешно принята {accept_user_id} в {record.accepted_at.strftime('%Y-%m-%d %H:%M:%S')}" + ) + return True + except Exception as e: + logger.error(f"Ошибка принятия записи: {str(e)}") + return False + + async def edit(record_id: int, edit_user_id: int, **kwargs): + try: + logger.info(f"Обновление записи {record_id} от {edit_user_id}") + record = await StocksRecords.get(id=record_id) + record.edit_user_id = edit_user_id + record.edited_at = datetime.now() + edited = {} + for key, value in kwargs.items(): + originalValue = getattr(record, key) + setattr(record, key, value) + edited[key] = {"original": originalValue, "new": value} + record.edited = edited + await record.save() + logger.info( + f"Запись {record_id} успешно обновлена {edit_user_id} в {record.updated_at.strftime('%Y-%m-%d %H:%M:%S')}" + ) + logger.debug(edited) + return True + except Exception as e: + logger.error(f"Ошибка обновления записи: {str(e)}") + return False + + async def get(user_id: int = None, days: int = 30): + from db import CRUD + + try: + if user_id: + userInfo = f"пользователя {user_id} " + query = select(StocksRecords).where( + StocksRecords.init_user_id == user_id, + StocksRecords.created_at > datetime.now() - timedelta(days=days), + ) + else: + userInfo = "всех пользователей " + query = select(StocksRecords).where( + StocksRecords.created_at > datetime.now() - timedelta(days=days), + ) + logger.info(f"Получение всех записей {userInfo}за последние {days} дн.") + records = await CRUD.read(query, True) + logger.info( + f"{len(records)} записей {userInfo}за последние {days} дн. успешно получены" + ) + if len(records) == 0: + return [] + records.sort(key=lambda x: x.created_at, reverse=True) + recordsData = [record.toDict() for record in records] + logger.debug(recordsData) + return recordsData + except Exception as e: + logger.error(f"Ошибка получения записей: {str(e)}") + return False + + +class ServiceRecordsHandler: + async def add(user_id: int, details: dict): + try: + logger.info(f"Создание записи: {user_id}") + logger.debug(details) + record = ServicesRecords(user_id=user_id, details=details) + await record.save() + logger.info(f"Запись успешно создана, id: {record.id}") + return True + except Exception as e: + logger.error(f"Ошибка создания записи: {str(e)}") + return False diff --git a/db/handlers/stock.py b/db/handlers/stock.py new file mode 100644 index 0000000..fa014ca --- /dev/null +++ b/db/handlers/stock.py @@ -0,0 +1,88 @@ +from sqlalchemy import select +from db.schemas import Stock +from utils import logger + + +def filterQuantity(stocksData, filtered): + def filterStock(stock): + if stock.quantity > 0: + return stock + else: + return False + + if isinstance(stocksData, list): + if len(stocksData) == 0: + return [] + stocksData.sort(key=lambda stock: stock.created_at) + filteredStocks = ( + list(filter(filterStock, stocksData)) if filtered else stocksData + ) + return [stock.toDict() for stock in filteredStocks] if filteredStocks else [] + else: + stock = filterStock(stocksData) if filtered else stocksData + if stock: + return stock.toDict() + else: + return {} + + +class StockHandler: + async def add(**kwargs): + newStock = await Stock(**kwargs).save() + logger.info( + f"Новая запись об инструменте {newStock.toolkit_data.title} на складе {newStock.toolbox_data.title} успешно создана" + ) + return newStock.toDict() + + async def getAll(filtered: bool = True): + from db import CRUD + + stocks = await CRUD.read(select(Stock), all=True) + return filterQuantity(stocks, filtered) + + async def get(stockId: int, filtered: bool = True): + from db import CRUD + + stock = await CRUD.read(select(Stock).where(Stock.id == stockId)) + if not stock: + logger.error("Запись об остатках не найдена") + return {} + return filterQuantity(stock, filtered) + + async def getByToolboxId(toolboxId: int, filtered: bool = True): + from db import CRUD + + query = select(Stock).where(Stock.toolbox_id == toolboxId) + stocks = await CRUD.read(query, True) + return filterQuantity(stocks, filtered) + + async def getByToolkitId(toolkitId: int, filtered: bool = True): + from db import CRUD + + query = select(Stock).where(Stock.toolkit_id == toolkitId) + stocks = await CRUD.read(query, True) + return filterQuantity(stocks, filtered) + + async def edit(stockId: int, **kwargs): + from db import CRUD + + stock = await CRUD.read(select(Stock).where(Stock.id == stockId)) + if not stock: + logger.error("Запись об остатках не найдена") + return {} + try: + stockInfo = f"инструмента {stock.toolkit_data.title} на складе {stock.toolbox_data.title}" + editedStock = await stock.edit(**kwargs) + except Exception as e: + logger.error(f"Ошибка обновления записи об остатках: {str(e)}") + return {} + if not editedStock: + logger.error("Запись об остатках не обновлена") + return {} + logger.info( + f"Запись об остатках {stockInfo} успешно обновлена, изменены данные: {kwargs.keys()}" + ) + return editedStock.toDict() + + async def initialize(): + pass diff --git a/db/handlers/toolbox.py b/db/handlers/toolbox.py index f89d5a8..9acf859 100644 --- a/db/handlers/toolbox.py +++ b/db/handlers/toolbox.py @@ -2,18 +2,112 @@ from utils import logger from db import CRUD from db.schemas import Toolbox from sqlalchemy import select +from db.handlers import ServiceRecordsHandler -async def addNewToolbox(toolboxData: dict): - title = toolboxData.get("title", None) - if not title: - logger.error("Не указано Назавание тулбокса") - return {} - query = select(Toolbox).where(Toolbox.title == title) - toolbox = await CRUD.read(query) - if toolbox: - logger.error("Тулбокс с таким названием уже существует") - return {} - newToolbox = await Toolbox(**toolboxData).save() - logger.info(f"Тулбокс {newToolbox.title} успешно создан") - return newToolbox.toDict() +class ToolboxHandler: + async def add(toolboxData: dict, user_id: int = None): + title = toolboxData.get("title", None) + if not title: + logger.error("Не указано Назавание тулбокса") + return {} + query = select(Toolbox).where(Toolbox.title == title) + toolbox = await CRUD.read(query) + if toolbox: + logger.error("Тулбокс с таким названием уже существует") + return {} + + try: + logger.info(f"Создание тулбокса {title}") + newToolbox = await Toolbox(**toolboxData).save() + except Exception as e: + logger.error(f"Ошибка сохранения тулбокса: {str(e)}") + return {} + + if not newToolbox: + logger.error("Тулбокс не сохранен") + return {} + + await ServiceRecordsHandler.add( + user_id, {"Добавлен тулбокс": newToolbox.toDict()} + ) + logger.info(f"Тулбокс {newToolbox.title} успешно создан") + return newToolbox.toDict() + + async def edit(toolboxId: int, **kwargs): + query = select(Toolbox).where(Toolbox.id == toolboxId) + toolbox = await CRUD.read(query) + if not toolbox: + logger.error("Тулбокс не найден") + return {} + try: + user_id = kwargs.pop("user_id", None) + editedToolbox = await toolbox.edit(**kwargs) + except Exception as e: + logger.error(f"Ошибка обновления тулбокса: {str(e)}") + return {} + if not editedToolbox: + logger.error("Тулбокс не обновлен") + return {} + logger.info( + f"Тулбокс {editedToolbox.title} успешно обновлен, изменены данные: {kwargs.keys()}" + ) + await ServiceRecordsHandler.add( + user_id, {f"Обновлен тулбокс {toolbox.title}": editedToolbox.toDict()} + ) + return editedToolbox.toDict() + + async def getAll() -> list: + query = select(Toolbox) + toolboxes = await CRUD.read(query, True) + return [toolbox.toDict() for toolbox in toolboxes] if toolboxes else [] + + async def get(toolboxId: int) -> dict: + query = select(Toolbox).where(Toolbox.id == toolboxId) + toolbox = await CRUD.read(query) + if not toolbox: + logger.error("Тулбокс не найден") + return {} + return toolbox.toDict() + + async def delete(toolboxId: int, user_id: int = None): + query = select(Toolbox).where(Toolbox.id == toolboxId) + toolbox = await CRUD.read(query) + if not toolbox: + logger.error("Тулбокс не найден") + return False + try: + toolboxTitle = toolbox.title + result = await CRUD.delete(toolbox) + except Exception as e: + logger.error(f"Ошибка удаления тулбокса: {str(e)}") + return False + logger.info( + f"Тулбокс {toolboxTitle} {'успешно удален' if result else 'не удален'}" + ) + await ServiceRecordsHandler.add( + user_id, {"Удален тулбокс": f"Название: {toolboxTitle}"} + ) + return result + + async def initialize(): + baseToolsboxes = [ + { + "title": "Стеллаж", + "description": "Основной стеллаж с режущим инструментом", + "owner_id": None, + "monitoring": True, + }, + { + "title": "Шкаф", + "description": "Шкаф для хранения инструментов", + "owner_id": None, + "monitoring": True, + }, + ] + + for toolboxData in baseToolsboxes: + await ToolboxHandler.add(toolboxData) + + logger.info("Тулбоксы успешно созданы") + return diff --git a/db/handlers/toolkit.py b/db/handlers/toolkit.py new file mode 100644 index 0000000..ed8a193 --- /dev/null +++ b/db/handlers/toolkit.py @@ -0,0 +1,288 @@ +from utils import logger, saveImage, safeFilename, deleteImage +from db import CRUD +from db.schemas import Toolkit +from sqlalchemy import select +from db.handlers import ServiceRecordsHandler + + +def handleToolkitImage(imageData, title: str): + title = safeFilename(title) + fileName = f"tools/{title}.png" + if not saveImage(imageData, fileName): + return None + return fileName + + +class ToolkitHandler: + async def add(toolkitData: dict, user_id: int = None): + title = toolkitData.get("title", None) + if not title: + logger.error("Не указано название инструмента") + return {} + + query = select(Toolkit).where(Toolkit.title == title) + toolkit = await CRUD.read(query) + if toolkit: + logger.error("Инструмент с таким названием уже существует") + return {} + + try: + imageDict = {"main": "images/tools/default.png", "additional": []} + if "image" in toolkitData: + imageData = toolkitData.pop("image") + mainImage = imageData.get("main") + imageFileName = handleToolkitImage(mainImage, title) + if imageFileName: + imageDict["main"] = imageFileName + additionalImages = imageData.get("additional", []) + if len(additionalImages) > 0: + for image in additionalImages: + imageFileName = handleToolkitImage(image, title) + if imageFileName: + imageDict["additional"].append(imageFileName) + toolkitData["image"] = imageDict + newToolkit = await Toolkit(**toolkitData).save() + except Exception as e: + logger.error(f"Ошибка сохранения инструмента: {str(e)}") + return {} + + if not newToolkit: + logger.error("Инструмент не сохранен") + return {} + + logger.info(f"Инструмент {newToolkit.title} успешно создан") + await ServiceRecordsHandler.add( + user_id, {"Добавлен инструмент": toolkitData.toDict()} + ) + return newToolkit.toDict() + + async def edit(toolkitId: int, **kwargs): + query = select(Toolkit).where(Toolkit.id == toolkitId) + toolkit = await CRUD.read(query) + if not toolkit: + logger.error("Инструмент не найден") + return {} + try: + if "image" in kwargs: + title = kwargs.get("title", toolkit.title) + + imageData = kwargs.pop("image") + imageDict = {"main": "", "additional": []} + + existImagesList = [toolkit.image.get("main")] + existImagesList.extend(toolkit.image.get("additional")) + + newImagesList = [imageData.get("main")] + newImagesList.extend(imageData.get("additional")) + + for existImage in existImagesList: + if existImage not in newImagesList: + deleteImage(existImage) + + if toolkit.image.get("main") != imageData.get("main"): + if imageData.get("main") in existImagesList: + imageDict["main"] = imageData.get("main") + else: + imageFileName = handleToolkitImage(imageData.get("main"), title) + if imageFileName: + imageDict["main"] = imageFileName + else: + imageDict["main"] = "images/tools/default.png" + + imageDict["additional"].extend(imageData.get("additional")) + + uploadList = imageData.get("upload", []) + if len(uploadList) > 0: + for image in uploadList: + imageFileName = handleToolkitImage(image, title) + if imageFileName: + imageDict["additional"].append(imageFileName) + + kwargs["image"] = imageDict + user_id = kwargs.pop("user_id", None) + editedToolkit = await toolkit.edit(**kwargs) + except Exception as e: + logger.error(f"Ошибка обновления инструмента: {str(e)}") + return {} + + if not editedToolkit: + logger.error("Инструмент не обновлен") + return {} + + logger.info( + f"Инструмент {editedToolkit.title} успешно обновлен, изменены данные: {kwargs.keys()}" + ) + await ServiceRecordsHandler.add( + user_id, {f"Обновлен инструмент {toolkit.title}": editedToolkit.toDict()} + ) + return editedToolkit.toDict() + + async def getAll(): + query = select(Toolkit) + toolkits = await CRUD.read(query, True) + return [toolkit.toDict() for toolkit in toolkits] if toolkits else [] + + async def get(toolkitId: int): + query = select(Toolkit).where(Toolkit.id == toolkitId) + toolkit = await CRUD.read(query) + if not toolkit: + logger.error("Инструмент не найден") + return {} + return toolkit.toDict() + + async def getSeveral(toolkitIds: list[int]) -> list[dict]: + query = select(Toolkit).where(Toolkit.id.in_(toolkitIds)) + toolkits = await CRUD.read(query, True) + return [toolkit.toDict() for toolkit in toolkits] if toolkits else [] + + async def delete(toolkitId: int, user_id: int = None): + query = select(Toolkit).where(Toolkit.id == toolkitId) + toolkit = await CRUD.read(query) + if not toolkit: + logger.error("Инструмент не найден") + return False + try: + toolkitTitle = toolkit.title + result = await CRUD.delete(toolkit) + except Exception as e: + logger.error(f"Ошибка удаления инструмента: {str(e)}") + return False + logger.info( + f"Инструмент {toolkitTitle} {'успешно удален' if result else 'не удален'}" + ) + await ServiceRecordsHandler.add( + user_id, {"Удален инструмент": f"Название: {toolkitTitle}"} + ) + return result + + async def initialize(self): + from .categories import CategoryHandler + + categoriesList = await CategoryHandler.getAll() + categories = {category["title"]: category["id"] for category in categoriesList} + + baseToolkits = [ + { + "title": "Фреза №1", + "description": "Фреза такая сякая этакая #1", + "specifications": { + "Диаметр": "10", + "Длина": "20", + "Ещё что-то": "Ещё столько-то", + }, + "category_id": categories["Фрезеровка"], + "quantity_min": 20, + "quantity_min_extra": 10, + "external_link": "https://nazv.ru", + }, + { + "title": "Фреза №2", + "description": "Фреза такая сякая этакая #2", + "specifications": { + "Диаметр": "10", + "Длина": "20", + "Ещё что-то": "Ещё столько-то", + }, + "category_id": categories["Фрезеровка"], + "quantity_min": 20, + "quantity_min_extra": 10, + "external_link": "https://nazv.ru", + }, + { + "title": "Фреза №3", + "description": "Фреза такая сякая этакая #3", + "specifications": { + "Диаметр": "10", + "Длина": "20", + "Ещё что-то": "Ещё столько-то", + }, + "category_id": categories["Фрезеровка"], + "quantity_min": 20, + "quantity_min_extra": 10, + "external_link": "https://nazv.ru", + }, + { + "title": "Псластина №1", + "description": "Пластина такая сякая этакая #1", + "specifications": { + "Размер": "10", + "Радиус": "0.4", + "Ещё что-то": "Ещё столько-то", + }, + "category_id": categories["Токарка"], + "quantity_min": 20, + "quantity_min_extra": 10, + "external_link": "https://nazv.ru", + }, + { + "title": "Псластина №2", + "description": "Пластина такая сякая этакая #2", + "specifications": { + "Размер": "10", + "Радиус": "0.4", + "Ещё что-то": "Ещё столько-то", + }, + "category_id": categories["Токарка"], + "quantity_min": 20, + "quantity_min_extra": 10, + "external_link": "https://nazv.ru", + }, + { + "title": "Псластина №3", + "description": "Пластина такая сякая этакая #3", + "specifications": { + "Размер": "10", + "Радиус": "0.4", + "Ещё что-то": "Ещё столько-то", + }, + "category_id": categories["Токарка"], + "quantity_min": 20, + "quantity_min_extra": 10, + "external_link": "https://nazv.ru", + }, + { + "title": "Сверло №1", + "description": "Сверло такое сякое этакое #1", + "specifications": { + "Длина": "30", + "Диаметр": "5", + "Ещё что-то": "Ещё столько-то", + }, + "category_id": categories["Слесарка"], + "quantity_min": 20, + "quantity_min_extra": 10, + "external_link": "https://nazv.ru", + }, + { + "title": "Сверло №2", + "description": "Сверло такое сякое этакое #2", + "specifications": { + "Длина": "30", + "Диаметр": "5", + "Ещё что-то": "Ещё столько-то", + }, + "category_id": categories["Слесарка"], + "quantity_min": 20, + "quantity_min_extra": 10, + "external_link": "https://nazv.ru", + }, + { + "title": "Сверло №3", + "description": "Сверло такое сякое этакое #3", + "specifications": { + "Длина": "30", + "Диаметр": "5", + "Ещё что-то": "Ещё столько-то", + }, + "category_id": categories["Слесарка"], + "quantity_min": 20, + "quantity_min_extra": 10, + "external_link": "https://nazv.ru", + }, + ] + + for toolkit in baseToolkits: + await self.add(toolkit) + + logger.info("Базовые инструменты успешно созданы") + return diff --git a/db/handlers/user.py b/db/handlers/user.py index 0a3717a..591a544 100644 --- a/db/handlers/user.py +++ b/db/handlers/user.py @@ -1,81 +1,222 @@ from sqlalchemy import or_, select from db import CRUD from db.handlers.toolbox import addNewToolbox +from utils import logger, pwd_hash, saveImage, safeFilename, deleteImage, pwd_verify from db.schemas import User -from utils import logger, pwd_hash +from db.handlers import ServiceRecordsHandler -async def addNewUser(userData: dict) -> dict: - login = userData.get("login", None) - if not login: - logger.error("Не указан логин") - return {} - userName = userData.get("username") - if not userName: - logger.error("Не указано имя пользователя") - return {} - query = select(User).where(or_(User.login == login, User.username == userName)) - user = await CRUD.read(query) - if user: - logger.error("Пользователь с таким логином или именем уже существует") - return {} - if "access_level_id" not in userData: - logger.error("Не указан уровень доступа") - return {} - if "password" not in userData: - logger.error("Не указан пароль") - return {} - userData["hashed_password"] = pwd_hash(userData.pop("password")) - newUser = await User(**userData).save() - if not newUser: - logger.error("Ошибка сохранения пользователя") - return {} - logger.info(f"Пользователь {newUser.username} успешно добавлен, id: {newUser.id}") - if newUser.available_own_toolbox: - newToolboxData = { - "title": f"Тулбокс {newUser.username}", - "description": f"Оборудование, полученное сотрудником {newUser.username}, под личную материальную ответственность", - "owner_id": newUser.id, - } - newToolbox = await addNewToolbox(newToolboxData) +def handleUserPhoto(imageData, login: str): + login = safeFilename(login) + fileName = f"users/{login}.png" + if not saveImage(imageData, fileName): + return None + return fileName + + +class UserHandler: + async def add(userData: dict, user_id: int = None) -> dict: + login = userData.get("login", None) + if not login: + logger.error("Не указан логин") + return {} + userName = userData.get("username", None) + if not userName: + logger.error("Не указано имя пользователя") + return {} + query = select(User).where(or_(User.login == login, User.username == userName)) + user = await CRUD.read(query) + if user: + logger.error("Пользователь с таким логином или именем уже существует") + return {} + if "access_level_id" not in userData: + logger.error("Не указан уровень доступа") + return {} + if "password" not in userData: + logger.error("Не указан пароль") + return {} + userData["hashed_password"] = pwd_hash(userData.pop("password")) + if "photo" in userData: + imageData = userData.pop("photo") + photoFile = handleUserPhoto(imageData, login) + if photoFile: + userData["photo"] = photoFile + try: + newUser = await User(**userData).save() + except Exception as e: + logger.error(f"Ошибка сохранения пользователя: {str(e)}") + return {} + if not newUser: + logger.error("Пользователь не сохранен") + return {} logger.info( - f"Тулбокс {newToolbox['title']} успешно создан и закреплен за пользователем {newUser.username}" + f"Пользователь {newUser.username} успешно добавлен, id: {newUser.id}" ) - return newUser.toDict() - - -async def editUser(userData: dict) -> dict: - id = userData.get("id", None) - if not id: - logger.error("Не указан id пользователя") - return {} - query = select(User).where(User.id == id) - user = await CRUD.read(query) - if not user: - logger.error("Пользователь с таким id не найден") - return {} - changedUserData = userData.get("changedUserData", {}) - if len(changedUserData.keys()) == 0: - logger.error("Не указаны изменяемые данные") - return {} - if "password" in changedUserData: - userData["hashed_password"] = pwd_hash(changedUserData.pop("password")) - editedUser = await user.edit(**changedUserData) - if not editedUser: - logger.error("Ошибка обновления пользователя") - return {} - logger.info( - f"Пользователь {editedUser.username} успешно обновлен, изменены данные: {changedUserData.keys()}" - ) - if not user.available_own_toolbox: - if editedUser.available_own_toolbox: + if newUser.available_own_toolbox: newToolboxData = { - "title": f"Тулбокс {editedUser.username}", - "description": f"Оборудование, полученное сотрудником {editedUser.username}, под личную материальную ответственность", - "owner_id": editedUser.id, + "title": f"Тулбокс {newUser.username}", + "description": f"Оборудование, полученное сотрудником {newUser.username}, под личную материальную ответственность", + "owner_id": newUser.id, } newToolbox = await addNewToolbox(newToolboxData) logger.info( - f"Тулбокс {newToolbox['title']} успешно создан и закреплен за пользователем {editedUser.username}" + f"Тулбокс {newToolbox['title']} успешно создан и закреплен за пользователем {newUser.username}" ) - return editedUser.toDict() + await ServiceRecordsHandler.add( + user_id, {"Добавлен пользователь": newUser.toDict()} + ) + return newUser.toDict() + + async def edit(userData: dict, user_id: int = None) -> dict: + id = userData.get("id", None) + if not id: + logger.error("Не указан id пользователя") + return {} + query = select(User).where(User.id == id) + user = await CRUD.read(query) + if not user: + logger.error("Пользователь с таким id не найден") + return {} + changedUserData = userData.get("changedUserData", {}) + if len(changedUserData.keys()) == 0: + logger.error("Не указаны изменяемые данные") + return {} + if "password" in changedUserData: + userData["hashed_password"] = pwd_hash(changedUserData.pop("password")) + if "photo" in changedUserData: + imageData = changedUserData.pop("photo") + photoFile = handleUserPhoto(imageData, user.login) + if photoFile: + changedUserData["photo"] = photoFile + deleteImage(user.photo) + try: + editedUser = await user.edit(**changedUserData) + except Exception as e: + logger.error(f"Ошибка обновления пользователя: {str(e)}") + return {} + if not editedUser: + logger.error("Ошибка обновления пользователя") + return {} + logger.info( + f"Пользователь {editedUser.username} успешно обновлен, изменены данные: {changedUserData.keys()}" + ) + if not user.available_own_toolbox: + if editedUser.available_own_toolbox: + newToolboxData = { + "title": f"Тулбокс {editedUser.username}", + "description": f"Оборудование, полученное сотрудником {editedUser.username}, под личную материальную ответственность", + "owner_id": editedUser.id, + } + newToolbox = await addNewToolbox(newToolboxData) + logger.info( + f"Тулбокс {newToolbox['title']} успешно создан и закреплен за пользователем {editedUser.username}" + ) + await ServiceRecordsHandler.add( + user_id, {"Изменен пользователь": editedUser.toDict()} + ) + return editedUser.toDict() + + async def getAll() -> list[dict]: + query = select(User) + users = await CRUD.read(query, True) + return [user.toDict() for user in users] + + async def get(id: int) -> dict: + query = select(User).where(User.id == id) + user = await CRUD.read(query) + if not user: + logger.error("Пользователь с таким id не найден") + return {} + return user.toDict() + + async def delete(id: int, user_id: int = None) -> bool: + query = select(User).where(User.id == id) + user = await CRUD.read(query) + if not user: + logger.error("Пользователь с таким id не найден") + return False + try: + userName = user.username + result = await CRUD.delete(user) + except Exception as e: + logger.error(f"Ошибка удаления пользователя: {str(e)}") + return False + logger.info( + f"Пользователь {userName} {'успешно удален' if result else 'не удален'}" + ) + await ServiceRecordsHandler.add(user_id, {"Удален пользователь": userName}) + return result + + async def deletePhoto(id: int, user_id: int = None) -> bool: + query = select(User).where(User.id == id) + user = await CRUD.read(query) + if not user: + logger.error("Пользователь с таким id не найден") + return False + try: + deleteImage(user.photo) + user.photo = "images/users/default.png" + await user.save() + except Exception as e: + logger.error(f"Ошибка удаления фото пользователя: {str(e)}") + return False + logger.info(f"Фото пользователя {user.username} успешно удалено") + await ServiceRecordsHandler.add( + user_id, {"Удалено фото пользователя": user.username} + ) + return True + + async def auth(login: str, password: str) -> dict: + query = select(User).where(User.login == login) + user = await CRUD.read(query) + if not user: + logger.error("Пользователь с таким логином не найден") + return {} + if not pwd_verify(password, user.hashed_password): + logger.error("Неверный пароль") + return {} + userData = user.toDict() + userData.pop("hashed_password") + await ServiceRecordsHandler.add( + user.id, {"Авторизован пользователь": user.username} + ) + return userData + + async def initialize(self): + from .access import AccessLevelHandler + + accessLevelsList = await AccessLevelHandler.getAll() + acessLevels = { + accessLevel["title"]: accessLevel["id"] for accessLevel in accessLevelsList + } + baseUsers = { + "admin": { + "login": "admin", + "username": "Администратор", + "password": "Alex0172", + "access_level_id": acessLevels["Администратор"], + }, + "manager": { + "login": "manager", + "username": "Менеджер", + "password": "Alex0172", + "access_level_id": acessLevels["Менеджер"], + }, + "storekeeper": { + "login": "storekeeper", + "username": "Кладовщик", + "password": "Alex0172", + "access_level_id": acessLevels["Кладовщик"], + }, + "employee": { + "login": "employee", + "username": "Сотрудник", + "password": "Alex0172", + "access_level_id": acessLevels["Сотрудник"], + }, + } + for user in baseUsers.values(): + await self.add(user) + + logger.info("Инициализация модуля пользователей завершена") + return diff --git a/db/schemas/__init__.py b/db/schemas/__init__.py index 2eb570f..314ee01 100644 --- a/db/schemas/__init__.py +++ b/db/schemas/__init__.py @@ -1,6 +1,144 @@ -from user import * -from access import * -from toolkit import * -from categories import * -from db.schemas.toolbox import * -from stock import * +from .user import * +from .access import * +from .toolkit import * +from .categories import * +from .toolbox import * +from .stock import * + +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 + +# Импортируем все модели +from db import Base +from db.handlers import InitializeDatabase + + +class DatabaseInitializer: + existing_tables: Optional[list[str]] = None + + def __init__(self, database_url: str): + self.database_url = database_url + self.engine: Optional[AsyncEngine] = None + self.metadata = Base.metadata + + async def initialize(self, force: bool = False, reNewDB: bool = False): + """Main database initialization method""" + try: + self.engine = create_async_engine(self.database_url) + + async with self.engine.begin() as conn: + if force: + logger.info("Принудительное удаление и создание баз...") + await self._drop_all() + await self._create_tables_directly() + await self._initialize_data() + elif not await self._check_tables_exist(conn): + logger.info("Не все необходимые таблицы существуют. Создаем...") + await self._create_tables_directly() + + # Проверяем после создания + async with self.engine.begin() as conn: + if not await self._check_tables_exist(conn): + raise RuntimeError("Не все необходимые таблицы существуют!") + + if reNewDB: + logger.info("Принудительная загрузка данных...") + await self._initialize_data() + else: + logger.info("Все необходимые таблицы существуют. Пропускаем...") + + except Exception as e: + logger.error(f"Инициализация базы завершилась ошибкой: {str(e)}") + raise + finally: + if self.engine: + await self.engine.dispose() + + async def _check_tables_exist(self, conn) -> bool: + """Check if all tables from metadata exist""" + try: + DatabaseInitializer.existing_tables = await conn.run_sync( + lambda sync_conn: inspect(sync_conn).get_table_names() + ) + + required_tables = set(self.metadata.tables.keys()) + + if not required_tables: + logger.error("Нет данных о таблицах в метаданных") + return False + + missing_tables = required_tables - set(DatabaseInitializer.existing_tables) + if missing_tables: + logger.warning("Существующие таблицы:") + logger.info(DatabaseInitializer.existing_tables) + logger.warning("Необходимые таблицы:") + logger.info(required_tables) + logger.warning("Отсутствующие таблицы:") + logger.info(missing_tables) + return False + + return True + except Exception as e: + logger.warning(f"Проверка таблиц завершилась ошибкой: {str(e)}") + return False + + async def _create_tables_directly(self): + """Create tables directly using SQLAlchemy (bypass Alembic)""" + async with self.engine.begin() as conn: + # Создаем все таблицы из метаданных + for table in self.metadata.sorted_tables: + try: + if ( + DatabaseInitializer.existing_tables + and table.name in DatabaseInitializer.existing_tables + ): + logger.debug( + f"Таблица {table.name} уже существует. Пропускаем..." + ) + continue + 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): + """Drop all tables""" + async with self.engine.begin() as conn: + # Получаем список всех таблиц + existing_tables = await conn.run_sync( + lambda sync_conn: inspect(sync_conn).get_table_names() + ) + + for table in existing_tables: + drop_stmt = text( + f'DROP TABLE "{table}" CASCADE' + ) # Кавычки на случай спец. символов + logger.info(f"Удаляем таблицу: {table}") + await conn.execute(drop_stmt) + + logger.warning("Все таблицы успешно удалены") + + async def _initialize_data(self): + """Initialize required data""" + try: + logger.info("Инициализация данных...") + await InitializeDatabase.initialize() + logger.info("Данные успешно инициализированы") + except Exception as e: + logger.error(f"Инициализация данных завершилась ошибкой: {str(e)}") + raise + + +__all__ = [ + "User", + "Access", + "Toolbox", + "Category", + "Stock", + "Toolkit", +] \ No newline at end of file diff --git a/db/schemas/access.py b/db/schemas/access.py index 726d2bd..7004bc8 100644 --- a/db/schemas/access.py +++ b/db/schemas/access.py @@ -23,7 +23,7 @@ class AccessLevel(Base): tools_registration = Column(Boolean, default=False) tools_registration_edit = Column(Boolean, default=False) tools_edit = Column(Boolean, default=False) - tools_achievement = Column(Boolean, default=False) + tools_delete = Column(Boolean, default=False) users_creation = Column(Boolean, default=False) users_edit = Column(Boolean, default=False) users_disabling = Column(Boolean, default=False) @@ -31,6 +31,7 @@ class AccessLevel(Base): available_own_toolbox = Column(Boolean, default=False) view_all_toolboxes = Column(Boolean, default=False) view_requests = Column(Boolean, default=False) + view_services = Column(Boolean, default=False) access_level_view = Column(Boolean, default=False) access_level_edit = Column(Boolean, default=False) manage_toolboxes = Column(Boolean, default=False) diff --git a/db/schemas/categories.py b/db/schemas/categories.py index 7306e99..bef42b9 100644 --- a/db/schemas/categories.py +++ b/db/schemas/categories.py @@ -9,7 +9,7 @@ class Category(Base): id = Column(Integer, primary_key=True, index=True) title = Column(String, unique=True, index=True) - description = Column(Text) + description = Column(Text, nullable=True) created_at = Column(DateTime, default=datetime.now) updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) diff --git a/db/schemas/records.py b/db/schemas/records.py new file mode 100644 index 0000000..ef6281b --- /dev/null +++ b/db/schemas/records.py @@ -0,0 +1,75 @@ +from datetime import datetime +from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text +from sqlalchemy.dialects.postgresql import JSONB +from db import Base +import utils + + +class StocksRecords(Base): + __tablename__ = "stocks_records" + + id = Column(Integer, primary_key=True, index=True) + action = Column(String, nullable=False) + source_toolbox_id = Column( + Integer, ForeignKey("toolboxes.id", ondelete="CASCADE"), nullable=False + ) + target_toolbox_id = Column( + Integer, ForeignKey("toolboxes.id", ondelete="CASCADE"), nullable=True + ) + toolkit_id = Column( + Integer, ForeignKey("toolkits.id", ondelete="CASCADE"), nullable=False + ) + init_user_id = Column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + accept_user_id = Column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True + ) + reason = Column(Text, nullable=False) + quantity = Column(Integer, nullable=False) + edit_user_id = Column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True + ) + edited = Column(JSONB, nullable=True) + created_at = Column(DateTime, default=datetime.now) + accepted_at = Column(DateTime, nullable=True) + edited_at = Column(DateTime, nullable=True) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + def __init__(self, **kwargs): + for key, value in kwargs.items(): + setattr(self, key, value) + + def toDict(self): + return utils.toDict(self) + + async def save(self): + from db import CRUD + + return await CRUD.create(self, refresh=True) + + async def edit(self, **kwargs): + from db import CRUD + + return await CRUD.update(StocksRecords, self.id, **kwargs) + + +class ServicesRecords(Base): + __tablename__ = "services_records" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True) + details = Column(JSONB, nullable=False) + created_at = Column(DateTime, default=datetime.now) + + def __init__(self, **kwargs): + for key, value in kwargs.items(): + setattr(self, key, value) + + def toDict(self): + return utils.toDict(self) + + async def save(self): + from db import CRUD + + return await CRUD.create(self, refresh=True) diff --git a/db/schemas/stock.py b/db/schemas/stock.py index 96cfbc6..6ccdf81 100644 --- a/db/schemas/stock.py +++ b/db/schemas/stock.py @@ -1,5 +1,5 @@ from datetime import datetime -from sqlalchemy import Column, DateTime, Float, ForeignKey, Integer +from sqlalchemy import Column, DateTime, Float, ForeignKey, Integer, String from sqlalchemy.orm import relationship from db import Base @@ -24,6 +24,7 @@ class Stock(Base): ) quantity = Column(Integer, nullable=False) price = Column(Float, nullable=False) + placement = Column(String, nullable=True) created_at = Column(DateTime, default=datetime.now) updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) diff --git a/db/schemas/toolbox.py b/db/schemas/toolbox.py index f04aac5..14a1a17 100644 --- a/db/schemas/toolbox.py +++ b/db/schemas/toolbox.py @@ -1,5 +1,5 @@ from datetime import datetime -from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text from db import Base import utils @@ -9,10 +9,11 @@ class Toolbox(Base): id = Column(Integer, primary_key=True, index=True) title = Column(String, unique=True, index=True) - description = Column(Text) + description = Column(Text, nullable=True) owner_id = Column( - Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True + Integer, ForeignKey("users.id", ondelete="CASCADE"), default=None, nullable=True ) + monitoring = Column(Boolean, default=False) created_at = Column(DateTime, default=datetime.now) updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) diff --git a/db/schemas/toolkit.py b/db/schemas/toolkit.py index cd7b849..ebf4c22 100644 --- a/db/schemas/toolkit.py +++ b/db/schemas/toolkit.py @@ -11,14 +11,13 @@ class Toolkit(Base): id = Column(Integer, primary_key=True, index=True) title = Column(String, unique=True, index=True) - description = Column(Text) + description = Column(Text, nullable=True) + specifications = Column(JSONB, default={}) category_id = Column(Integer, ForeignKey("categories.id", ondelete="CASCADE")) category_data = relationship( "Category", cascade="all, delete-orphan", lazy="joined", uselist=False ) - image = Column( - JSONB, default={"main": "images/tools/default.png", "additional": []} - ) + image = Column(JSONB) quantity_min = Column(Integer, nullable=True) quantity_min_extra = Column(Integer, nullable=True) external_link = Column(String, nullable=True) diff --git a/main.py b/main.py index fb04cd0..c771ae3 100644 --- a/main.py +++ b/main.py @@ -1,6 +1,21 @@ +from utils import logger, setLogLevel + + def main(): - print("Hello from tools-stock!") + setLogLevel("WARNING") + logger.info("Приложение запущено") + + logger.info("Получение данных из базы...") + logger.warning({"query": "SELECT * FROM tools", "status": "slow"}) + setLogLevel("INFO") + logger.info("Пользователь открыл страницу настроек") + logger.debug(["Ошибка загрузки интерфейса", 502, "Bad Gateway"]) + setLogLevel("DEBUG") + test_data = {"a": 1, "b": 2, "c": [10, 20]} + logger.info(test_data) + + logger.debug("Приложение завершено") if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 30a3d20..31181b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ requires-python = ">=3.13" dependencies = [ "colorlog>=6.10.1", "passlib>=1.7.4", + "pillow>=12.0.0", "python-dotenv>=1.2.1", "sqlalchemy>=2.0.44", ] diff --git a/static/images/tools/default.png b/static/images/tools/default.png new file mode 100644 index 0000000..cbe4b49 Binary files /dev/null and b/static/images/tools/default.png differ diff --git a/static/images/users/default.png b/static/images/users/default.png new file mode 100644 index 0000000..6cb2d7a Binary files /dev/null and b/static/images/users/default.png differ diff --git a/utils/__init__.py b/utils/__init__.py index 90a4922..d1a5ce9 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -1,3 +1,5 @@ -from my_loggers import * -from for_DB import * -from password import * +from .loggers import * +from .for_DB import * +from .password import * +from .image import * +from .safe_filemane import * \ No newline at end of file diff --git a/utils/__pycache__/__init__.cpython-313.pyc b/utils/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..0d862db Binary files /dev/null and b/utils/__pycache__/__init__.cpython-313.pyc differ diff --git a/utils/__pycache__/for_DB.cpython-313.pyc b/utils/__pycache__/for_DB.cpython-313.pyc new file mode 100644 index 0000000..df9ad02 Binary files /dev/null and b/utils/__pycache__/for_DB.cpython-313.pyc differ diff --git a/utils/__pycache__/image.cpython-313.pyc b/utils/__pycache__/image.cpython-313.pyc new file mode 100644 index 0000000..7c8b140 Binary files /dev/null and b/utils/__pycache__/image.cpython-313.pyc differ diff --git a/utils/__pycache__/loggers.cpython-313.pyc b/utils/__pycache__/loggers.cpython-313.pyc new file mode 100644 index 0000000..7a48cf8 Binary files /dev/null and b/utils/__pycache__/loggers.cpython-313.pyc differ diff --git a/utils/__pycache__/my_loggers.cpython-313.pyc b/utils/__pycache__/my_loggers.cpython-313.pyc new file mode 100644 index 0000000..debd67b Binary files /dev/null and b/utils/__pycache__/my_loggers.cpython-313.pyc differ diff --git a/utils/__pycache__/password.cpython-313.pyc b/utils/__pycache__/password.cpython-313.pyc new file mode 100644 index 0000000..c4a49de Binary files /dev/null and b/utils/__pycache__/password.cpython-313.pyc differ diff --git a/utils/__pycache__/safe_filemane.cpython-313.pyc b/utils/__pycache__/safe_filemane.cpython-313.pyc new file mode 100644 index 0000000..b95f18a Binary files /dev/null and b/utils/__pycache__/safe_filemane.cpython-313.pyc differ diff --git a/utils/image.py b/utils/image.py new file mode 100644 index 0000000..899f5d5 --- /dev/null +++ b/utils/image.py @@ -0,0 +1,60 @@ +from utils.loggers import logger + +# def saveImage(imageData, fileName: str): +# try: +# imageFormat = imageData.split(';')[0].split('/')[1] +# if imageFormat != 'png': +# logger.error(f"Неподдерживаемый формат изображения: {imageFormat}") +# return False +# imageData = imageData.split(';base64,')[1] +# with open(f"static/images/{fileName}", "wb") as f: +# f.write(base64.b64decode(imageData)) +# logger.info(f"Изображение {fileName} успешно сохранено") +# return True +# except Exception as e: +# logger.error(f"Ошибка сохранения изображения: {str(e)}") +# return False + +# UPLOAD_DIR = "uploads" +# os.makedirs(UPLOAD_DIR, exist_ok=True) + + +def saveImage(file_bytes, fileName: str): + from PIL import Image + import io + + # Загружаем изображение через Pillow + try: + img = Image.open(io.BytesIO(file_bytes)) + except Exception: + logger.error("Неподдерживаемый формат изображения") + return False + + # Конвертация (если нужно) + if img.mode not in ("RGB", "RGBA", "P"): + img = img.convert("RGB") + + # Сохранение в выбранный формат + try: + logger.info(f"Сохраняем изображение {fileName}") + img.save(fileName, "PNG") + except Exception as e: + logger.error(f"Ошибка сохранения изображения: {str(e)}") + return False + + logger.info(f"Изображение {fileName} успешно сохранено") + return True + +def deleteImage(fileName: str): + if fileName.endswith("default.png"): + return True + try: + import os + + logger.info(f"Удаляем изображение {fileName}") + os.remove(f"static/images/{fileName}") + logger.info(f"Изображение {fileName} успешно удалено") + return True + except Exception as e: + logger.error(f"Ошибка удаления изображения: {str(e)}") + return False diff --git a/utils/loggers.py b/utils/loggers.py new file mode 100644 index 0000000..f7056c7 --- /dev/null +++ b/utils/loggers.py @@ -0,0 +1,35 @@ +import logging +import logging.config +import json + + +class SmartLogger(logging.Logger): + def _log(self, level, msg, args, exc_info=None, extra=None, stack_info=False, stacklevel=2): + # Увеличиваем stacklevel до 2, чтобы показать реального вызывающего + if isinstance(msg, (dict, list)): + msg = json.dumps(msg, indent=4, ensure_ascii=False) + super()._log(level, msg, args, exc_info, extra, stack_info, stacklevel) + + +logging.setLoggerClass(SmartLogger) +logging.config.fileConfig("config/log.ini") + +logger = logging.getLogger("toolbox") + +def setLogLevel(level: str = ""): + match level: + case "DEBUG": + loggerLevel = logging.DEBUG + case "WARNING": + loggerLevel = logging.WARNING + case "ERROR": + loggerLevel = logging.ERROR + case _: + loggerLevel = logging.INFO + + root_logger = logging.getLogger() + for handler in root_logger.handlers: + handler.setLevel(loggerLevel) + + # Также меняем уровень самого логгера + logger.setLevel(loggerLevel) \ No newline at end of file diff --git a/utils/my_loggers.py b/utils/my_loggers.py deleted file mode 100644 index 4f61700..0000000 --- a/utils/my_loggers.py +++ /dev/null @@ -1,9 +0,0 @@ -import logging -import logging.config - -logging.config.fileConfig("config/log.ini") - -# loggers -logger = logging.getLogger("Tools Stock") -loggerDB = logging.getLogger("DB operations") -logger = logging.getLogger("UI operations") diff --git a/utils/password.py b/utils/password.py index 19c4165..b8327c9 100644 --- a/utils/password.py +++ b/utils/password.py @@ -9,7 +9,7 @@ def pwd_hash(pwd_plant: str) -> str: def pwd_verify(pwd_plant: str, pwd_hash: str) -> bool: - from my_loggers import logger + from utils.loggers import logger try: return pwd_context.verify(pwd_plant, pwd_hash) diff --git a/utils/safe_filemane.py b/utils/safe_filemane.py new file mode 100644 index 0000000..5cb4c11 --- /dev/null +++ b/utils/safe_filemane.py @@ -0,0 +1,39 @@ +import re +import time + +# Простая транслитерация +TRANSLIT_MAP = { + 'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', + 'е': 'e', 'ё': 'yo', 'ж': 'zh', 'з': 'z', 'и': 'i', + 'й': 'y', 'к': 'k', 'л': 'l', 'м': 'm', 'н': 'n', + 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', + 'у': 'u', 'ф': 'f', 'х': 'h', 'ц': 'c', 'ч': 'ch', + 'ш': 'sh', 'щ': 'sch', 'ъ': '', 'ы': 'y', 'ь': '', + 'э': 'e', 'ю': 'yu', 'я': 'ya' +} + +# Заглавные буквы → в ту же латиницу, но без capital (ниже мы в lower() всё равно переводим) +TRANSLIT_MAP.update({k.upper(): v for k, v in TRANSLIT_MAP.items()}) + +def transliterate(text: str) -> str: + return ''.join(TRANSLIT_MAP.get(ch, ch) for ch in text) + +def safeFilename(name: str) -> str: + # 1. Транслитерация кириллицы + name = transliterate(name) + + # 2. Приводим к нижнему регистру + name = name.lower() + + # 3. Заменяем всё, что не буква/цифра, на "_" + name = re.sub(r'[^a-z0-9]+', '_', name) + + # 4. Убираем повторяющиеся "_" + name = re.sub(r'_+', '_', name).strip('_') + + # 5. Ограничиваем длину + name = name[:80] or "file" + + # 6. Добавляем таймштамп + timestamp = int(time.time() * 1000) # миллисекунды + return f"{name}_{timestamp}" \ No newline at end of file diff --git a/uv.lock b/uv.lock index 616a623..3266a11 100644 --- a/uv.lock +++ b/uv.lock @@ -60,6 +60,64 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554, upload-time = "2020-10-08T19:00:49.856Z" }, ] +[[package]] +name = "pillow" +version = "12.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828, upload-time = "2025-10-15T18:24:14.008Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/f2/de993bb2d21b33a98d031ecf6a978e4b61da207bef02f7b43093774c480d/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643", size = 4045493, upload-time = "2025-10-15T18:22:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b6/bc8d0c4c9f6f111a783d045310945deb769b806d7574764234ffd50bc5ea/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4", size = 4120461, upload-time = "2025-10-15T18:22:27.286Z" }, + { url = "https://files.pythonhosted.org/packages/5d/57/d60d343709366a353dc56adb4ee1e7d8a2cc34e3fbc22905f4167cfec119/pillow-12.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399", size = 3576912, upload-time = "2025-10-15T18:22:28.751Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a4/a0a31467e3f83b94d37568294b01d22b43ae3c5d85f2811769b9c66389dd/pillow-12.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5", size = 5249132, upload-time = "2025-10-15T18:22:30.641Z" }, + { url = "https://files.pythonhosted.org/packages/83/06/48eab21dd561de2914242711434c0c0eb992ed08ff3f6107a5f44527f5e9/pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b", size = 4650099, upload-time = "2025-10-15T18:22:32.73Z" }, + { url = "https://files.pythonhosted.org/packages/fc/bd/69ed99fd46a8dba7c1887156d3572fe4484e3f031405fcc5a92e31c04035/pillow-12.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3", size = 6230808, upload-time = "2025-10-15T18:22:34.337Z" }, + { url = "https://files.pythonhosted.org/packages/ea/94/8fad659bcdbf86ed70099cb60ae40be6acca434bbc8c4c0d4ef356d7e0de/pillow-12.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07", size = 8037804, upload-time = "2025-10-15T18:22:36.402Z" }, + { url = "https://files.pythonhosted.org/packages/20/39/c685d05c06deecfd4e2d1950e9a908aa2ca8bc4e6c3b12d93b9cafbd7837/pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e", size = 6345553, upload-time = "2025-10-15T18:22:38.066Z" }, + { url = "https://files.pythonhosted.org/packages/38/57/755dbd06530a27a5ed74f8cb0a7a44a21722ebf318edbe67ddbd7fb28f88/pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344", size = 7037729, upload-time = "2025-10-15T18:22:39.769Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/7e94f4c41d238615674d06ed677c14883103dce1c52e4af16f000338cfd7/pillow-12.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27", size = 6459789, upload-time = "2025-10-15T18:22:41.437Z" }, + { url = "https://files.pythonhosted.org/packages/9c/14/4448bb0b5e0f22dd865290536d20ec8a23b64e2d04280b89139f09a36bb6/pillow-12.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79", size = 7130917, upload-time = "2025-10-15T18:22:43.152Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ca/16c6926cc1c015845745d5c16c9358e24282f1e588237a4c36d2b30f182f/pillow-12.0.0-cp313-cp313-win32.whl", hash = "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098", size = 6302391, upload-time = "2025-10-15T18:22:44.753Z" }, + { url = "https://files.pythonhosted.org/packages/6d/2a/dd43dcfd6dae9b6a49ee28a8eedb98c7d5ff2de94a5d834565164667b97b/pillow-12.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905", size = 7007477, upload-time = "2025-10-15T18:22:46.838Z" }, + { url = "https://files.pythonhosted.org/packages/77/f0/72ea067f4b5ae5ead653053212af05ce3705807906ba3f3e8f58ddf617e6/pillow-12.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a", size = 2435918, upload-time = "2025-10-15T18:22:48.399Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5e/9046b423735c21f0487ea6cb5b10f89ea8f8dfbe32576fe052b5ba9d4e5b/pillow-12.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3", size = 5251406, upload-time = "2025-10-15T18:22:49.905Z" }, + { url = "https://files.pythonhosted.org/packages/12/66/982ceebcdb13c97270ef7a56c3969635b4ee7cd45227fa707c94719229c5/pillow-12.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced", size = 4653218, upload-time = "2025-10-15T18:22:51.587Z" }, + { url = "https://files.pythonhosted.org/packages/16/b3/81e625524688c31859450119bf12674619429cab3119eec0e30a7a1029cb/pillow-12.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b", size = 6266564, upload-time = "2025-10-15T18:22:53.215Z" }, + { url = "https://files.pythonhosted.org/packages/98/59/dfb38f2a41240d2408096e1a76c671d0a105a4a8471b1871c6902719450c/pillow-12.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d", size = 8069260, upload-time = "2025-10-15T18:22:54.933Z" }, + { url = "https://files.pythonhosted.org/packages/dc/3d/378dbea5cd1874b94c312425ca77b0f47776c78e0df2df751b820c8c1d6c/pillow-12.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a", size = 6379248, upload-time = "2025-10-15T18:22:56.605Z" }, + { url = "https://files.pythonhosted.org/packages/84/b0/d525ef47d71590f1621510327acec75ae58c721dc071b17d8d652ca494d8/pillow-12.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe", size = 7066043, upload-time = "2025-10-15T18:22:58.53Z" }, + { url = "https://files.pythonhosted.org/packages/61/2c/aced60e9cf9d0cde341d54bf7932c9ffc33ddb4a1595798b3a5150c7ec4e/pillow-12.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee", size = 6490915, upload-time = "2025-10-15T18:23:00.582Z" }, + { url = "https://files.pythonhosted.org/packages/ef/26/69dcb9b91f4e59f8f34b2332a4a0a951b44f547c4ed39d3e4dcfcff48f89/pillow-12.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef", size = 7157998, upload-time = "2025-10-15T18:23:02.627Z" }, + { url = "https://files.pythonhosted.org/packages/61/2b/726235842220ca95fa441ddf55dd2382b52ab5b8d9c0596fe6b3f23dafe8/pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9", size = 6306201, upload-time = "2025-10-15T18:23:04.709Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3d/2afaf4e840b2df71344ababf2f8edd75a705ce500e5dc1e7227808312ae1/pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b", size = 7013165, upload-time = "2025-10-15T18:23:06.46Z" }, + { url = "https://files.pythonhosted.org/packages/6f/75/3fa09aa5cf6ed04bee3fa575798ddf1ce0bace8edb47249c798077a81f7f/pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47", size = 2437834, upload-time = "2025-10-15T18:23:08.194Z" }, + { url = "https://files.pythonhosted.org/packages/54/2a/9a8c6ba2c2c07b71bec92cf63e03370ca5e5f5c5b119b742bcc0cde3f9c5/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9", size = 4045531, upload-time = "2025-10-15T18:23:10.121Z" }, + { url = "https://files.pythonhosted.org/packages/84/54/836fdbf1bfb3d66a59f0189ff0b9f5f666cee09c6188309300df04ad71fa/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2", size = 4120554, upload-time = "2025-10-15T18:23:12.14Z" }, + { url = "https://files.pythonhosted.org/packages/0d/cd/16aec9f0da4793e98e6b54778a5fbce4f375c6646fe662e80600b8797379/pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a", size = 3576812, upload-time = "2025-10-15T18:23:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b7/13957fda356dc46339298b351cae0d327704986337c3c69bb54628c88155/pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b", size = 5252689, upload-time = "2025-10-15T18:23:15.562Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f5/eae31a306341d8f331f43edb2e9122c7661b975433de5e447939ae61c5da/pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad", size = 4650186, upload-time = "2025-10-15T18:23:17.379Z" }, + { url = "https://files.pythonhosted.org/packages/86/62/2a88339aa40c4c77e79108facbd307d6091e2c0eb5b8d3cf4977cfca2fe6/pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01", size = 6230308, upload-time = "2025-10-15T18:23:18.971Z" }, + { url = "https://files.pythonhosted.org/packages/c7/33/5425a8992bcb32d1cb9fa3dd39a89e613d09a22f2c8083b7bf43c455f760/pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c", size = 8039222, upload-time = "2025-10-15T18:23:20.909Z" }, + { url = "https://files.pythonhosted.org/packages/d8/61/3f5d3b35c5728f37953d3eec5b5f3e77111949523bd2dd7f31a851e50690/pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e", size = 6346657, upload-time = "2025-10-15T18:23:23.077Z" }, + { url = "https://files.pythonhosted.org/packages/3a/be/ee90a3d79271227e0f0a33c453531efd6ed14b2e708596ba5dd9be948da3/pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e", size = 7038482, upload-time = "2025-10-15T18:23:25.005Z" }, + { url = "https://files.pythonhosted.org/packages/44/34/a16b6a4d1ad727de390e9bd9f19f5f669e079e5826ec0f329010ddea492f/pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9", size = 6461416, upload-time = "2025-10-15T18:23:27.009Z" }, + { url = "https://files.pythonhosted.org/packages/b6/39/1aa5850d2ade7d7ba9f54e4e4c17077244ff7a2d9e25998c38a29749eb3f/pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab", size = 7131584, upload-time = "2025-10-15T18:23:29.752Z" }, + { url = "https://files.pythonhosted.org/packages/bf/db/4fae862f8fad0167073a7733973bfa955f47e2cac3dc3e3e6257d10fab4a/pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b", size = 6400621, upload-time = "2025-10-15T18:23:32.06Z" }, + { url = "https://files.pythonhosted.org/packages/2b/24/b350c31543fb0107ab2599464d7e28e6f856027aadda995022e695313d94/pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b", size = 7142916, upload-time = "2025-10-15T18:23:34.71Z" }, + { url = "https://files.pythonhosted.org/packages/0f/9b/0ba5a6fd9351793996ef7487c4fdbde8d3f5f75dbedc093bb598648fddf0/pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0", size = 2523836, upload-time = "2025-10-15T18:23:36.967Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7a/ceee0840aebc579af529b523d530840338ecf63992395842e54edc805987/pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6", size = 5255092, upload-time = "2025-10-15T18:23:38.573Z" }, + { url = "https://files.pythonhosted.org/packages/44/76/20776057b4bfd1aef4eeca992ebde0f53a4dce874f3ae693d0ec90a4f79b/pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6", size = 4653158, upload-time = "2025-10-15T18:23:40.238Z" }, + { url = "https://files.pythonhosted.org/packages/82/3f/d9ff92ace07be8836b4e7e87e6a4c7a8318d47c2f1463ffcf121fc57d9cb/pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1", size = 6267882, upload-time = "2025-10-15T18:23:42.434Z" }, + { url = "https://files.pythonhosted.org/packages/9f/7a/4f7ff87f00d3ad33ba21af78bfcd2f032107710baf8280e3722ceec28cda/pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e", size = 8071001, upload-time = "2025-10-15T18:23:44.29Z" }, + { url = "https://files.pythonhosted.org/packages/75/87/fcea108944a52dad8cca0715ae6247e271eb80459364a98518f1e4f480c1/pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca", size = 6380146, upload-time = "2025-10-15T18:23:46.065Z" }, + { url = "https://files.pythonhosted.org/packages/91/52/0d31b5e571ef5fd111d2978b84603fce26aba1b6092f28e941cb46570745/pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925", size = 7067344, upload-time = "2025-10-15T18:23:47.898Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f4/2dd3d721f875f928d48e83bb30a434dee75a2531bca839bb996bb0aa5a91/pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8", size = 6491864, upload-time = "2025-10-15T18:23:49.607Z" }, + { url = "https://files.pythonhosted.org/packages/30/4b/667dfcf3d61fc309ba5a15b141845cece5915e39b99c1ceab0f34bf1d124/pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4", size = 7158911, upload-time = "2025-10-15T18:23:51.351Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/16cabcc6426c32218ace36bf0d55955e813f2958afddbf1d391849fee9d1/pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52", size = 6408045, upload-time = "2025-10-15T18:23:53.177Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/e29aa0c9c666cf787628d3f0dcf379f4791fba79f4936d02f8b37165bdf8/pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a", size = 7148282, upload-time = "2025-10-15T18:23:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630, upload-time = "2025-10-15T18:23:57.149Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.1" @@ -97,6 +155,7 @@ source = { virtual = "." } dependencies = [ { name = "colorlog" }, { name = "passlib" }, + { name = "pillow" }, { name = "python-dotenv" }, { name = "sqlalchemy" }, ] @@ -105,6 +164,7 @@ dependencies = [ requires-dist = [ { name = "colorlog", specifier = ">=6.10.1" }, { name = "passlib", specifier = ">=1.7.4" }, + { name = "pillow", specifier = ">=12.0.0" }, { name = "python-dotenv", specifier = ">=1.2.1" }, { name = "sqlalchemy", specifier = ">=2.0.44" }, ]