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:
10
models/__init__.py
Normal file
10
models/__init__.py
Normal 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
118
models/question.py
Normal 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
92
models/user.py
Normal 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('&', '&')
|
||||
.replace('<', '<')
|
||||
.replace('>', '>')
|
||||
.replace('"', '"')
|
||||
.replace("'", '''))
|
||||
|
||||
|
||||
@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
18
models/user_block.py
Normal 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
37
models/user_settings.py
Normal 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()
|
||||
|
||||
Reference in New Issue
Block a user