diff --git a/README.md b/README.md deleted file mode 120000 index c3e5058..0000000 --- a/README.md +++ /dev/null @@ -1 +0,0 @@ -backend/README.md \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..da7b3d7 --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +[![FastAPI and Pytest CI](https://github.com/mbrav/signup_api/actions/workflows/fastapi.yml/badge.svg)](https://github.com/mbrav/signup_api/actions/workflows/fastapi.yml) +[![License](https://img.shields.io/badge/License-BSD_3--Clause-yellow.svg)](https://opensource.org/licenses/BSD-3-Clause) +[![wakatime](https://wakatime.com/badge/user/54ad05ce-f39b-4fa3-9f2a-6fe4b1c53ba4/project/218dc651-c58d-4dfb-baeb-1f70c7bdf2c1.svg)](https://wakatime.com/badge/user/54ad05ce-f39b-4fa3-9f2a-6fe4b1c53ba4/project/218dc651-c58d-4dfb-baeb-1f70c7bdf2c1) + +## FastAPI signup_api + +An 100% asynchronous Fast API service for signups and Telegram integration. + +### Intent + +As of now, this project is mainly an **architecture design** experimental ground with an abstract end goal in mind, rather than an actual functioning app and therefore would be most useful if used as a starting template example. The project uses [FastAPI](https://fastapi.tiangolo.com/) as a base framework with the following stack: + +- Integration with [SQLAlchemy's](https://www.sqlalchemy.org/) new ORM statement paradigm to be implemented in [v2.0](https://docs.sqlalchemy.org/en/20/changelog/migration_20.html); +- Asynchronous PostgreSQL databse via [asyncpg](https://github.com/MagicStack/asyncpg), one of the fastest and high performant Database Client Libraries for python/asyncio; +- Integration with Telegram library [aiogram](https://github.com/aiogram/aiogram) using its upcoming [v3.0 version](https://docs.aiogram.dev/en/dev-3.x/) with webhooks as an integration method with FastAPI; +- A token authorization system using the [argon2 password hashing algorithm](https://github.com/P-H-C/phc-winner-argon2), the password-hashing function that won the [Password Hashing Competition (PHC)](https://www.password-hashing.net/); +- Asynchronous task scheduling using [apscheduler](https://github.com/agronholm/apscheduler); +- Designed to run efficently as possbile on a device such as the Raspberry Pi; +- Asynchronous pytests using [pytest-asyncio](https://github.com/pytest-dev/pytest-asyncio) and [httpx](https://www.python-httpx.org/) libraries instead of the synchronous requests library; +- Vue.js 3.2 basic frontend with potential future experimentation with [vite](https://vitejs.dev/) and [vuetify](https://github.com/vuetifyjs/vuetify) framework. + +### Run FastAPI backend in Docker + +With docker-compose installed, do: + +```bash +docker-compose up +``` + +Go to [0.0.0.0:8000/docs](http://0.0.0.0:8000/docs) for SwaggerUI diff --git a/backend/app/middleware.py b/backend/app/middleware.py index 3a89d88..af890aa 100644 --- a/backend/app/middleware.py +++ b/backend/app/middleware.py @@ -11,7 +11,7 @@ class ProcessTimeMiddleware(BaseHTTPMiddleware): """Add request process time to response headers with logger""" - time_warning = 0.2 + timeout_warning = 0.2 async def dispatch(self, request, call_next): start_time = time.time() @@ -25,11 +25,14 @@ async def dispatch(self, request, call_next): f'client {request.client.host} port {request.client.port} ' \ f'time {process_time:.5f}s' - slow_warning = process_time > self.time_warning + slow_warning = process_time > self.timeout_warning if response.status_code < 203 and not slow_warning: - logger.info(log_message) - elif response.status_code < 500 or slow_warning: - logger.warning(log_message) + logger.debug(log_message) + elif response.status_code < 500: + if slow_warning: + logger.warning(log_message) + else: + logger.info(log_message) else: logger.error(log_message) diff --git a/backend/app/models/events.py b/backend/app/models/events.py index ec828e6..028066c 100644 --- a/backend/app/models/events.py +++ b/backend/app/models/events.py @@ -10,13 +10,13 @@ class Event(BaseModel): """Event class""" - name = Column(String(100), nullable=False) + name = Column(String(128), nullable=False) description = Column(Text, default='') start = Column(DateTime, nullable=False) end = Column(DateTime, nullable=True) google_modified = Column(DateTime, nullable=True) - google_id = Column(String(30), nullable=True) + google_id = Column(String(128), nullable=True) signups = relationship('Signup', back_populates='event') @@ -39,15 +39,15 @@ def __init__(self, async def get_current( self, db_session: AsyncSession, - days_ago: int = 0, + hours_ago: int = 0, limit: int = None, offset: int = 0 ): - """Get events newer than days_ago + """Get events newer than hours_ago Args: db_session (AsyncSession): Current db session - days_ago (int, optional): Ignore events before n days ago. + hours_ago (int, optional): Ignore events before n hours ago. Defaults to 0. limit (int, optional): limit result. Defaults to 0. offset (int, optional): offset result. Defaults to 0. @@ -57,7 +57,7 @@ async def get_current( """ db_query = select(self).where( - self.start > datetime.utcnow() - timedelta(days=days_ago+1) + self.start > datetime.utcnow() - timedelta(hours=hours_ago) ).order_by( self.start.asc()) @@ -70,11 +70,11 @@ async def get_current( async def get_current_count( self, db_session: AsyncSession, - days_ago: int = 0, + hours_ago: int = 0, ) -> int: - """Return count only of events newer than days_ago""" + """Return count only of events newer than hours_ago""" db_query = select([func.count()]).select_from(self).where( - self.start > datetime.utcnow() - timedelta(days=days_ago)) + self.start > datetime.utcnow() - timedelta(hours=hours_ago)) result = await db_session.execute(db_query) return result.scalar() diff --git a/backend/app/models/signups.py b/backend/app/models/signups.py index 2dcc9a3..8676e71 100644 --- a/backend/app/models/signups.py +++ b/backend/app/models/signups.py @@ -29,6 +29,7 @@ def __init__(self, notification: bool = True): self.user_id = user_id self.event_id = event_id + self.cancelled = cancelled self.notification = notification @classmethod @@ -36,16 +37,16 @@ async def by_user( self, db_session: AsyncSession, user_id: int, - days_ago: Optional[Union[int, None]] = 0, + hours_ago: Optional[Union[int, None]] = 0, limit: int = None, offset: int = 0 ): - """Get signups of a user newer than days_ago + """Get signups of a user newer than hours_ago Args: db_session (AsyncSession): Current db session user_id (int): User id - days_ago (Union[int, None], optional): Ignore events before n days ago. + hours_ago (Union[int, None], optuser_idional): Ignore events before n hours ago. Show all events if None. Defaults to 0. limit (int, optional): limit result. Defaults to None. offset (int, optional): offset result. Defaults to 0. @@ -59,10 +60,10 @@ async def by_user( joinedload(self.event)).filter( self.user_id == user_id) - if days_ago is not None: + if hours_ago is not None: db_query = db_query.where( Event.start > datetime.utcnow() - - timedelta(days=days_ago)) + timedelta(hours=hours_ago)) if limit: db_query = db_query.limit(limit).offset(offset) diff --git a/backend/app/models/tasks.py b/backend/app/models/tasks.py index 731f7f9..3263178 100644 --- a/backend/app/models/tasks.py +++ b/backend/app/models/tasks.py @@ -13,10 +13,10 @@ class Task(BaseModel): """Task class""" - name = Column(String(40), nullable=False) + name = Column(String(64), nullable=False) kwargs = Column(PickleType, nullable=False) result = Column(Text, nullable=True) - status = Column(String(20), nullable=False) + status = Column(String(16), nullable=False) delay_seconds = Column(Integer, nullable=False) planned_for = Column(DateTime, nullable=True) diff --git a/backend/app/models/users.py b/backend/app/models/users.py index f39068d..42d03ae 100644 --- a/backend/app/models/users.py +++ b/backend/app/models/users.py @@ -7,13 +7,13 @@ class User(BaseModel): """User class""" - username = Column(String(20), nullable=False) - hashed_password = Column(String(130), nullable=True) + username = Column(String(16), nullable=False) + hashed_password = Column(String(128), nullable=True) tg_id = Column(BigInteger, nullable=True) - email = Column(String(30), nullable=True) - first_name = Column(String(30), nullable=True) - last_name = Column(String(30), nullable=True) + email = Column(String(32), nullable=True) + first_name = Column(String(32), nullable=True) + last_name = Column(String(32), nullable=True) is_active = Column(Boolean(), default=True) is_admin = Column(Boolean(), default=False) diff --git a/backend/app/services/scheduler/google_cal.py b/backend/app/services/scheduler/google_cal.py index dec6818..14c8f4f 100644 --- a/backend/app/services/scheduler/google_cal.py +++ b/backend/app/services/scheduler/google_cal.py @@ -1,12 +1,12 @@ import logging from datetime import datetime, timedelta, timezone -from typing import Dict, List, Optional +from typing import List, Optional +import httpx from app import db from app.models import Event from app.schemas import EventCalIn -from httpx import AsyncClient -from pydantic import BaseModel +from pydantic import BaseModel, validator from sqlalchemy.ext.asyncio import AsyncSession logger = logging.getLogger(__name__) @@ -23,6 +23,18 @@ class EventListParams(BaseModel): timeMin: Optional[str] # timeMax: Optional[datetime] + @validator('singleEvents') + def singleEvents_valid(cls, val): + error = 'singleEvents must be "true" or false' + assert val in ('true', 'false'), error + return val + + @validator('sanitizeHtml') + def sanitizeHtml_valid(cls, val): + error = 'sanitizeHtml must be "true" or false' + assert val in ('true', 'false'), error + return val + def sanitize_keys(d: dict) -> dict: """Sanitize dict unnecessary dict keys""" @@ -57,9 +69,9 @@ def __init__( self, api_key: str, cal_id: str, - days_ago: int = 0): + hours_ago: int = 6): - self.days_ago = days_ago + self.hours_ago = hours_ago self._set_time() self.params = EventListParams( key=api_key, calendarId=cal_id, timeMin=self.iso) @@ -69,30 +81,41 @@ def __init__( def _set_time(self): """Setup event fetching time""" - yesterday = datetime.utcnow() - timedelta(days=self.days_ago) - self.iso = yesterday.astimezone().isoformat() + date_before = datetime.utcnow() - timedelta(hours=self.hours_ago) + self.iso = date_before.astimezone().isoformat() async def fetch_events(self, sanitize: bool = False) -> List: """Fetch events from Google Calendar API""" - async with AsyncClient() as client: - response = await client.get(self.cal_url, params=self.params.dict()) - response_json = response.json() - self.events = response_json.get('items', []) - - elapsed = response.elapsed.total_seconds() - slow_warning = elapsed > 0.7 - log_message = f'Response {response.status_code}, ' \ - f'time {elapsed:.4f}s, ' \ - f'events {len(self.events)}, ' \ - f'yesterday {self.iso}, ' - - if response.status_code == 200 and not slow_warning: - logger.debug(log_message) - elif slow_warning: - logger.warning(log_message) - else: - logger.error(log_message) + async with httpx.AsyncClient() as client: + response = None + try: + response = await client.get(self.cal_url, params=self.params.dict()) + except httpx.ConnectError as errc: + logger.error("Error Connecting:", errc) + except httpx.ConnectTimeout as errt: + logger.error("Timeout Error:", errt) + except httpx.RequestError as err: + logger.error("OOps: Something Else", err) + except httpx.HTTPError as errh: + logger.error("Http Error:", errh) + + response_json = response.json() + self.events = response_json.get('items', []) + + elapsed = response.elapsed.total_seconds() + slow_warning = elapsed > 0.7 + log_message = f'Response {response.status_code}, ' \ + f'time {elapsed:.4f}s, ' \ + f'events {len(self.events)}, ' \ + f'date_before {self.iso}, ' + + if response.status_code == 200 and not slow_warning: + logger.debug(log_message) + elif slow_warning: + logger.warning(log_message) + else: + logger.error(log_message) async def update_events(self): """Update events in db or create new ones""" @@ -137,9 +160,12 @@ async def _update_events_db( google_modified=google_modified) new_object = Event(**new_event.dict()) - await new_object.save(db_session) - logger.debug( - f'Added #{google_id} - {name} to db ') + try: + logger.debug(f'Adding #{google_id} - {name} to db ') + await new_object.save(db_session) + except Exception as e: + logger.error( + f'{e}, Failed to add #{google_id} - {name} to db ') continue event = event_db_dict[google_id] diff --git a/backend/app/services/telegram/callbacks.py b/backend/app/services/telegram/callbacks.py index bbd7ab0..2ce00d7 100644 --- a/backend/app/services/telegram/callbacks.py +++ b/backend/app/services/telegram/callbacks.py @@ -1,6 +1,6 @@ from enum import Enum -from aiogram.dispatcher.filters.callback_data import CallbackData +from aiogram.filters.callback_data import CallbackData class PageNav(str, Enum): diff --git a/backend/app/services/telegram/commands.py b/backend/app/services/telegram/commands.py index 050f0bc..f722cdc 100644 --- a/backend/app/services/telegram/commands.py +++ b/backend/app/services/telegram/commands.py @@ -9,18 +9,18 @@ async def set_bot_commands(bot: Bot): commands = [ BotCommand( command='start', - description=texts.command1_detail), + description=texts.ru.command1_detail), BotCommand( command='help', - description=texts.command2_detail), + description=texts.ru.command2_detail), BotCommand( command='register', - description=texts.command3_detail), + description=texts.ru.command3_detail), BotCommand( command='events', - description=texts.command4_detail), + description=texts.ru.command4_detail), BotCommand( command='me', - description=texts.command5_detail)] + description=texts.ru.command5_detail)] await bot.set_my_commands( commands=commands, scope=BotCommandScopeAllPrivateChats()) diff --git a/backend/app/services/telegram/handlers/handlers.py b/backend/app/services/telegram/handlers/handlers.py index 45e82e8..d79a360 100644 --- a/backend/app/services/telegram/handlers/handlers.py +++ b/backend/app/services/telegram/handlers/handlers.py @@ -4,7 +4,7 @@ from typing import Union from aiogram import types -from aiogram.dispatcher.fsm.context import FSMContext +from aiogram.fsm.context import FSMContext from app import models, schemas from app.config import settings from app.db import Session @@ -44,7 +44,7 @@ async def get_valid_state(call: types.CallbackQuery, state: FSMContext) -> FSMCo seconds = 5 while seconds > 0: await call.message.edit_text( - texts.inline_expired_destroy.format(seconds=seconds), + texts.ru.inline_expired_destroy.format(seconds=seconds), reply_markup=None) await asyncio.sleep(1) seconds -= 1 @@ -75,11 +75,12 @@ async def _user_get_or_create( if registration: if user: - return await message.reply( - texts.register_already.format( + await message.reply( + texts.ru.register_already.format( username=message.from_user.username)) + return user await bot.send_message( - message.from_user.id, texts.register_create) + message.from_user.id, texts.ru.register_create) username = str(message.from_user.id) if not ( message.from_user.username) else message.from_user.username @@ -94,7 +95,7 @@ async def _user_get_or_create( created_user = await new_user.save(db_session) await bot.send_message( message.from_user.id, - texts.register_success.format( + texts.ru.register_success.format( username=created_user.username)) await bot.send_message( settings.TELEGRAM_ADMIN, @@ -102,16 +103,17 @@ async def _user_get_or_create( except Exception as ex: await bot.send_message( message.from_user.id, - texts.register_fail) + texts.ru.register_fail) await bot.send_message( settings.TELEGRAM_ADMIN, f'Error creating account\n {repr(ex)}') - return + return created_user if not user: - return await bot.send_message( + await bot.send_message( message.from_user.id, - texts.register_not) + texts.ru.register_not) + return None return user @@ -157,7 +159,7 @@ async def events_page(page_current: int = 1, limit: int = 10) -> Page: elements_total = await models.Event.get_current_count(db_session) pages_total = (elements_total // limit) + 1 - text = texts.events_page_body.format( + text = texts.ru.events_page_body.format( page_current=page_current, pages_total=pages_total, elements_total=elements_total) @@ -165,7 +167,7 @@ async def events_page(page_current: int = 1, limit: int = 10) -> Page: event_ids = [] for index, event in enumerate(db_events): event_ids.append(event.id) - text += texts.events_page_detail.format( + text += texts.ru.events_page_detail.format( index=emoji_num[index+1], name=event.name, start=time_text(event.start), @@ -190,11 +192,11 @@ async def user_signup_page(call: types.CallbackQuery) -> str: db_signups = await models.Signup.by_user(db_session, user.id) elements_total = len(db_signups) - text = texts.my_signups.format( + text = texts.ru.my_signups.format( signup_count=elements_total) for index, signup in enumerate(db_signups): - text += texts.signups_page_detail.format( + text += texts.ru.signups_page_detail.format( index=emoji_num[index+1], name=signup.event.name, start=time_text(signup.event.start), @@ -202,7 +204,7 @@ async def user_signup_page(call: types.CallbackQuery) -> str: return text -async def _get_or_create_signup(call: types.CallbackQuery, event_id: int) -> Union[bool, models.Signup]: +async def _get_or_create_signup(call: types.CallbackQuery, event_id: int) -> Union[None, models.Signup]: """Get or create new signup Args: @@ -210,25 +212,29 @@ async def _get_or_create_signup(call: types.CallbackQuery, event_id: int) -> Uni event_id (int): Id of the event in the db table Returns: - Union[bool, models.Signup]: False or model + Union[None, models.Signup]: None or model """ async with Session() as db_session: try: user = await models.User.get( db_session, tg_id=call.from_user.id, raise_404=False) - except Exception: - await call.message.edit_text( - texts.register_not, - reply_markup=None) - return False - try: event = await models.Event.get(db_session, id=event_id) + if not user: + await call.message.edit_text( + texts.ru.register_not, + reply_markup=None) + return None + if not event: + raise Exception(f'Event with id {event_id} not found') except Exception: await call.message.edit_text( - texts.inline_fail, + texts.ru.inline_fail, reply_markup=None) - return False + await bot.send_message( + settings.TELEGRAM_ADMIN, + f'Event with id {event_id} not found') + return None get_signup = await models.Signup.get_list( db_session=db_session, diff --git a/backend/app/services/telegram/handlers/inlines.py b/backend/app/services/telegram/handlers/inlines.py index 5f9ad41..c22a751 100644 --- a/backend/app/services/telegram/handlers/inlines.py +++ b/backend/app/services/telegram/handlers/inlines.py @@ -1,5 +1,5 @@ from aiogram import F, types -from aiogram.dispatcher.fsm.context import FSMContext +from aiogram.fsm.context import FSMContext from .. import texts from ..callbacks import Action, EventCallback, MeCallback, PageNav @@ -34,7 +34,7 @@ async def inline_events_list(call: types.CallbackQuery, state: FSMContext): if not page: return await bot.send_message( call.from_user.id, - texts.inline_fail) + texts.ru.inline_fail) await state.update_data(page_current=page_current) await state.update_data(elements_ids=page.elements_ids) @@ -60,33 +60,39 @@ async def inline_event_detail( if not event: return await bot.send_message( call.from_user.id, - texts.inline_fail) + texts.ru.inline_fail) signup = None if action is Action.signup: signup = await signup_create(call, event_id) - await bot.send_message( - call.from_user.id, - texts.signup_success.format( - name=event.name, - start=time_text(event.end), - end=time_text(event.start, time_only=True))) + if signup: + await bot.send_message( + call.from_user.id, + texts.ru.signup_success.format( + name=event.name, + start=time_text(event.end), + end=time_text(event.start, time_only=True))) + else: + return if action is Action.signup_cancel: signup = await signup_cancel(call, event_id) - await bot.send_message( - call.from_user.id, - texts.signup_cancel.format( - name=event.name, - start=time_text(event.end), - end=time_text(event.start, time_only=True))) + if signup: + await bot.send_message( + call.from_user.id, + texts.ru.signup_cancel.format( + name=event.name, + start=time_text(event.end), + end=time_text(event.start, time_only=True))) + else: + return selected = True if signup and action is not Action.signup_cancel else False reply_markup = signup_detail_nav( id=event_id, selected=selected) await call.message.edit_text( - texts.event_detail.format( + texts.ru.event_detail.format( name=event.name, start=time_text(event.end), end=time_text(event.start, time_only=True)), @@ -108,7 +114,7 @@ async def inline_me( # Temp place holder if action in (Action.account, Action.settings): user_info = call.from_user - text = texts.help_text_extra.format( + text = texts.ru.help_text_extra.format( first_name=user_info.first_name, last_name=user_info.first_name, username=user_info.username, @@ -118,7 +124,7 @@ async def inline_me( if action is Action.back: user = await user_profile(call) signup_count = await user_signup_count(user.id) - text = texts.my_account.format(signup_count=signup_count) + text = texts.ru.my_account.format(signup_count=signup_count) await call.message.edit_text( text, diff --git a/backend/app/services/telegram/handlers/main.py b/backend/app/services/telegram/handlers/main.py index 468120a..a7badac 100644 --- a/backend/app/services/telegram/handlers/main.py +++ b/backend/app/services/telegram/handlers/main.py @@ -1,6 +1,6 @@ from aiogram import F, types -from aiogram.dispatcher.filters import Command -from aiogram.dispatcher.fsm.context import FSMContext +from aiogram.filters import Command +from aiogram.fsm.context import FSMContext from app.config import settings from .. import texts @@ -11,7 +11,7 @@ from .states import BotState -@dp.message(Command(commands=['start']), state='*') +@dp.message(Command(commands=['start'])) @dp.message(F.text.in_({'start', 'begin'})) async def start(message: types.Message, state: FSMContext): await state.set_state(None) @@ -19,23 +19,23 @@ async def start(message: types.Message, state: FSMContext): message.from_user.id, sticker=(texts.lotus_sheep)) await message.reply( - texts.start_text.format(version=settings.VERSION)) + texts.ru.start_text.format(version=settings.VERSION)) -@dp.message(Command(commands=['register']), state='*') +@dp.message(Command(commands=['register'])) @dp.message(F.text.in_({'register', 'login'})) async def register(message: types.Message, state: FSMContext): await state.set_state(None) await user_registration(message) -@dp.message(Command(commands=['help']), state='*') +@dp.message(Command(commands=['help'])) @dp.message(F.text.in_({'help', 'fuck'})) async def help(message: types.Message, state: FSMContext): user_info = message.from_user await state.set_state(None) await message.reply( - texts.help_text_extra.format( + texts.ru.help_text_extra.format( first_name=user_info.first_name, last_name=user_info.first_name, username=user_info.username, @@ -43,7 +43,7 @@ async def help(message: types.Message, state: FSMContext): id=user_info.id)) -@dp.message(Command(commands=['events']), state='*') +@dp.message(Command(commands=['events'])) @dp.message(F.text.in_({'events', 'calendar'})) async def events(message: types.Message, state: FSMContext): page = await events_page() @@ -58,7 +58,7 @@ async def events(message: types.Message, state: FSMContext): ids=page.elements_ids)) -@dp.message(Command(commands=['me']), state='*') +@dp.message(Command(commands=['me'])) @dp.message(F.text.in_({'me', 'my'})) async def me(message: types.Message, state: FSMContext): @@ -69,5 +69,5 @@ async def me(message: types.Message, state: FSMContext): await bot.send_message( message.chat.id, - texts.my_account.format(signup_count=signup_count), + texts.ru.my_account.format(signup_count=signup_count), reply_markup=me_keyboard(action=None)) diff --git a/backend/app/services/telegram/handlers/states.py b/backend/app/services/telegram/handlers/states.py index 011344b..0b84478 100644 --- a/backend/app/services/telegram/handlers/states.py +++ b/backend/app/services/telegram/handlers/states.py @@ -2,7 +2,7 @@ from typing import Any, Optional -from aiogram.dispatcher.fsm.state import State, StatesGroup +from aiogram.fsm.state import State, StatesGroup from pydantic import BaseModel diff --git a/backend/app/services/telegram/keyboards.py b/backend/app/services/telegram/keyboards.py index 29c3571..4c2e154 100644 --- a/backend/app/services/telegram/keyboards.py +++ b/backend/app/services/telegram/keyboards.py @@ -21,13 +21,13 @@ pagination_nav = [ InlineKeyboardButton( - text=texts.inline_signup_button_1, + text=texts.ru.inline_signup_button_1, callback_data=EventCallback(page_nav=PageNav.left).pack()), InlineKeyboardButton( - text=texts.inline_signup_button_2, + text=texts.ru.inline_signup_button_2, callback_data=EventCallback(page_nav=PageNav.center).pack()), InlineKeyboardButton( - text=texts.inline_signup_button_3, + text=texts.ru.inline_signup_button_3, callback_data=EventCallback(page_nav=PageNav.right).pack()) ] @@ -37,22 +37,22 @@ def me_keyboard(action: Action): if action and action != Action.back: keys.append(InlineKeyboardButton( - text=texts.inline_signup_action_1, + text=texts.ru.inline_signup_action_1, callback_data=MeCallback( action=Action.back).pack())) if action != Action.signups: keys.append(InlineKeyboardButton( - text=texts.inline_me_button_1, + text=texts.ru.inline_me_button_1, callback_data=MeCallback( action=Action.signups).pack())) if action != Action.account: keys.append(InlineKeyboardButton( - text=texts.inline_me_button_2, + text=texts.ru.inline_me_button_2, callback_data=MeCallback( action=Action.account).pack())) if action != Action.settings: keys.append(InlineKeyboardButton( - text=texts.inline_me_button_3, + text=texts.ru.inline_me_button_3, callback_data=MeCallback( action=Action.settings).pack())) @@ -78,21 +78,21 @@ def signup_detail_nav( keys = [ InlineKeyboardButton( - text=texts.inline_signup_action_1, + text=texts.ru.inline_signup_action_1, callback_data=EventCallback(page_nav=PageNav.center).pack()) ] if selected is True: keys.append( InlineKeyboardButton( - text=texts.inline_signup_action_3, + text=texts.ru.inline_signup_action_3, callback_data=EventCallback( action=Action.signup_cancel, option_id=id).pack())) else: keys.append( InlineKeyboardButton( - text=texts.inline_signup_action_2, + text=texts.ru.inline_signup_action_2, callback_data=EventCallback( action=Action.signup, option_id=id).pack())) @@ -100,14 +100,14 @@ def signup_detail_nav( if notification is True: keys.append( InlineKeyboardButton( - text=texts.inline_notify_on, + text=texts.ru.inline_notify_on, callback_data=EventCallback( action=Action.notify_toggle, option_id=id).pack())) elif notification is False: keys.append( InlineKeyboardButton( - text=texts.inline_notify_off, + text=texts.ru.inline_notify_off, callback_data=EventCallback( action=Action.notify_toggle, option_id=id).pack())) diff --git a/backend/app/services/telegram/loader.py b/backend/app/services/telegram/loader.py index efc5d07..80be863 100644 --- a/backend/app/services/telegram/loader.py +++ b/backend/app/services/telegram/loader.py @@ -1,9 +1,9 @@ from aiogram import Bot, Dispatcher +from aiogram.fsm.storage.memory import MemoryStorage from app.config import settings bot = Bot( token=settings.TELEGRAM_TOKEN.get_secret_value(), - parse_mode='HTML' -) - -dp = Dispatcher() + parse_mode='HTML') +storage = MemoryStorage() +dp = Dispatcher(storage=storage) diff --git a/backend/app/services/telegram/routes.py b/backend/app/services/telegram/routes.py index a773b00..01aa778 100644 --- a/backend/app/services/telegram/routes.py +++ b/backend/app/services/telegram/routes.py @@ -18,7 +18,7 @@ async def feed_update(update: Dict[str, Any]) -> None: if settings.WEBHOOK_USE: await dp.feed_webhook_update(bot, telegram_update) else: - await dp.feed_update(bot, telegram_update) + await dp.feed_raw_update(bot, update) @router.post(path='') @@ -33,11 +33,11 @@ async def on_startup() -> None: Bot.set_current(bot) await set_bot_commands(bot) if settings.WEBHOOK_USE: - current_webhook = await bot.get_webhook_info() - if current_webhook.url != settings.WEBHOOK_PATH: - await bot.set_webhook( - url=settings.WEBHOOK_URL, - certificate=settings.SSL_PUBLIC) + await bot.set_webhook(url=settings.WEBHOOK_URL) + # current_webhook = await bot.get_webhook_info() + # if current_webhook.url != settings.WEBHOOK_PATH: + # await bot.set_webhook( + # url=settings.WEBHOOK_URL) else: bot.get_updates() await bot.send_message(settings.TELEGRAM_ADMIN, '🤖🟢Signup Bot Startup') diff --git a/backend/app/services/telegram/texts/__init__.py b/backend/app/services/telegram/texts/__init__.py index 14afea0..5692a41 100644 --- a/backend/app/services/telegram/texts/__init__.py +++ b/backend/app/services/telegram/texts/__init__.py @@ -1,4 +1,3 @@ -from .keyboards import * -from .main import * +from . import en, ru from .stickers import * diff --git a/backend/app/services/telegram/texts/en/__init__.py b/backend/app/services/telegram/texts/en/__init__.py new file mode 100644 index 0000000..26a1f8b --- /dev/null +++ b/backend/app/services/telegram/texts/en/__init__.py @@ -0,0 +1,3 @@ + +from .keyboards import * +from .main import * diff --git a/backend/app/services/telegram/texts/keyboards.py b/backend/app/services/telegram/texts/en/keyboards.py similarity index 100% rename from backend/app/services/telegram/texts/keyboards.py rename to backend/app/services/telegram/texts/en/keyboards.py diff --git a/backend/app/services/telegram/texts/main.py b/backend/app/services/telegram/texts/en/main.py similarity index 99% rename from backend/app/services/telegram/texts/main.py rename to backend/app/services/telegram/texts/en/main.py index 8fec88a..90472eb 100644 --- a/backend/app/services/telegram/texts/main.py +++ b/backend/app/services/telegram/texts/en/main.py @@ -25,7 +25,6 @@ start_text = """ 🤖Hi! This is signup_api v{version}! -Powered by aiogram """ + help_text my_account = """ @@ -66,6 +65,7 @@ inline_expired_destroy = inline_expired + """ 🗑️The following message will self-destruct in {seconds} seconds. +Please wait """ events_page_body = """ diff --git a/backend/app/services/telegram/texts/ru/__init__.py b/backend/app/services/telegram/texts/ru/__init__.py new file mode 100644 index 0000000..26a1f8b --- /dev/null +++ b/backend/app/services/telegram/texts/ru/__init__.py @@ -0,0 +1,3 @@ + +from .keyboards import * +from .main import * diff --git a/backend/app/services/telegram/texts/ru/keyboards.py b/backend/app/services/telegram/texts/ru/keyboards.py new file mode 100644 index 0000000..dcdcf0d --- /dev/null +++ b/backend/app/services/telegram/texts/ru/keyboards.py @@ -0,0 +1,14 @@ +inline_me_button_1 = '📝Мои записи' +inline_me_button_2 = '👤Мой профиль' +inline_me_button_3 = '⚙️Мои настройки' + +inline_signup_button_1 = '⬅️' +inline_signup_button_2 = 'Первая страница' +inline_signup_button_3 = '➡️' + +inline_signup_action_1 = '🔙 Назад' +inline_signup_action_2 = '📝 Записаться' +inline_signup_action_3 = '❌ Отмена' + +inline_notify_on = '🔔вкл.' +inline_notify_off = '🔕выкл.' diff --git a/backend/app/services/telegram/texts/ru/main.py b/backend/app/services/telegram/texts/ru/main.py new file mode 100644 index 0000000..8093a42 --- /dev/null +++ b/backend/app/services/telegram/texts/ru/main.py @@ -0,0 +1,111 @@ +command1_detail = 'Начать бот' +command2_detail = 'Получить помощь' +command3_detail = 'Регистрация' +command4_detail = 'Список занятий' +command5_detail = 'Моя учётная запись' + +help_text = f""" +Команды: +/start - {command1_detail} +/register - {command2_detail} +/events - {command3_detail} +/me - {command4_detail} +/help - {command5_detail} +""" + +help_text_extra = help_text + """ +Ваша информация: +🔸Имя: {first_name} +🔸Фамилия: {last_name} +🔸Никнейм: {username} +🔸Код языка: {lang} +🔸id: {id} +""" + +start_text = """ +🤖Привет! +Это signup_api v{version}! +""" + help_text + +my_account = """ +👤Моя учётная запись +📝Кол-во: {signup_count} +""" + +my_signups = """ +👤Мои записи +📝Кол-во: {signup_count} +""" + +register_create = '🤖Создаю новую учётную запись ...' + +register_not = """ +🤖Вы не зарегистрированные ... +🔧Пожалуйста, пройдите регистрацию: /register +""" + +register_success = 'Привет {username}, вы теперь зарегистрированы!' + +register_already = 'Привет {username}, вы уже зарегистрированы!' + +register_fail = """ +🤖Произошёл сбой при создании учётной записи ... +🔧Свяжитесь с поддержкой +""" + +inline_fail = """ +🤖Просим прощения, бот где-то поломался, но не сильно ... +🔧Начните заново: /start +""" + +inline_expired = """ +🤖Просим прощения, но данное собщение болье не действительно ... +🔧Начните заново: /start +""" + +inline_expired_destroy = inline_expired + """ +🗑️Данное сообщение удалится через {seconds} секунд. +Подождите +""" + +events_page_body = """ +Страница: {page_current} из {pages_total} +Занятий: {elements_total} +""" + +events_page_detail = """ +{index} - {name} +{start} - {end} +""" + +signups_page_detail = """ +{index} - {name} +{start} - {end} +""" + +signup_success = """ +Успешная запись! + +📝{name} +{start} - {end} + +Вы будете уведомлены до начала занятий +Нажмите /me чтобы просмотреть все ваши записи +""" + +signup_cancel = """ +Запись удалена: + +❌{name} +{start} - {end} +""" + +event_detail = """ +{name} +{start} - {end} +""" + +signup_detail = """ +{name} +{start} - {end} +""" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 44a340f..60ceb5c 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -6,19 +6,19 @@ authors = ["mbrav "] [tool.poetry.dependencies] python = "^3.8" -fastapi = "^0.78.0" -fastapi-pagination = "^0.9" +fastapi = "^0.85" +fastapi-pagination = "^0.10" pydantic = {extras = ["email", "dotenv"], version = "^1.9"} -uvicorn = {extras = ["standard"], version = "^0.17"} +uvicorn = {extras = ["standard"], version = "^0.18"} SQLAlchemy = "^1.4" -asyncpg = "*" +asyncpg = "^0.26" python-jose = {extras = ["cryptography"], version = "^3.3"} passlib = {extras = ["argon2"], version = "^1.7"} -python-multipart = "*" -httpx = {extras = ["http2"], version = "^0.22"} +python-multipart = "0.0.5" +httpx = {extras = ["http2"], version = "^0.23"} APScheduler = "^3.9" -transliterate = "*" -aiogram = "^3.0.0b3" +transliterate = "^1.10" +aiogram = "^3.0.0b5" [tool.poetry.dev-dependencies]