domovoy_bot/database/models.py
Admin 54e8cea522 📄 feat: Отправка документов (PDF, DOCX, XLSX...) через веб-панель
Новый функционал:
- Загрузка документов через веб-панель (10 форматов)
- Отправка в любую тему форума по расписанию
- Поддержка: PDF, DOC, DOCX, XLS, XLSX, PPT, PPTX, TXT, ZIP, RAR
- Отправка в чат и/или всем верифицированным жильцам
- UI: превью документа, кнопка удаления

Изменения:
- database/models.py: document_file_id, document_name поля
- database/migrate_v3_4_documents.py: миграция БД
- web/app.py: API endpoint с загрузкой документов
- services/scheduler.py: отправка документов по расписанию
- web/templates/scheduled_posts.html: UI для загрузки документов

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-13 21:51:52 +04:00

765 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Модели базы данных
"""
from datetime import datetime
from sqlalchemy import (
Column, Integer, BigInteger, String, Boolean, DateTime,
Text, Float, ForeignKey, Index
)
from sqlalchemy.orm import relationship, declarative_base
Base = declarative_base()
def get_level_by_rating(rating: int) -> str:
"""Определить уровень по рейтингу"""
if rating >= 1000:
return 'Лидер'
elif rating >= 500:
return 'Активист'
elif rating >= 100:
return 'Сосед'
else:
return 'Новичок'
class User(Base):
"""Пользователи (жильцы)"""
__tablename__ = 'users'
user_id = Column(BigInteger, primary_key=True)
username = Column(String(255), nullable=True)
first_name = Column(String(255), nullable=True)
last_name = Column(String(255), nullable=True)
apartment = Column(String(10), nullable=True) # Номер квартиры
phone = Column(String(20), nullable=True) # Телефон жильца
verified = Column(Boolean, default=False) # Прошёл верификацию
verification_date = Column(DateTime, nullable=True)
join_date = Column(DateTime, default=datetime.utcnow)
message_count = Column(Integer, default=0)
is_banned = Column(Boolean, default=False)
is_admin = Column(Boolean, default=False)
last_seen = Column(DateTime, nullable=True)
# Рейтинг активности
rating = Column(Integer, default=0) # Баллы активности
level = Column(String(50), default='Новичок') # Новичок, Сосед, Активист, Лидер
# Для детекта шпионов
spy_score = Column(Integer, default=0) # 0-100
spy_flags = Column(Text, nullable=True) # JSON список флагов
# Для анти-спама
spam_warning_count = Column(Integer, default=0) # Количество предупреждений за спам
# Для анти-спам лимитов
is_muted = Column(Boolean, default=False) # Замучен ли
mute_until = Column(DateTime, nullable=True) # До когда мут
rate_limit_warnings = Column(Integer, default=0) # Предупреждения за лимиты
# Для детекта мультиаккаунтов
multicount_score = Column(Integer, default=0) # Баллы подозрительности
suspected_device_id = Column(String(100), nullable=True) # ID устройства (если есть)
suspected_alt_accounts = Column(Text, nullable=True) # JSON список подозрительных аккаунтов
# Для рейтинга токсичности
toxicity_score = Column(Integer, default=0) # Средний балл токсичности
toxic_messages_count = Column(Integer, default=0) # Количество токсичных сообщений
complaints_count = Column(Integer, default=0) # Количество жалоб
# Связи
messages = relationship('Message', back_populates='user', lazy='dynamic')
__table_args__ = (
Index('ix_users_apartment', 'apartment'),
Index('ix_users_verified', 'verified'),
Index('ix_users_spy_score', 'spy_score'),
Index('ix_users_rating', 'rating'),
)
def __repr__(self):
return f"<User {self.user_id} ({self.apartment})>"
@property
def full_name(self):
parts = [self.first_name, self.last_name]
return ' '.join(filter(None, parts)) or self.username or f'user{self.user_id}'
def update_level(self):
"""Обновить уровень на основе рейтинга"""
self.level = get_level_by_rating(self.rating)
class Message(Base):
"""Сообщения в чате"""
__tablename__ = 'messages'
id = Column(Integer, primary_key=True, autoincrement=True)
message_id = Column(BigInteger)
user_id = Column(BigInteger, ForeignKey('users.user_id'))
chat_id = Column(BigInteger)
text = Column(Text, nullable=True)
timestamp = Column(DateTime, default=datetime.utcnow)
reply_to_message_id = Column(BigInteger, nullable=True)
forward_from = Column(String(255), nullable=True)
topic = Column(String(50), nullable=True) # Тема сообщения (лифты, парковка, уборка...)
# Связи
user = relationship('User', back_populates='messages')
__table_args__ = (
Index('ix_messages_chat_time', 'chat_id', 'timestamp'),
Index('ix_messages_user_time', 'user_id', 'timestamp'),
Index('ix_messages_topic', 'topic'),
)
def __repr__(self):
return f"<Message {self.message_id} by {self.user_id}>"
class Service(Base):
"""Контакты служб и мастеров"""
__tablename__ = 'services'
service_id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(255), nullable=False)
category = Column(String(100), nullable=False) # emergency, plumber, electrician, etc.
phone = Column(String(20), nullable=False)
description = Column(Text, nullable=True)
image_path = Column(String(500), nullable=True) # Путь к картинке
rating = Column(Float, default=0.0)
reviews_count = Column(Integer, default=0)
verified_by_admin = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow)
__table_args__ = (
Index('ix_services_category', 'category'),
)
def __repr__(self):
return f"<Service {self.name} ({self.category})>"
class VerificationRequest(Base):
"""Заявки на верификацию"""
__tablename__ = 'verification_requests'
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(BigInteger, ForeignKey('users.user_id'), nullable=False)
apartment = Column(String(10), nullable=False)
photo_file_id = Column(String(255), nullable=True) # Фото квитанции
comment = Column(Text, nullable=True)
status = Column(String(50), default='pending') # pending, approved, rejected
created_at = Column(DateTime, default=datetime.utcnow)
reviewed_at = Column(DateTime, nullable=True)
reviewed_by = Column(BigInteger, nullable=True) # Admin ID
reject_reason = Column(Text, nullable=True)
__table_args__ = (
Index('ix_verification_status', 'status'),
)
def __repr__(self):
return f"<VerificationRequest {self.user_id} apt={self.apartment}>"
class SpyLog(Base):
"""Логи детекта шпионов"""
__tablename__ = 'spy_logs'
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(BigInteger, ForeignKey('users.user_id'), nullable=False)
spy_score = Column(Integer, nullable=False)
flags = Column(Text, nullable=False) # JSON список
detected_at = Column(DateTime, default=datetime.utcnow)
action_taken = Column(String(50), nullable=True) # alerted, warned, banned
__table_args__ = (
Index('ix_spy_logs_user_time', 'user_id', 'detected_at'),
)
def __repr__(self):
return f"<SpyLog {self.user_id} score={self.spy_score}>"
class Announcement(Base):
"""Объявления для жильцов"""
__tablename__ = 'announcements'
id = Column(Integer, primary_key=True, autoincrement=True)
title = Column(String(255), nullable=False)
text = Column(Text, nullable=False)
category = Column(String(50), default='general') # general, emergency, utility
created_by = Column(BigInteger, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
is_pinned = Column(Boolean, default=False)
is_active = Column(Boolean, default=True)
__table_args__ = (
Index('ix_announcements_category', 'category'),
Index('ix_announcements_active', 'is_active'),
)
def __repr__(self):
return f"<Announcement {self.title}>"
class Poll(Base):
"""Опросы/голосования жильцов"""
__tablename__ = 'polls'
poll_id = Column(Integer, primary_key=True, autoincrement=True)
message_id = Column(BigInteger, nullable=False) # ID сообщения с опросом в чате
chat_id = Column(BigInteger, nullable=False) # ID чата
question = Column(Text, nullable=False) # Вопрос опроса
options = Column(Text, nullable=False) # JSON: ["Вариант 1", "Вариант 2", ...]
votes = Column(Text, nullable=True) # JSON: {"0": [user_id1, user_id2], "1": [user_id3]}
created_by = Column(BigInteger, ForeignKey('users.user_id'), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
is_active = Column(Boolean, default=True) # Активен ли опрос
closed_at = Column(DateTime, nullable=True) # Дата закрытия
# Связи
creator = relationship('User', backref='polls')
__table_args__ = (
Index('ix_polls_chat_active', 'chat_id', 'is_active'),
Index('ix_polls_created_at', 'created_at'),
)
def __repr__(self):
return f"<Poll {self.poll_id}: {self.question[:30]}>"
@property
def total_votes(self) -> int:
"""Общее количество голосов"""
import json
if not self.votes:
return 0
votes_data = json.loads(self.votes)
return sum(len(v) for v in votes_data.values())
def get_results(self) -> list:
"""Получить результаты в формате [(вариант, голоса, процент), ...]"""
import json
if not self.votes:
options_list = json.loads(self.options)
return [(opt, 0, 0.0) for opt in options_list]
options_list = json.loads(self.options)
votes_data = json.loads(self.votes)
total = self.total_votes
results = []
for i, option in enumerate(options_list):
vote_count = len(votes_data.get(str(i), []))
percent = (vote_count / total * 100) if total > 0 else 0.0
results.append((option, vote_count, round(percent, 1)))
return results
class SpamWarning(Base):
"""Предупреждения за спам/запрещённые ссылки"""
__tablename__ = 'spam_warnings'
warning_id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(BigInteger, ForeignKey('users.user_id'), nullable=False)
message_text = Column(Text, nullable=True) # Текст сообщения
reason = Column(Text, nullable=True) # Причина предупреждения
created_at = Column(DateTime, default=datetime.utcnow)
is_active = Column(Boolean, default=True) # Активно ли предупреждение
# Связи
user = relationship('User', backref='spam_warnings')
__table_args__ = (
Index('ix_spam_warnings_user', 'user_id'),
Index('ix_spam_warnings_active', 'is_active'),
)
def __repr__(self):
return f"<SpamWarning {self.user_id}: {self.reason[:30]}>"
class MessageRate(Base):
"""Счётчик сообщений для rate limiting"""
__tablename__ = 'message_rates'
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(BigInteger, ForeignKey('users.user_id'), nullable=False)
timestamp = Column(DateTime, default=datetime.utcnow)
# Связи
user = relationship('User', backref='message_rates')
__table_args__ = (
Index('ix_message_rates_user', 'user_id'),
Index('ix_message_rates_time', 'timestamp'),
)
def __repr__(self):
return f"<MessageRate {self.user_id} @ {self.timestamp}>"
class Schedule(Base):
"""Расписание отключений (вода, свет, отопление)"""
__tablename__ = 'schedules'
schedule_id = Column(Integer, primary_key=True, autoincrement=True)
schedule_type = Column(String(50), nullable=False) # water, electricity, heating, etc.
title = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
start_time = Column(DateTime, nullable=False)
end_time = Column(DateTime, nullable=False)
created_by = Column(BigInteger, ForeignKey('users.user_id'), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
reminder_sent = Column(Boolean, default=False) # Отправлено ли напоминание
is_active = Column(Boolean, default=True)
# Связи
creator = relationship('User', backref='schedules')
__table_args__ = (
Index('ix_schedules_type', 'schedule_type'),
Index('ix_schedules_time', 'start_time', 'end_time'),
Index('ix_schedules_active', 'is_active'),
)
def __repr__(self):
return f"<Schedule {self.title} ({self.start_time})>"
@property
def duration_hours(self) -> float:
"""Длительность отключения в часах"""
if self.start_time and self.end_time:
delta = self.end_time - self.start_time
return round(delta.total_seconds() / 3600, 1)
return 0
def get_type_emoji(self) -> str:
"""Получить emoji типа отключения"""
emojis = {
'water': '💧',
'electricity': '',
'heating': '🔥',
'gas': '🔶',
'elevator': '🛗',
'general': '📋'
}
return emojis.get(self.schedule_type, '📋')
class Ad(Base):
"""Объявления жильцов (куплю, продам, отдам, найду)"""
__tablename__ = 'ads'
ad_id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(BigInteger, ForeignKey('users.user_id'), nullable=False)
category = Column(String(50), nullable=False) # sell, buy, give, lost, found, service
title = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
price = Column(Integer, nullable=True) # Цена в рублях
phone = Column(String(50), nullable=True) # Контактный телефон
message_id = Column(BigInteger, nullable=True) # ID сообщения в чате
created_at = Column(DateTime, default=datetime.utcnow)
expires_at = Column(DateTime, nullable=True) # Дата истечения (через 7 дней)
is_active = Column(Boolean, default=True)
is_moderated = Column(Boolean, default=False) # Проверено админом
# Связи
user = relationship('User', backref='ads')
__table_args__ = (
Index('ix_ads_category', 'category'),
Index('ix_ads_active', 'is_active'),
Index('ix_ads_created_at', 'created_at'),
)
def __repr__(self):
return f"<Ad {self.title} by {self.user_id}>"
@property
def days_left(self) -> int:
"""Осталось дней до истечения"""
if self.expires_at:
delta = self.expires_at - datetime.utcnow()
return max(0, delta.days)
return 7
def get_category_emoji(self) -> str:
"""Получить emoji категории"""
emojis = {
'sell': '💰',
'buy': '🛒',
'give': '🎁',
'lost': '😿',
'found': '😺',
'service': '🔧'
}
return emojis.get(self.category, '📋')
def get_category_name(self) -> str:
"""Получить название категории"""
names = {
'sell': 'Продам',
'buy': 'Куплю',
'give': 'Отдам',
'lost': 'Потеряно',
'found': 'Нашлось',
'service': 'Услуга'
}
return names.get(self.category, 'Общее')
class PaymentReminder(Base):
"""Напоминания о платежах ЖКХ"""
__tablename__ = 'payment_reminders'
reminder_id = Column(Integer, primary_key=True, autoincrement=True)
title = Column(String(255), nullable=False) # Название платежа
amount = Column(Integer, nullable=True) # Сумма в рублях
due_date = Column(DateTime, nullable=False) # Дата оплаты (число месяца)
description = Column(Text, nullable=True) # Описание
payment_link = Column(String(500), nullable=True) # Ссылка на оплату
is_recurring = Column(Boolean, default=True) # Ежемесячный платёж
created_by = Column(BigInteger, ForeignKey('users.user_id'), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
is_active = Column(Boolean, default=True)
last_reminder_sent = Column(DateTime, nullable=True) # Последнее напоминание
# Связи
creator = relationship('User', backref='payment_reminders')
__table_args__ = (
Index('ix_payment_reminders_due_date', 'due_date'),
Index('ix_payment_reminders_active', 'is_active'),
)
def __repr__(self):
return f"<PaymentReminder {self.title} (due: {self.due_date})>"
def get_days_until_due(self, current_day: int = None) -> int:
"""Сколько дней до срока оплаты"""
from datetime import datetime
if current_day is None:
current_day = datetime.utcnow().day
due_day = self.due_date.day if hasattr(self.due_date, 'day') else self.due_date
if due_day >= current_day:
return due_day - current_day
else:
return (30 - current_day) + due_day
class ProfileHistory(Base):
"""История изменений профиля пользователя"""
__tablename__ = 'profile_history'
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(BigInteger, ForeignKey('users.user_id'), nullable=False)
field_name = Column(String(50), nullable=False) # username, first_name, last_name, apartment
old_value = Column(Text, nullable=True) # Старое значение
new_value = Column(Text, nullable=True) # Новое значение
changed_at = Column(DateTime, default=datetime.utcnow)
# Связи
user = relationship('User', backref='profile_history')
__table_args__ = (
Index('ix_profile_history_user', 'user_id'),
Index('ix_profile_history_field', 'field_name'),
Index('ix_profile_history_time', 'changed_at'),
)
def __repr__(self):
return f"<ProfileHistory {self.user_id}: {self.field_name}>"
class Achievement(Base):
"""Достижения пользователей"""
__tablename__ = 'achievements'
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(BigInteger, ForeignKey('users.user_id'), nullable=False)
achievement_type = Column(String(50), nullable=False) # Тип достижения
earned_at = Column(DateTime, default=datetime.utcnow) # Когда получено
achievement_data = Column(Text, nullable=True) # JSON с доп. данными
# Связи
user = relationship('User', backref='achievements_list')
__table_args__ = (
Index('ix_achievements_user', 'user_id'),
Index('ix_achievements_type', 'achievement_type'),
)
def __repr__(self):
return f"<Achievement {self.user_id}: {self.achievement_type}>"
class Thank(Base):
"""Благодарности пользователей"""
__tablename__ = 'thanks'
id = Column(Integer, primary_key=True, autoincrement=True)
from_user_id = Column(BigInteger, ForeignKey('users.user_id'), nullable=False) # Кто поблагодарил
to_user_id = Column(BigInteger, ForeignKey('users.user_id'), nullable=False) # Кого поблагодарили
message = Column(Text, nullable=True) # Текст благодарности
created_at = Column(DateTime, default=datetime.utcnow)
# Связи
from_user = relationship('User', foreign_keys=[from_user_id], backref='thanks_given')
to_user = relationship('User', foreign_keys=[to_user_id], backref='thanks_received')
__table_args__ = (
Index('ix_thanks_from', 'from_user_id'),
Index('ix_thanks_to', 'to_user_id'),
Index('ix_thanks_created', 'created_at'),
)
def __repr__(self):
return f"<Thank {self.from_user_id}{self.to_user_id}>"
class Event(Base):
"""События дома (собрания, субботники, праздники)"""
__tablename__ = 'events'
id = Column(Integer, primary_key=True, autoincrement=True)
title = Column(String(255), nullable=False) # Название события
description = Column(Text, nullable=True) # Описание
event_type = Column(String(50), default='general') # Тип: meeting, subbotnik, holiday
event_date = Column(DateTime, nullable=False) # Дата и время события
location = Column(String(255), nullable=True) # Место проведения
created_by = Column(BigInteger, ForeignKey('users.user_id'), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
is_active = Column(Boolean, default=True)
reminder_sent = Column(Boolean, default=False) # Напоминание отправлено
# Связи
creator = relationship('User', backref='created_events')
__table_args__ = (
Index('ix_events_date', 'event_date'),
Index('ix_events_type', 'event_type'),
Index('ix_events_active', 'is_active'),
)
def __repr__(self):
return f"<Event {self.title} ({self.event_date})>"
class EventParticipant(Base):
"""Участники событий"""
__tablename__ = 'event_participants'
id = Column(Integer, primary_key=True, autoincrement=True)
event_id = Column(Integer, ForeignKey('events.id'), nullable=False)
user_id = Column(BigInteger, ForeignKey('users.user_id'), nullable=False)
joined_at = Column(DateTime, default=datetime.utcnow)
# Связи
event = relationship('Event', backref='participants_list')
user = relationship('User', backref='events_participating')
__table_args__ = (
Index('ix_event_participants_event', 'event_id'),
Index('ix_event_participants_user', 'user_id'),
Index('ix_event_participants_unique', 'event_id', 'user_id', unique=True),
)
def __repr__(self):
return f"<EventParticipant event={self.event_id} user={self.user_id}>"
class InitiativeGroup(Base):
"""Участники инициативной группы"""
__tablename__ = 'initiative_group'
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(BigInteger, ForeignKey('users.user_id'), nullable=False, unique=True)
role = Column(String(50), default='member') # admin, member, coordinator
joined_at = Column(DateTime, default=datetime.utcnow)
is_active = Column(Boolean, default=True)
notes = Column(Text, nullable=True) # Заметки о роли
# Связи
user = relationship('User', backref='initiative_membership')
__table_args__ = (
Index('ix_initiative_group_user', 'user_id'),
Index('ix_initiative_group_active', 'is_active'),
)
def __repr__(self):
return f"<InitiativeGroup user={self.user_id} role={self.role}>"
class ScheduledPost(Base):
"""Запланированные посты для отложенленной публикации"""
__tablename__ = 'scheduled_posts'
id = Column(Integer, primary_key=True, autoincrement=True)
text = Column(Text, nullable=False) # Текст поста
photo_file_id = Column(String(255), nullable=True) # ID фото в Telegram
document_file_id = Column(String(255), nullable=True) # ID документа (PDF, DOCX...)
document_name = Column(String(255), nullable=True) # Имя файла документа
topic_id = Column(BigInteger, nullable=True) # ID темы форума (message_thread_id)
topic_name = Column(String(50), nullable=True) # Название темы ('general', 'memes'...)
recipients = Column(String(50), default='chat_only') # Получатели: 'chat_only', 'all_verified', 'all_and_chat'
scheduled_time = Column(DateTime, nullable=False) # Когда отправить
status = Column(String(20), default='pending') # 'pending', 'sent', 'failed', 'cancelled'
created_by = Column(BigInteger, nullable=True) # ID админа создавшего пост
created_at = Column(DateTime, default=datetime.utcnow)
sent_at = Column(DateTime, nullable=True) # Когда фактически отправлено
error_message = Column(Text, nullable=True) # Текст ошибки если failed
message_id = Column(BigInteger, nullable=True) # ID отправленного сообщения в Telegram
__table_args__ = (
Index('ix_scheduled_posts_time', 'scheduled_time'),
Index('ix_scheduled_posts_status', 'status'),
Index('ix_scheduled_posts_topic', 'topic_name'),
)
def __repr__(self):
return f"<ScheduledPost {self.id} at {self.scheduled_time} status={self.status}>"
@property
def is_due(self) -> bool:
"""Пора ли отправлять пост"""
from datetime import datetime
return datetime.utcnow() >= self.scheduled_time and self.status == 'pending'
def get_topic_emoji(self) -> str:
"""Получить emoji темы"""
emojis = {
'general': '💬',
'ads': '📢',
'memes': '😂',
'meetings': '🏛️',
'kgm': '🗑️',
'lostfound': '🔍',
'auto': '🚗',
'phones': '📞',
'links': '🔗',
'uo': '📝',
}
return emojis.get(self.topic_name, '📝')
def get_status_emoji(self) -> str:
"""Получить emoji статуса"""
emojis = {
'pending': '',
'sent': '',
'failed': '',
'cancelled': '🚫',
}
return emojis.get(self.status, '')
class Broadcast(Base):
"""Умные рассылки с отслеживанием прочтения"""
__tablename__ = 'broadcasts'
id = Column(Integer, primary_key=True, autoincrement=True)
message_id = Column(BigInteger, nullable=True) # ID сообщения в Telegram (в чате)
chat_id = Column(BigInteger, nullable=True) # ID чата
text = Column(Text, nullable=False) # Текст рассылки
photo_file_id = Column(String(255), nullable=True) # ID фото
sent_at = Column(DateTime, nullable=True) # Когда отправлено
sent_by = Column(BigInteger, nullable=True) # ID админа
total_sent = Column(Integer, default=0) # Сколько получателей
has_read_button = Column(Boolean, default=True) # Показывать кнопку "Прочитал"
is_reminder_sent = Column(Boolean, default=False) # Было ли напоминание непрочитавшим
created_at = Column(DateTime, default=datetime.utcnow)
broadcast_type = Column(String(50), default='manual') # 'manual', 'digest', 'emergency'
__table_args__ = (
Index('ix_broadcasts_sent_at', 'sent_at'),
Index('ix_broadcasts_type', 'broadcast_type'),
)
def __repr__(self):
return f"<Broadcast {self.id} at {self.sent_at}>"
@property
def read_count(self) -> int:
"""Сколько прочитали (запрос к БД)"""
# Это свойство вычисляется через relationship
return 0
@property
def unread_count(self) -> int:
"""Сколько НЕ прочитали"""
return self.total_sent - self.read_count
class BroadcastRead(Base):
"""Кто прочитал рассылку"""
__tablename__ = 'broadcast_reads'
id = Column(Integer, primary_key=True, autoincrement=True)
broadcast_id = Column(BigInteger, ForeignKey('broadcasts.id'), nullable=False)
user_id = Column(BigInteger, ForeignKey('users.user_id'), nullable=False)
read_at = Column(DateTime, default=datetime.utcnow)
# Связи
broadcast = relationship('Broadcast', backref='reads')
user = relationship('User', backref='broadcast_reads')
__table_args__ = (
Index('ix_broadcast_read_broadcast', 'broadcast_id'),
Index('ix_broadcast_read_user', 'user_id'),
Index('ix_broadcast_read_unique', 'broadcast_id', 'user_id', unique=True),
)
def __repr__(self):
return f"<BroadcastRead broadcast={self.broadcast_id} user={self.user_id}>"
class Digest(Base):
"""Еженедельные дайджесты"""
__tablename__ = 'digests'
id = Column(Integer, primary_key=True, autoincrement=True)
period_start = Column(DateTime, nullable=False) # Начало периода
period_end = Column(DateTime, nullable=False) # Конец периода
week_number = Column(Integer, nullable=False) # Номер недели
year = Column(Integer, nullable=False) # Год
content = Column(Text, nullable=True) # JSON с данными дайджеста
digest_text = Column(Text, nullable=True) # Сформированный текст
status = Column(String(50), default='draft') # 'draft', 'pending_approval', 'approved', 'rejected', 'sent'
message_id = Column(BigInteger, nullable=True) # ID сообщения в Telegram
created_at = Column(DateTime, default=datetime.utcnow)
approved_at = Column(DateTime, nullable=True)
approved_by = Column(BigInteger, nullable=True) # Admin ID
sent_at = Column(DateTime, nullable=True)
rejected_by = Column(BigInteger, nullable=True)
rejection_reason = Column(Text, nullable=True) # Причина отклонения
__table_args__ = (
Index('ix_digests_week', 'year', 'week_number'),
Index('ix_digests_status', 'status'),
Index('ix_digests_period', 'period_start', 'period_end'),
)
def __repr__(self):
return f"<Digest #{self.week_number}/{self.year} status={self.status}>"
@property
def status_emoji(self) -> str:
emojis = {
'draft': '📝',
'pending_approval': '',
'approved': '',
'rejected': '',
'sent': '📨',
}
return emojis.get(self.status, '')
@property
def period_label(self) -> str:
"""Красивая метка периода"""
return f"{self.period_start.strftime('%d.%m')} - {self.period_end.strftime('%d.%m.%Y')}"