Implement user-specific question numbering and update database schema. Added triggers for automatic question numbering and adjustments upon deletion. Enhanced CRUD operations to manage user_question_number effectively.

This commit is contained in:
2025-09-06 18:35:12 +03:00
parent 50be010026
commit 596a2fa813
111 changed files with 16847 additions and 65 deletions

10
models/__init__.py Normal file
View File

@@ -0,0 +1,10 @@
"""
Модели данных для бота анонимных вопросов
"""
from .user import User
from .question import Question, QuestionStatus
from .user_block import UserBlock
from .user_settings import UserSettings
__all__ = ['User', 'Question', 'QuestionStatus', 'UserBlock', 'UserSettings']

118
models/question.py Normal file
View File

@@ -0,0 +1,118 @@
"""
Модель вопроса
"""
import asyncio
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
from typing import Optional
from config.constants import DEFAULT_QUESTION_PREVIEW_LENGTH, EMPTY_VALUES
class QuestionStatus(Enum):
"""Статусы вопроса"""
PENDING = "pending" # Ожидает ответа
ANSWERED = "answered" # Отвечен
REJECTED = "rejected" # Отклонен
DELETED = "deleted" # Удален
@dataclass
class Question:
"""Модель вопроса"""
id: Optional[int] = None
from_user_id: Optional[int] = None # ID отправителя (может быть None для анонимных)
to_user_id: int = None # ID получателя
message_text: str = "" # Текст вопроса
answer_text: Optional[str] = None # Текст ответа
is_anonymous: bool = True # Анонимный ли вопрос
message_id: Optional[int] = None # ID сообщения в Telegram
created_at: Optional[datetime] = None
answered_at: Optional[datetime] = None
is_read: bool = False # Прочитан ли вопрос
status: QuestionStatus = QuestionStatus.PENDING
user_question_number: Optional[int] = None # Локальный номер вопроса для пользователя
# Lazy loading атрибуты
_from_user: Optional['User'] = None
_to_user: Optional['User'] = None
_user_loader: Optional[callable] = None
@property
def is_answered(self) -> bool:
"""Проверка, отвечен ли вопрос"""
return self.status == QuestionStatus.ANSWERED
@property
def is_pending(self) -> bool:
"""Проверка, ожидает ли вопрос ответа"""
return self.status == QuestionStatus.PENDING
@classmethod
def _parse_datetime(cls, date_str) -> Optional[datetime]:
"""Безопасный парсинг datetime из строки"""
if not date_str or date_str in EMPTY_VALUES:
return None
try:
return datetime.fromisoformat(date_str)
except (ValueError, TypeError):
return None
@classmethod
async def _parse_datetime_async(cls, date_str) -> Optional[datetime]:
"""Асинхронный безопасный парсинг datetime из строки"""
if not date_str or date_str in EMPTY_VALUES:
return None
try:
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, datetime.fromisoformat, date_str)
except (ValueError, TypeError):
return None
def mark_as_answered(self, answer_text: str):
"""Отметить вопрос как отвеченный"""
self.answer_text = answer_text
self.status = QuestionStatus.ANSWERED
self.answered_at = datetime.now()
def mark_as_rejected(self):
"""Отметить вопрос как отклоненный"""
self.status = QuestionStatus.REJECTED
self.answered_at = datetime.now()
def mark_as_deleted(self):
"""Отметить вопрос как удаленный"""
self.status = QuestionStatus.DELETED
self.answered_at = datetime.now()
self.user_question_number = None # Удаленные вопросы не имеют номера
def set_user_loader(self, loader_func: callable):
"""Установка функции для загрузки пользователей"""
self._user_loader = loader_func
async def get_from_user(self) -> Optional['User']:
"""Lazy loading автора вопроса"""
if self._from_user is None and self.from_user_id and self._user_loader:
self._from_user = await self._user_loader(self.from_user_id)
return self._from_user
async def get_to_user(self) -> Optional['User']:
"""Lazy loading получателя вопроса"""
if self._to_user is None and self.to_user_id and self._user_loader:
self._to_user = await self._user_loader(self.to_user_id)
return self._to_user
def get_question_preview(self, max_length: int = DEFAULT_QUESTION_PREVIEW_LENGTH) -> str:
"""Получить превью вопроса"""
if len(self.message_text) <= max_length:
return self.message_text
return self.message_text[:max_length] + "..."
def get_display_number(self) -> int:
"""Получить номер вопроса для отображения (приоритет user_question_number)"""
return self.user_question_number if self.user_question_number is not None else self.id

92
models/user.py Normal file
View File

@@ -0,0 +1,92 @@
"""
Модель пользователя
"""
import asyncio
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
from config.constants import EMPTY_VALUES
def escape_html(text: str) -> str:
"""Экранирование HTML символов"""
if not text:
return ""
return (text
.replace('&', '&amp;')
.replace('<', '&lt;')
.replace('>', '&gt;')
.replace('"', '&quot;')
.replace("'", '&#x27;'))
@dataclass
class User:
"""Модель пользователя бота"""
id: Optional[int] = None
telegram_id: int = None
username: Optional[str] = None
first_name: str = ""
last_name: Optional[str] = None
chat_id: int = None
profile_link: str = ""
is_active: bool = True
is_superuser: bool = False
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
banned_until: Optional[datetime] = None
ban_reason: Optional[str] = None
@property
def full_name(self) -> str:
"""Полное имя пользователя"""
parts = []
if self.first_name:
parts.append(escape_html(self.first_name))
if self.last_name:
parts.append(escape_html(self.last_name))
return ' '.join(parts) if parts else 'Неизвестно'
@property
def display_name(self) -> str:
"""Отображаемое имя пользователя"""
if self.username:
return f"@{escape_html(self.username)}"
return escape_html(self.full_name)
@property
def is_banned(self) -> bool:
"""Проверка, забанен ли пользователь"""
if not self.banned_until:
return False
return datetime.now() < self.banned_until
@classmethod
def _parse_datetime(cls, date_str) -> Optional[datetime]:
"""Безопасный парсинг datetime из строки"""
if not date_str or date_str in EMPTY_VALUES:
return None
try:
return datetime.fromisoformat(date_str)
except (ValueError, TypeError):
return None
@classmethod
async def _parse_datetime_async(cls, date_str) -> Optional[datetime]:
"""Асинхронный безопасный парсинг datetime из строки"""
if not date_str or date_str in EMPTY_VALUES:
return None
try:
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, datetime.fromisoformat, date_str)
except (ValueError, TypeError):
return None
def update_timestamp(self):
"""Обновление времени последнего обновления"""
self.updated_at = datetime.now()

18
models/user_block.py Normal file
View File

@@ -0,0 +1,18 @@
"""
Модель блокировки пользователя
"""
from datetime import datetime
from typing import Optional
from dataclasses import dataclass
@dataclass
class UserBlock:
"""Модель блокировки пользователя"""
id: Optional[int] = None
blocker_id: int = None # ID пользователя, который заблокировал
blocked_id: int = None # ID заблокированного пользователя
created_at: Optional[datetime] = None

37
models/user_settings.py Normal file
View File

@@ -0,0 +1,37 @@
"""
Модель настроек пользователя
"""
from datetime import datetime
from typing import Optional
from dataclasses import dataclass
@dataclass
class UserSettings:
"""Модель настроек пользователя"""
id: Optional[int] = None
user_id: int = None
allow_questions: bool = True # Разрешить вопросы
notify_new_questions: bool = True # Уведомления о новых вопросах
notify_answers: bool = True # Уведомления об ответах
language: str = 'ru' # Язык интерфейса
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
@classmethod
def _parse_datetime(cls, date_str) -> Optional[datetime]:
"""Безопасный парсинг datetime из строки"""
if not date_str or date_str in ['0', '']:
return None
try:
return datetime.fromisoformat(date_str)
except (ValueError, TypeError):
return None
def update_timestamp(self):
"""Обновление времени последнего обновления"""
self.updated_at = datetime.now()