Files
School/database/db.py
T

527 lines
24 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.
import pg8000
import os
from typing import Optional, Dict, List, Any
from datetime import datetime, timedelta
import logging
logger = logging.getLogger(__name__)
class Database:
def __init__(self):
self.conn = None
self.cursor = None
async def connect(self):
try:
self.conn = pg8000.connect(
host=os.getenv('DB_HOST', 'localhost'),
port=int(os.getenv('DB_PORT', '5432')),
database=os.getenv('DB_NAME', 'max_bot'),
user=os.getenv('DB_USER', 'max_user'),
password=os.getenv('DB_PASSWORD', 'max_password')
)
self.cursor = self.conn.cursor()
logger.info("✅ Подключение к PostgreSQL установлено (pg8000)")
await self.init_tables()
await self.init_schools()
except Exception as e:
logger.error(f"❌ Ошибка подключения к БД: {e}")
raise
async def disconnect(self):
if self.cursor:
self.cursor.close()
if self.conn:
self.conn.close()
logger.info("Соединение с PostgreSQL закрыто")
async def init_tables(self):
try:
sql_path = os.path.join(os.path.dirname(__file__), 'init.sql')
with open(sql_path, 'r') as f:
sql = f.read()
self.cursor.execute(sql)
self.conn.commit()
logger.info("✅ Таблицы созданы/проверены")
except Exception as e:
logger.error(f"❌ Ошибка инициализации таблиц: {e}")
raise
async def init_schools(self):
try:
self.cursor.execute("SELECT COUNT(*) FROM schools")
count = self.cursor.fetchone()[0]
if count == 0:
schools = [
('101', 'Средняя общеобразовательная школа №101', 'ул. Ленина, 15', 'Москва'),
('102', 'Средняя общеобразовательная школа №102', 'ул. Гагарина, 10', 'Москва'),
('103', 'Средняя общеобразовательная школа №103', 'ул. Мира, 25', 'Москва'),
('104', 'Гимназия №104', 'ул. Садовая, 8', 'Москва'),
('105', 'Лицей №105', 'ул. Тверская, 12', 'Москва'),
('201', 'Гимназия №201', 'ул. Пушкина, 5', 'Санкт-Петербург'),
('202', 'Средняя школа №202', 'ул. Невский проспект, 20', 'Санкт-Петербург'),
('203', 'Средняя школа №203', 'ул. Московская, 15', 'Санкт-Петербург'),
('301', 'Лицей №301', 'ул. Советская, 8', 'Казань'),
('302', 'Средняя школа №302', 'ул. Ленина, 42', 'Казань'),
('401', 'Средняя школа №401', 'ул. Мира, 12', 'Новосибирск'),
('402', 'Гимназия №402', 'ул. Кирова, 7', 'Новосибирск'),
('501', 'Школа №501 с углубленным изучением математики', 'ул. Лермонтова, 3', 'Екатеринбург'),
('502', 'Гимназия №502', 'ул. Свердлова, 10', 'Екатеринбург'),
('601', 'Средняя общеобразовательная школа №601', 'ул. Садовая, 25', 'Нижний Новгород'),
('602', 'Лицей №602', 'ул. Горького, 8', 'Нижний Новгород'),
('701', 'Гимназия №701', 'ул. Чехова, 10', 'Ростов-на-Дону'),
('702', 'Средняя школа №702', 'ул. Пушкинская, 15', 'Ростов-на-Дону'),
]
for school in schools:
self.cursor.execute("""
INSERT INTO schools (school_number, school_name, address, city)
VALUES (%s, %s, %s, %s)
""", school)
self.conn.commit()
logger.info(f"✅ Добавлено {len(schools)} школ в справочник")
except Exception as e:
logger.error(f"❌ Ошибка инициализации школ: {e}")
async def get_parent(self, user_id: int) -> Optional[Dict]:
self.cursor.execute("SELECT * FROM parents WHERE user_id = %s", (user_id,))
row = self.cursor.fetchone()
if row:
columns = [desc[0] for desc in self.cursor.description]
return dict(zip(columns, row))
return None
async def create_parent(self, user_id: int, username: str) -> Dict:
self.cursor.execute("""
INSERT INTO parents (user_id, username)
VALUES (%s, %s)
ON CONFLICT (user_id) DO UPDATE
SET username = %s, updated_at = CURRENT_TIMESTAMP
RETURNING *
""", (user_id, username, username))
self.conn.commit()
row = self.cursor.fetchone()
columns = [desc[0] for desc in self.cursor.description]
return dict(zip(columns, row))
async def get_all_schools(self) -> List[Dict]:
self.cursor.execute("SELECT * FROM schools ORDER BY school_number")
rows = self.cursor.fetchall()
columns = [desc[0] for desc in self.cursor.description]
return [dict(zip(columns, row)) for row in rows]
async def get_school_by_number(self, school_number: str) -> Optional[Dict]:
self.cursor.execute("SELECT * FROM schools WHERE school_number = %s", (school_number,))
row = self.cursor.fetchone()
if row:
columns = [desc[0] for desc in self.cursor.description]
return dict(zip(columns, row))
return None
async def get_school_by_id(self, school_id: int) -> Optional[Dict]:
self.cursor.execute("SELECT * FROM schools WHERE id = %s", (school_id,))
row = self.cursor.fetchone()
if row:
columns = [desc[0] for desc in self.cursor.description]
return dict(zip(columns, row))
return None
async def search_schools(self, query: str) -> List[Dict]:
self.cursor.execute("""
SELECT * FROM schools
WHERE school_number ILIKE %s OR school_name ILIKE %s
ORDER BY school_number
""", (f'%{query}%', f'%{query}%'))
rows = self.cursor.fetchall()
columns = [desc[0] for desc in self.cursor.description]
return [dict(zip(columns, row)) for row in rows]
async def get_children(self, parent_id: int) -> List[Dict]:
self.cursor.execute("""
SELECT
c.id, c.parent_id, c.card_number, c.student_name, c.school_id,
s.school_number, s.school_name, s.address, s.city,
c.created_at, c.updated_at
FROM children c
JOIN schools s ON c.school_id = s.id
WHERE c.parent_id = %s
ORDER BY c.id
""", (parent_id,))
rows = self.cursor.fetchall()
columns = [desc[0] for desc in self.cursor.description]
return [dict(zip(columns, row)) for row in rows]
async def get_child_by_card_number(self, card_number: str) -> Optional[Dict]:
self.cursor.execute("""
SELECT
c.id, c.parent_id, c.card_number, c.student_name, c.school_id,
s.school_number, s.school_name
FROM children c
JOIN schools s ON c.school_id = s.id
WHERE c.card_number = %s
""", (card_number,))
row = self.cursor.fetchone()
if row:
columns = [desc[0] for desc in self.cursor.description]
return dict(zip(columns, row))
return None
async def add_child(self, parent_id: int, card_number: str, student_name: str, school_number: str) -> Dict:
parent = await self.get_parent(parent_id)
if not parent:
raise ValueError("Родитель не найден. Сначала отправьте /start")
school = await self.get_school_by_number(school_number)
if not school:
raise ValueError(f"Школа с номером '{school_number}' не найдена в справочнике")
self.cursor.execute("""
INSERT INTO children (parent_id, card_number, student_name, school_id)
VALUES (%s, %s, %s, %s)
ON CONFLICT (parent_id, card_number) DO UPDATE
SET student_name = %s, school_id = %s, updated_at = CURRENT_TIMESTAMP
RETURNING *
""", (parent_id, card_number, student_name, school['id'], student_name, school['id']))
self.conn.commit()
row = self.cursor.fetchone()
columns = [desc[0] for desc in self.cursor.description]
child = dict(zip(columns, row))
child['school_number'] = school['school_number']
child['school_name'] = school['school_name']
return child
async def delete_child(self, parent_id: int, child_id: int) -> bool:
self.cursor.execute("DELETE FROM children WHERE parent_id = %s AND id = %s", (parent_id, child_id))
self.conn.commit()
return self.cursor.rowcount > 0
async def get_access_event_by_id(self, event_id: str) -> Optional[Dict]:
self.cursor.execute("SELECT * FROM access_events WHERE event_id = %s", (event_id,))
row = self.cursor.fetchone()
if row:
columns = [desc[0] for desc in self.cursor.description]
return dict(zip(columns, row))
return None
async def save_access_event(self, event_id: str, user_id: str, resource_number: int,
event_time: datetime, device_id: str = None,
access_zone_id1: str = None, access_zone_id2: str = None) -> Dict:
self.cursor.execute("""
INSERT INTO access_events
(event_id, user_id, device_id, resource_number, access_zone_id1, access_zone_id2, event_time)
VALUES (%s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (event_id) DO NOTHING
RETURNING *
""", (event_id, user_id, device_id, resource_number, access_zone_id1, access_zone_id2, event_time))
self.conn.commit()
row = self.cursor.fetchone()
if row:
columns = [desc[0] for desc in self.cursor.description]
return dict(zip(columns, row))
existing = await self.get_access_event_by_id(event_id)
return existing if existing else {}
async def mark_event_processed(self, event_id: int) -> bool:
self.cursor.execute("UPDATE access_events SET processed = TRUE WHERE id = %s", (event_id,))
self.conn.commit()
return self.cursor.rowcount > 0
async def save_notification(self, parent_id: int, child_id: int, message: str, event_type: str) -> Dict:
self.cursor.execute("""
INSERT INTO notifications (parent_id, child_id, message, event_type)
VALUES (%s, %s, %s, %s)
RETURNING *
""", (parent_id, child_id, message, event_type))
self.conn.commit()
row = self.cursor.fetchone()
columns = [desc[0] for desc in self.cursor.description]
return dict(zip(columns, row))
async def get_unread_notifications(self, parent_id: int) -> List[Dict]:
self.cursor.execute("""
SELECT * FROM notifications
WHERE parent_id = %s AND is_read = FALSE
ORDER BY created_at DESC
""", (parent_id,))
rows = self.cursor.fetchall()
columns = [desc[0] for desc in self.cursor.description]
return [dict(zip(columns, row)) for row in rows]
async def mark_notification_as_read(self, notification_id: int) -> bool:
self.cursor.execute("UPDATE notifications SET is_read = TRUE WHERE id = %s", (notification_id,))
self.conn.commit()
return self.cursor.rowcount > 0
# --- ПОДПИСОЧНАЯ СИСТЕМА ---
async def get_subscription(self, parent_id: int) -> Optional[Dict]:
self.cursor.execute("SELECT * FROM subscriptions WHERE parent_id = %s", (parent_id,))
row = self.cursor.fetchone()
if row:
columns = [desc[0] for desc in self.cursor.description]
return dict(zip(columns, row))
return None
async def create_subscription(self, parent_id: int) -> Dict:
trial_end = datetime.now() + timedelta(days=7)
self.cursor.execute("""
INSERT INTO subscriptions (parent_id, status, trial_start, trial_end)
VALUES (%s, %s, %s, %s)
ON CONFLICT (parent_id) DO UPDATE
SET status = 'trial', trial_start = CURRENT_TIMESTAMP, trial_end = %s, updated_at = CURRENT_TIMESTAMP
RETURNING *
""", (parent_id, 'trial', datetime.now(), trial_end, trial_end))
self.conn.commit()
row = self.cursor.fetchone()
columns = [desc[0] for desc in self.cursor.description]
return dict(zip(columns, row))
async def check_subscription_status(self, parent_id: int) -> Dict:
subscription = await self.get_subscription(parent_id)
if not subscription:
subscription = await self.create_subscription(parent_id)
now = datetime.now()
status = subscription.get('status')
if status == 'trial':
trial_end = subscription.get('trial_end')
if trial_end and trial_end < now:
await self.update_subscription_status(parent_id, 'expired')
subscription['status'] = 'expired'
elif status == 'active':
paid_until = subscription.get('paid_until')
if paid_until and paid_until < now:
await self.update_subscription_status(parent_id, 'expired')
subscription['status'] = 'expired'
return subscription
async def update_subscription_status(self, parent_id: int, status: str) -> bool:
self.cursor.execute("""
UPDATE subscriptions
SET status = %s, updated_at = CURRENT_TIMESTAMP
WHERE parent_id = %s
""", (status, parent_id))
self.conn.commit()
return self.cursor.rowcount > 0
async def extend_subscription(self, parent_id: int, months: int = 1) -> Dict:
try:
subscription = await self.get_subscription(parent_id)
if not subscription:
subscription = await self.create_subscription(parent_id)
current_end = subscription.get('paid_until')
if current_end and current_end > datetime.now():
paid_until = current_end + timedelta(days=30 * months)
else:
paid_until = datetime.now() + timedelta(days=30 * months)
self.cursor.execute("""
UPDATE subscriptions
SET status = 'active', paid_until = %s, last_payment_date = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE parent_id = %s
RETURNING *
""", (paid_until, parent_id))
self.conn.commit()
row = self.cursor.fetchone()
columns = [desc[0] for desc in self.cursor.description]
return dict(zip(columns, row))
except Exception as e:
self.conn.rollback()
logger.error(f"❌ Ошибка продления подписки: {e}")
raise
# --- ПЛАТЕЖИ ---
async def record_payment(self, parent_id: int, amount: float, transaction_id: str, status: str = 'pending') -> Dict:
"""Запись платежа с проверкой на дубликат"""
try:
# Сначала проверяем, существует ли платеж
existing = await self.get_payment_by_transaction_id(transaction_id)
if existing:
if existing.get('status') != status:
await self.update_payment_status(transaction_id, status)
return await self.get_payment_by_transaction_id(transaction_id)
return existing
self.cursor.execute("""
INSERT INTO payments (parent_id, amount, transaction_id, status)
VALUES (%s, %s, %s, %s)
RETURNING *
""", (parent_id, amount, transaction_id, status))
self.conn.commit()
row = self.cursor.fetchone()
columns = [desc[0] for desc in self.cursor.description]
return dict(zip(columns, row))
except Exception as e:
# Если ошибка уникальности, пытаемся получить существующий платеж
if 'duplicate key' in str(e).lower():
self.conn.rollback()
existing = await self.get_payment_by_transaction_id(transaction_id)
if existing:
return existing
self.conn.rollback()
logger.error(f"❌ Ошибка записи платежа: {e}")
raise
async def get_payment_by_transaction_id(self, transaction_id: str) -> Optional[Dict]:
self.cursor.execute("SELECT * FROM payments WHERE transaction_id = %s", (transaction_id,))
row = self.cursor.fetchone()
if row:
columns = [desc[0] for desc in self.cursor.description]
return dict(zip(columns, row))
return None
async def update_payment_status(self, transaction_id: str, status: str) -> bool:
try:
self.cursor.execute("""
UPDATE payments
SET status = %s
WHERE transaction_id = %s
""", (status, transaction_id))
self.conn.commit()
return self.cursor.rowcount > 0
except Exception as e:
self.conn.rollback()
logger.error(f"❌ Ошибка обновления статуса платежа: {e}")
return False
async def get_expiring_subscriptions(self, days_threshold: int = 3) -> List[Dict]:
"""
Получение подписок, у которых до окончания осталось days_threshold дней или меньше.
"""
now = datetime.now()
threshold_date = now + timedelta(days=days_threshold)
self.cursor.execute("""
SELECT
s.*,
p.username,
p.user_id
FROM subscriptions s
JOIN parents p ON s.parent_id = p.user_id
WHERE s.status IN ('trial', 'active')
AND s.trial_end <= %s
AND s.trial_end > %s
UNION
SELECT
s.*,
p.username,
p.user_id
FROM subscriptions s
JOIN parents p ON s.parent_id = p.user_id
WHERE s.status = 'active'
AND s.paid_until <= %s
AND s.paid_until > %s
""", (threshold_date, now, threshold_date, now))
rows = self.cursor.fetchall()
if rows:
columns = [desc[0] for desc in self.cursor.description]
return [dict(zip(columns, row)) for row in rows]
return []
async def get_subscription_notification_sent(self, parent_id: int, days_left: int) -> bool:
"""Проверка, отправлялось ли уже уведомление об окончании подписки для данного количества дней"""
self.cursor.execute("""
SELECT 1 FROM subscription_notifications
WHERE parent_id = %s AND days_left = %s
""", (parent_id, days_left))
return self.cursor.fetchone() is not None
async def get_expiring_subscriptions(self, days_threshold: int = 3) -> List[Dict]:
"""
Получение подписок, у которых до окончания осталось days_threshold дней или меньше.
"""
now = datetime.now()
threshold_date = now + timedelta(days=days_threshold)
self.cursor.execute("""
SELECT
s.*,
p.username,
p.user_id
FROM subscriptions s
JOIN parents p ON s.parent_id = p.user_id
WHERE s.status IN ('trial', 'active')
AND s.trial_end <= %s
AND s.trial_end > %s
UNION
SELECT
s.*,
p.username,
p.user_id
FROM subscriptions s
JOIN parents p ON s.parent_id = p.user_id
WHERE s.status = 'active'
AND s.paid_until <= %s
AND s.paid_until > %s
""", (threshold_date, now, threshold_date, now))
rows = self.cursor.fetchall()
if rows:
columns = [desc[0] for desc in self.cursor.description]
return [dict(zip(columns, row)) for row in rows]
return []
async def get_subscription_notification_sent(self, parent_id: int, days_left: int) -> bool:
"""Проверка, отправлялось ли уже уведомление об окончании подписки для данного количества дней"""
self.cursor.execute("""
SELECT 1 FROM subscription_notifications
WHERE parent_id = %s AND days_left = %s
""", (parent_id, days_left))
return self.cursor.fetchone() is not None
async def mark_subscription_notification_sent(self, parent_id: int, days_left: int) -> Dict:
"""Отметить, что уведомление об окончании подписки отправлено"""
self.cursor.execute("""
INSERT INTO subscription_notifications (parent_id, days_left)
VALUES (%s, %s)
ON CONFLICT (parent_id, days_left) DO NOTHING
RETURNING *
""", (parent_id, days_left))
self.conn.commit()
row = self.cursor.fetchone()
if row:
columns = [desc[0] for desc in self.cursor.description]
return dict(zip(columns, row))
return {}
async def clean_old_notifications(self, days: int = 30):
"""Очистка старых записей уведомлений (старше days дней)"""
self.cursor.execute("""
DELETE FROM subscription_notifications
WHERE notified_at < CURRENT_DATE - INTERVAL '%s days'
""", (days,))
self.conn.commit()
return self.cursor.rowcount
async def mark_subscription_notification_sent(self, parent_id: int, days_left: int) -> Dict:
"""Отметить, что уведомление об окончании подписки отправлено"""
self.cursor.execute("""
INSERT INTO subscription_notifications (parent_id, days_left)
VALUES (%s, %s)
ON CONFLICT (parent_id, days_left) DO NOTHING
RETURNING *
""", (parent_id, days_left))
self.conn.commit()
row = self.cursor.fetchone()
if row:
columns = [desc[0] for desc in self.cursor.description]
return dict(zip(columns, row))
return {}
async def clean_old_notifications(self, days: int = 30):
"""Очистка старых записей уведомлений (старше days дней)"""
self.cursor.execute("""
DELETE FROM subscription_notifications
WHERE notified_at < CURRENT_DATE - INTERVAL '%s days'
""", (days,))
self.conn.commit()
return self.cursor.rowcount
async def get_payment_history(self, parent_id: int, limit: int = 10) -> List[Dict]:
self.cursor.execute("""
SELECT * FROM payments
WHERE parent_id = %s
ORDER BY payment_date DESC
LIMIT %s
""", (parent_id, limit))
rows = self.cursor.fetchall()
columns = [desc[0] for desc in self.cursor.description]
return [dict(zip(columns, row)) for row in rows]
db = Database()