├── .coveragerc ├── .dockerignore ├── .editorconfig ├── .env.default ├── .github ├── FUNDING.yml └── workflows │ └── tests.yml ├── .gitignore ├── .sonarcloud.properties ├── Dockerfile ├── LICENSE ├── README.md ├── __init__.py ├── alembic.ini ├── app ├── __init__.py ├── cache.py ├── callbacks │ ├── __init__.py │ ├── currencies.py │ ├── disclaimers.py │ ├── donate.py │ ├── feedback.py │ ├── help.py │ ├── personal_settings │ │ ├── __init__.py │ │ ├── default_currency.py │ │ ├── default_currency_position.py │ │ ├── language.py │ │ ├── main.py │ │ └── onscreen_menu.py │ ├── price.py │ ├── sources.py │ ├── start.py │ ├── stop.py │ └── tutorial.py ├── celery.py ├── constants.py ├── converter │ ├── __init__.py │ ├── base.py │ ├── converter.py │ └── exceptions.py ├── decorators.py ├── exceptions.py ├── exchanges │ ├── __init__.py │ ├── base.py │ ├── bitfinex.py │ ├── bitkub.py │ ├── bittrex.py │ ├── exceptions.py │ ├── fixer.py │ ├── openexchangerates.py │ ├── satang.py │ ├── sp_today.py │ └── tests │ │ ├── __init__.py │ │ ├── fixtures │ │ └── vcr │ │ │ ├── bitfinex │ │ │ ├── get_pair_200.yaml │ │ │ └── symbols_200.yaml │ │ │ ├── bitkub │ │ │ └── query_200.yaml │ │ │ ├── bittrex │ │ │ ├── null_bid_ask.yaml │ │ │ ├── null_high_low.yaml │ │ │ ├── null_prevday.yaml │ │ │ └── query_200.yaml │ │ │ ├── fixer │ │ │ └── query_200.yaml │ │ │ ├── openexchangerates │ │ │ └── query_200.yaml │ │ │ ├── satang │ │ │ └── query_200.yaml │ │ │ └── sp_today │ │ │ └── query_200.yaml │ │ ├── test_base.py │ │ ├── test_bitfinex.py │ │ ├── test_bitkub.py │ │ ├── test_bittrex.py │ │ ├── test_fixer.py │ │ ├── test_openexchangerates.py │ │ ├── test_satang.py │ │ └── test_sp_today.py ├── formatter │ ├── __init__.py │ ├── formatter.py │ └── tests │ │ ├── __init__.py │ │ └── test_formater.py ├── helpers.py ├── keyboard.py ├── logic.py ├── main.py ├── models.py ├── parsers │ ├── __init__.py │ ├── base.py │ ├── exceptions.py │ ├── extend_regex_parser.py │ ├── last_request_parser.py │ ├── regex_parser.py │ └── tests │ │ ├── __init__.py │ │ └── test_regex_parser.py ├── queries.py ├── sentry.py ├── settings.py ├── tasks.py ├── tasks_notifications.py ├── tests │ ├── __init__.py │ ├── test_helpers.py │ ├── test_keyboard.py │ ├── test_logic.py │ ├── test_main.py │ ├── test_queries.py │ ├── test_tasks.py │ ├── test_tasks_notifications.py │ └── test_translations.py └── translations.py ├── babel.cfg ├── docker-cmd.sh ├── docker-compose.yml ├── docker-entrypoint.sh ├── locale ├── ca │ └── LC_MESSAGES │ │ └── messages.po ├── de │ └── LC_MESSAGES │ │ └── messages.po ├── en │ └── LC_MESSAGES │ │ └── messages.po ├── es │ └── LC_MESSAGES │ │ └── messages.po ├── es_AR │ └── LC_MESSAGES │ │ └── messages.po ├── fr │ └── LC_MESSAGES │ │ └── messages.po ├── id │ └── LC_MESSAGES │ │ └── messages.po ├── it │ └── LC_MESSAGES │ │ └── messages.po ├── kk │ └── LC_MESSAGES │ │ └── messages.po ├── ms │ └── LC_MESSAGES │ │ └── messages.po ├── nl │ └── LC_MESSAGES │ │ └── messages.po ├── pl │ └── LC_MESSAGES │ │ └── messages.po ├── pt │ └── LC_MESSAGES │ │ └── messages.po ├── pt_BR │ └── LC_MESSAGES │ │ └── messages.po ├── ru │ └── LC_MESSAGES │ │ └── messages.po ├── tr │ └── LC_MESSAGES │ │ └── messages.po ├── uk │ └── LC_MESSAGES │ │ └── messages.po ├── uz │ └── LC_MESSAGES │ │ └── messages.po ├── zh_Hans │ └── LC_MESSAGES │ │ └── messages.po └── zh_Hans_SG │ └── LC_MESSAGES │ └── messages.po ├── manage.py ├── migrations ├── README ├── env.py ├── script.py.mako └── versions │ ├── 20190306160951_initial.py │ ├── 20190306162241_rename_tables.py │ ├── 20190306164236_chat_request_chat_foreigns.py │ ├── 20190306193447_currencies_chat_request_foreigns.py │ ├── 20190311151631_exchange_rates.py │ ├── 20190314103043_requests_log_refact.py │ ├── 20190314175025_events.py │ ├── 20190314182214_chat_settings.py │ ├── 20190321212933_fixer_exchange.py │ ├── 20190324223003_convert_locales.py │ ├── 20190326060130_add_btt_currency.py │ ├── 20190326083625_add_bx_in_tx_exchange.py │ ├── 20190326131313_add_syp_exchange.py │ ├── 20190328125146_notifications.py │ ├── 20190402024141_chat_rm_personal_data.py │ ├── 20190402045327_settings_show_keyboard.py │ ├── 20190404054029_keyboard_size.py │ ├── 20190903073346_bitkub_exchanger.py │ ├── 20200217055212_satang_exchanger.py │ ├── 20200217062024_add_xzc_currency.py │ └── 20200329134234_referral_bittrex.py ├── poetry.lock ├── pyproject.toml └── suite ├── __init__.py ├── conf ├── __init__.py ├── exceptions.py ├── global_settings.py └── tests │ ├── __init__.py │ ├── settings │ ├── __init__.py │ ├── base_settings.py │ └── testing_settings.py │ └── test_settings.py ├── database ├── __init__.py └── models.py ├── management ├── __init__.py └── commands │ ├── __init__.py │ ├── db.py │ ├── newsletter.py │ ├── start.py │ └── test.py └── test ├── __init__.py ├── testcases.py ├── tests ├── __init__.py ├── test_db.py └── test_override_settings.py └── utils.py /.coveragerc: -------------------------------------------------------------------------------- 1 | # .coveragerc to control coverage.py 2 | [run] 3 | branch = True 4 | 5 | source = . 6 | 7 | omit = 8 | */migrations/* 9 | */tests/* 10 | 11 | [report] 12 | # Regexes for lines to exclude from consideration 13 | exclude_lines = 14 | # Have to re-enable the standard pragma 15 | pragma: no cover 16 | 17 | # Don't complain about missing debug-only code: 18 | def __repr__ 19 | if self\.debug 20 | 21 | # Don't complain if tests don't hit defensive assertion code: 22 | raise AssertionError 23 | raise NotImplementedError 24 | return NotImplemented 25 | 26 | # Don't complain if non-runnable code isn't run: 27 | if 0: 28 | if __name__ == .__main__.: 29 | 30 | ignore_errors = True 31 | 32 | [html] 33 | directory = coverage_html_report 34 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | .coverage 7 | .git 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | end_of_line = lf 11 | charset = utf-8 12 | 13 | # Docstrings and comments use max_line_length = 119 14 | [*.py] 15 | max_line_length = 119 16 | 17 | # Use 2 spaces for the HTML files 18 | [*.html] 19 | indent_size = 2 20 | 21 | [{*.yml,*.yaml}] 22 | indent_size = 2 23 | 24 | # The JSON files contain newlines inconsistently 25 | [*.json] 26 | indent_size = 2 27 | insert_final_newline = false 28 | 29 | # Minified JavaScript files shouldn't be changed 30 | [**.min.js] 31 | insert_final_newline = false 32 | 33 | # Makefiles always use tabs for indentation 34 | [Makefile] 35 | indent_style = tab 36 | 37 | # Batch files use tabs for indentation 38 | [{*.bat,*.sh,Dockerfile}] 39 | indent_style = tab 40 | 41 | [docs/**.txt] 42 | max_line_length = 79 43 | 44 | [*.md] 45 | trim_trailing_whitespace = false 46 | -------------------------------------------------------------------------------- /.env.default: -------------------------------------------------------------------------------- 1 | SETTINGS_MODULE=app.settings 2 | BOT_TOKEN= 3 | OPENEXCHANGERATES_TOKEN= 4 | FIXER_TOKEN= 5 | SENTRY_URL= 6 | DEVELOPER_BOT_TOKEN= 7 | DEVELOPER_USER_ID= 8 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [llybin] 2 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | 7 | build: 8 | 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: Set up Python 3.9 14 | uses: actions/setup-python@v2 15 | with: 16 | python-version: 3.9 17 | - name: set up env 18 | run: cp .env.default .env 19 | - name: docker-compose pull 20 | run: docker-compose pull 21 | - uses: satackey/action-docker-layer-caching@v0.0.11 22 | - name: docker-compose build 23 | run: docker-compose build --build-arg UID="$UID" 24 | - name: docker-compose up services 25 | run: docker-compose up -d db redis 26 | - name: Test 27 | run: | 28 | docker-compose run service pybabel compile -d locale 29 | docker-compose run service coverage run ./manage.py test 30 | docker-compose run service coverage report 31 | docker-compose run service coverage xml 32 | - name: Send coverage to Codacy 33 | env: 34 | CODACY_PROJECT_TOKEN: ${{ secrets.CODACY_PROJECT_TOKEN }} 35 | run: | 36 | pip install codacy-coverage==1.3.11 37 | python-codacy-coverage -r ./coverage.xml 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | celerybeat.pid 81 | 82 | # SageMath parsed files 83 | *.sage.py 84 | 85 | # Environments 86 | .env 87 | .venv 88 | env/ 89 | venv/ 90 | ENV/ 91 | env.bak/ 92 | venv.bak/ 93 | 94 | # Spyder project settings 95 | .spyderproject 96 | .spyproject 97 | 98 | # Rope project settings 99 | .ropeproject 100 | 101 | # mkdocs documentation 102 | /site 103 | 104 | # mypy 105 | .mypy_cache/ 106 | 107 | old_code 108 | -------------------------------------------------------------------------------- /.sonarcloud.properties: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9.5-slim-buster 2 | 3 | ARG UID=1000 4 | ARG GID=1000 5 | ARG APP_MIGRATE=off 6 | ARG START_APP=off 7 | 8 | ENV APP_MIGRATE=${APP_MIGRATE} \ 9 | START_APP=${START_APP} \ 10 | # https://docs.python.org/3.8/using/cmdline.html 11 | PYTHONFAULTHANDLER=1 \ 12 | PYTHONUNBUFFERED=1 \ 13 | PYTHONHASHSEED=random \ 14 | # https://github.com/pypa/pip/blob/master/src/pip/_internal/cli/cmdoptions.py 15 | PIP_NO_CACHE_DIR=on \ 16 | PIP_DISABLE_PIP_VERSION_CHECK=on \ 17 | PIP_DEFAULT_TIMEOUT=100 \ 18 | # https://github.com/jwilder/dockerize 19 | DOCKERIZE_VERSION=v0.6.1 \ 20 | # https://github.com/python-poetry/poetry 21 | POETRY_VERSION=1.1.6 22 | 23 | # Create user and group for running app 24 | RUN groupadd -r -g $GID app && useradd --no-log-init -r -u $UID -g app app 25 | 26 | # System deps 27 | RUN apt-get update \ 28 | && apt-get install --assume-yes --no-install-recommends --no-install-suggests \ 29 | tini \ 30 | && rm -rf /var/lib/apt/lists/* 31 | 32 | # This is a special case. We need to run this script as an entry point 33 | COPY ./docker-entrypoint.sh /docker-entrypoint.sh 34 | COPY ./docker-cmd.sh /docker-cmd.sh 35 | RUN chmod +x "/docker-entrypoint.sh" \ 36 | && chmod +x "/docker-cmd.sh" 37 | 38 | # Copy only requirements, to cache them in docker layer 39 | WORKDIR /pysetup 40 | COPY ./poetry.lock ./pyproject.toml /pysetup/ 41 | 42 | # Building system and app dependencies 43 | RUN set -ex \ 44 | && savedAptMark="$(apt-mark showmanual)" \ 45 | && apt-get update \ 46 | && apt-get install --assume-yes --no-install-recommends --no-install-suggests \ 47 | wget \ 48 | && wget -nv "https://github.com/jwilder/dockerize/releases/download/${DOCKERIZE_VERSION}/dockerize-linux-amd64-${DOCKERIZE_VERSION}.tar.gz" \ 49 | && tar -C /usr/local/bin -xzvf "dockerize-linux-amd64-${DOCKERIZE_VERSION}.tar.gz" \ 50 | && rm "dockerize-linux-amd64-${DOCKERIZE_VERSION}.tar.gz" \ 51 | && pip install "poetry==$POETRY_VERSION" \ 52 | && poetry config virtualenvs.create false \ 53 | && poetry install --no-interaction --no-ansi \ 54 | && apt-mark auto '.*' > /dev/null \ 55 | && apt-mark manual $savedAptMark \ 56 | && find /usr/local -type f -executable -not \( -name '*tkinter*' \) -exec ldd '{}' ';' \ 57 | | awk '/=>/ { print $(NF-1) }' \ 58 | | sort -u \ 59 | | xargs -r dpkg-query --search \ 60 | | cut -d: -f1 \ 61 | | sort -u \ 62 | | xargs -r apt-mark manual \ 63 | && apt-get purge --assume-yes --auto-remove \ 64 | --option APT::AutoRemove::RecommendsImportant=false \ 65 | --option APT::AutoRemove::SuggestsImportant=false \ 66 | && rm -rf /var/lib/apt/lists/* 67 | 68 | USER app 69 | COPY --chown=app:app . /app 70 | WORKDIR /app 71 | 72 | RUN pybabel compile -d locale 73 | 74 | ENTRYPOINT ["/usr/bin/tini", "--", "/docker-entrypoint.sh"] 75 | CMD ["/docker-cmd.sh"] 76 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Telegram ExchangeRatesBot 2 | 3 | [![CI](https://github.com/llybin/TelegramExchangeRatesBot/workflows/tests/badge.svg)](https://github.com/llybin/TelegramExchangeRatesBot/actions) 4 | [![Codacy Badge](https://api.codacy.com/project/badge/Grade/ddb58369590944a69a53737837c8dd3b)](https://www.codacy.com/app/llybin/TelegramExchangeRatesBot?utm_source=github.com&utm_medium=referral&utm_content=llybin/TelegramExchangeRatesBot&utm_campaign=Badge_Grade) 5 | [![Codacy Badge](https://api.codacy.com/project/badge/Coverage/ddb58369590944a69a53737837c8dd3b)](https://www.codacy.com/app/llybin/TelegramExchangeRatesBot?utm_source=github.com&utm_medium=referral&utm_content=llybin/TelegramExchangeRatesBot&utm_campaign=Badge_Coverage) 6 | [![GPLv3](https://img.shields.io/badge/license-GPLv3-blue.svg)](LICENSE) 7 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 8 | 9 | Telegram bot actual exchange rates for travel, work and daily life. 10 | 11 | Online since 01 July 2015. 12 | 13 | [https://telegram.me/ExchangeRatesBot]() 14 | 15 | ## Translations 16 | 17 | Don't have your localization? Any translation errors? Help fix it. 18 | 19 | 👉 [PoEditor.com](https://poeditor.com/join/project/LLu8AztSPb) 20 | 21 | ## How to run 22 | 23 | `cp .env.default .env` 24 | 25 | Configure your .env: 26 | 27 | BOT_TOKEN - set up 28 | 29 | `docker-compose up` 30 | 31 | ## Development 32 | 33 | ### See manage commands 34 | 35 | `docker-compose run service ./manage.py` 36 | 37 | ### How to run tests 38 | 39 | `docker-compose run service ./manage.py test` 40 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/llybin/TelegramExchangeRatesBot/7cd5e8173915dd56d2c360595cf14f8850d6e853/__init__.py -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = migrations 6 | 7 | # template used to generate migration files 8 | file_template = %%(year)d%%(month).2d%%(day).2d%%(hour).2d%%(minute).2d%%(second).2d_%%(slug)s 9 | 10 | # timezone to use when rendering the date 11 | # within the migration file as well as the filename. 12 | # string value is passed to dateutil.tz.gettz() 13 | # leave blank for localtime 14 | # timezone = 15 | 16 | # max length of characters to apply to the 17 | # "slug" field 18 | #truncate_slug_length = 40 19 | 20 | # set to 'true' to run the environment during 21 | # the 'revision' command, regardless of autogenerate 22 | # revision_environment = false 23 | 24 | # set to 'true' to allow .pyc and .pyo files without 25 | # a source .py file to be detected as revisions in the 26 | # versions/ directory 27 | # sourceless = false 28 | 29 | # version location specification; this defaults 30 | # to alembic/versions. When using multiple version 31 | # directories, initial revisions must be specified with --version-path 32 | # version_locations = %(here)s/bar %(here)s/bat alembic/versions 33 | 34 | # the output encoding used when revision files 35 | # are written from script.py.mako 36 | # output_encoding = utf-8 37 | 38 | # sqlalchemy.url = postgresql://postgres:@db:5432/postgres 39 | 40 | 41 | # Logging configuration 42 | [loggers] 43 | keys = root,sqlalchemy,alembic 44 | 45 | [handlers] 46 | keys = console 47 | 48 | [formatters] 49 | keys = generic 50 | 51 | [logger_root] 52 | level = WARN 53 | handlers = console 54 | qualname = 55 | 56 | [logger_sqlalchemy] 57 | level = WARN 58 | handlers = 59 | qualname = sqlalchemy.engine 60 | 61 | [logger_alembic] 62 | level = INFO 63 | handlers = 64 | qualname = alembic 65 | 66 | [handler_console] 67 | class = StreamHandler 68 | args = (sys.stderr,) 69 | level = NOTSET 70 | formatter = generic 71 | 72 | [formatter_generic] 73 | format = %(levelname)-5.5s [%(name)s] %(message)s 74 | datefmt = %H:%M:%S 75 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- 1 | from decimal import getcontext 2 | from logging.config import dictConfig 3 | 4 | from suite.conf import settings 5 | 6 | from .constants import decimal_precision 7 | 8 | dictConfig(settings.LOGGING) 9 | 10 | getcontext().prec = decimal_precision 11 | -------------------------------------------------------------------------------- /app/cache.py: -------------------------------------------------------------------------------- 1 | from dogpile.cache import make_region 2 | 3 | from suite.conf import settings 4 | 5 | region = make_region().configure( 6 | "dogpile.cache.redis", 7 | expiration_time=3600, 8 | arguments={"host": settings.CACHE["host"], "db": settings.CACHE["db"]}, 9 | ) 10 | -------------------------------------------------------------------------------- /app/callbacks/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/llybin/TelegramExchangeRatesBot/7cd5e8173915dd56d2c360595cf14f8850d6e853/app/callbacks/__init__.py -------------------------------------------------------------------------------- /app/callbacks/currencies.py: -------------------------------------------------------------------------------- 1 | from telegram import ParseMode, Update 2 | from telegram.ext import CallbackContext, ConversationHandler 3 | 4 | from app.decorators import register_update 5 | from app.queries import get_all_currencies 6 | 7 | 8 | @register_update 9 | def currencies_callback(update: Update, context: CallbackContext, chat_info: dict): 10 | text_to = "\n".join([f"{code} - {name}" for code, name in get_all_currencies()]) 11 | 12 | update.message.reply_text(parse_mode=ParseMode.MARKDOWN, text=text_to) 13 | 14 | return ConversationHandler.END 15 | -------------------------------------------------------------------------------- /app/callbacks/disclaimers.py: -------------------------------------------------------------------------------- 1 | from gettext import gettext 2 | 3 | from telegram import Update 4 | from telegram.ext import CallbackContext, ConversationHandler 5 | 6 | from app.decorators import chat_language, register_update 7 | 8 | 9 | @register_update 10 | @chat_language 11 | def disclaimers_callback( 12 | update: Update, context: CallbackContext, chat_info: dict, _: gettext 13 | ): 14 | update.message.reply_text( 15 | text=_( 16 | "Data is provided by financial exchanges and may be delayed " 17 | "as specified by financial exchanges or our data providers. " 18 | "Bot does not verify any data and disclaims any obligation " 19 | "to do so. Bot cannot guarantee the accuracy of the exchange " 20 | "rates displayed. You should confirm current rates before making " 21 | "any transactions that could be affected by changes in " 22 | "the exchange rates." 23 | ) 24 | ) 25 | 26 | return ConversationHandler.END 27 | -------------------------------------------------------------------------------- /app/callbacks/donate.py: -------------------------------------------------------------------------------- 1 | from gettext import gettext 2 | 3 | from telegram import ParseMode, Update 4 | from telegram.ext import CallbackContext, ConversationHandler 5 | 6 | from app.decorators import chat_language, register_update 7 | from suite.conf import settings 8 | 9 | 10 | @register_update 11 | @chat_language 12 | def donate_callback( 13 | update: Update, context: CallbackContext, chat_info: dict, _: gettext 14 | ): 15 | text_to = _("*Bot is free and online since 01 July 2015*") 16 | 17 | text_to += "\n\n" 18 | 19 | for currency, wallet in settings.DONATION_WALLETS.items(): 20 | text_to += f"*{currency}*: `{wallet}`\n" 21 | 22 | update.message.reply_text( 23 | disable_web_page_preview=True, parse_mode=ParseMode.MARKDOWN, text=text_to 24 | ) 25 | 26 | return ConversationHandler.END 27 | -------------------------------------------------------------------------------- /app/callbacks/feedback.py: -------------------------------------------------------------------------------- 1 | from gettext import gettext 2 | 3 | from telegram import ReplyKeyboardMarkup, ReplyKeyboardRemove, Update 4 | from telegram.ext import CallbackContext, ConversationHandler 5 | 6 | from app.decorators import chat_language, register_update 7 | from app.logic import get_keyboard 8 | from app.tasks import send_feedback 9 | 10 | 11 | @register_update 12 | @chat_language 13 | def feedback_callback( 14 | update: Update, context: CallbackContext, chat_info: dict, _: gettext 15 | ): 16 | chat_id = update.message.chat_id 17 | 18 | if chat_id < 0: 19 | update.message.reply_text(_("The command is not available for group chats")) 20 | return 21 | 22 | update.message.reply_text( 23 | reply_markup=ReplyKeyboardRemove(), 24 | text=_("What do you want to tell? Or nothing?") + " /nothing", 25 | ) 26 | 27 | return 1 28 | 29 | 30 | @register_update 31 | @chat_language 32 | def send_feedback_callback( 33 | update: Update, context: CallbackContext, chat_info: dict, _: gettext 34 | ): 35 | text_to = _("Message sent, thank you.") 36 | 37 | keyboard = get_keyboard(update.message.chat_id) 38 | 39 | update.message.reply_text( 40 | reply_markup=ReplyKeyboardMarkup(keyboard) if keyboard else None, text=text_to 41 | ) 42 | 43 | send_feedback.delay( 44 | update.message.chat.id, 45 | update.message.from_user.first_name, 46 | update.message.from_user.username, 47 | update.message.text, 48 | ) 49 | 50 | return ConversationHandler.END 51 | -------------------------------------------------------------------------------- /app/callbacks/help.py: -------------------------------------------------------------------------------- 1 | from gettext import gettext 2 | 3 | from telegram import ParseMode, Update 4 | from telegram.ext import CallbackContext, ConversationHandler 5 | 6 | from app.decorators import chat_language, register_update 7 | 8 | 9 | @register_update 10 | @chat_language 11 | def help_callback( 12 | update: Update, context: CallbackContext, chat_info: dict, _: gettext 13 | ): 14 | text_to = _("*Commands*") 15 | 16 | text_to += "\n\n" 17 | text_to += _("/start - Start to enslave mankind") 18 | text_to += "\n" 19 | text_to += _("/tutorial - Tutorial, how to talk with me") 20 | text_to += "\n" 21 | text_to += _("/currencies - All currencies that I support") 22 | text_to += "\n" 23 | text_to += _("/feedback - If you have suggestions, text me") 24 | text_to += "\n" 25 | text_to += _("/p - Command for group chats, get exchange rate") 26 | text_to += "\n" 27 | text_to += _("/sources - Currency rates sources") 28 | text_to += "\n" 29 | text_to += _("/settings - Bot personal settings") 30 | text_to += "\n" 31 | text_to += _("/disclaimers - Disclaimers") 32 | text_to += "\n" 33 | text_to += _("/stop - Unsubscribe") 34 | 35 | text_to += "\n\n" 36 | text_to += _( 37 | "Don't have your localization? Any translation errors? Help fix it 👉 [poeditor.com](%(trans_link)s)" 38 | ) % { # NOQA 39 | "trans_link": "https://poeditor.com/join/project/LLu8AztSPb" 40 | } 41 | 42 | text_to += "\n\n" 43 | text_to += "Bot is free and online since 01 July 2015 /donate" 44 | 45 | update.message.reply_text( 46 | disable_web_page_preview=True, parse_mode=ParseMode.MARKDOWN, text=text_to 47 | ) 48 | 49 | return ConversationHandler.END 50 | -------------------------------------------------------------------------------- /app/callbacks/personal_settings/__init__.py: -------------------------------------------------------------------------------- 1 | from . import default_currency, default_currency_position, language, main, onscreen_menu 2 | from .main import SettingsSteps 3 | 4 | __all__ = [ 5 | "default_currency", 6 | "default_currency_position", 7 | "language", 8 | "SettingsSteps", 9 | "main", 10 | "onscreen_menu", 11 | ] 12 | -------------------------------------------------------------------------------- /app/callbacks/personal_settings/default_currency.py: -------------------------------------------------------------------------------- 1 | from gettext import gettext 2 | 3 | import transaction 4 | from telegram import ParseMode, ReplyKeyboardMarkup, Update 5 | from telegram.ext import CallbackContext 6 | 7 | from app.callbacks.personal_settings.main import SettingsSteps, main_menu 8 | from app.decorators import chat_language, register_update 9 | from app.keyboard import KeyboardSimpleClever 10 | from app.models import Chat, Currency 11 | from app.queries import get_all_currency_codes 12 | from suite.database import Session 13 | 14 | 15 | @register_update 16 | @chat_language 17 | def menu_callback( 18 | update: Update, context: CallbackContext, chat_info: dict, _: gettext 19 | ): 20 | text_to = _( 21 | "*%(default_currency)s* is your default currency.\n" 22 | "You can set any currency by default, e.g. *EUR*. When you send only USD - will get *EUR USD*" 23 | ) % {"default_currency": chat_info["default_currency"]} 24 | 25 | keyboard = KeyboardSimpleClever(["↩️"] + get_all_currency_codes(), 4).show() 26 | 27 | update.message.reply_text( 28 | parse_mode=ParseMode.MARKDOWN, 29 | reply_markup=ReplyKeyboardMarkup(keyboard), 30 | text=text_to, 31 | ) 32 | 33 | return SettingsSteps.default_currency 34 | 35 | 36 | @register_update 37 | @chat_language 38 | def set_callback(update: Update, context: CallbackContext, chat_info: dict, _: gettext): 39 | currency_code = update.message.text.upper() 40 | 41 | db_session = Session() 42 | 43 | currency = ( 44 | db_session.query(Currency).filter_by(code=currency_code, is_active=True).first() 45 | ) 46 | 47 | if not currency: 48 | update.message.reply_text(text="🧐") 49 | return SettingsSteps.default_currency 50 | 51 | db_session.query(Chat).filter_by(id=update.message.chat_id).update( 52 | {"default_currency": currency_code} 53 | ) 54 | transaction.commit() 55 | 56 | text_to = _("*%(default_currency)s* is your default currency.") % { 57 | "default_currency": currency_code 58 | } 59 | 60 | update.message.reply_text(parse_mode=ParseMode.MARKDOWN, text=text_to) 61 | 62 | main_menu(update, chat_info, _) 63 | 64 | return SettingsSteps.main 65 | -------------------------------------------------------------------------------- /app/callbacks/personal_settings/default_currency_position.py: -------------------------------------------------------------------------------- 1 | from gettext import gettext 2 | 3 | import transaction 4 | from telegram import ParseMode, ReplyKeyboardMarkup, Update 5 | from telegram.ext import CallbackContext 6 | 7 | from app.callbacks.personal_settings.main import SettingsSteps, main_menu 8 | from app.decorators import chat_language, register_update 9 | from app.keyboard import KeyboardSimpleClever 10 | from app.models import Chat 11 | from suite.database import Session 12 | 13 | 14 | @register_update 15 | @chat_language 16 | def menu_callback( 17 | update: Update, context: CallbackContext, chat_info: dict, _: gettext 18 | ): 19 | if chat_info["default_currency_position"]: 20 | position = f'___{chat_info["default_currency"]}' 21 | else: 22 | position = f'{chat_info["default_currency"]}___' 23 | 24 | text_to = _( 25 | "*%(position)s* - position where your default currency will be added." 26 | ) % {"position": position} 27 | 28 | keyboard = KeyboardSimpleClever( 29 | [ 30 | f'{chat_info["default_currency"]}___', 31 | f'___{chat_info["default_currency"]}', 32 | "↩️", 33 | ], 34 | 3, 35 | ).show() 36 | 37 | update.message.reply_text( 38 | parse_mode=ParseMode.MARKDOWN, 39 | reply_markup=ReplyKeyboardMarkup(keyboard), 40 | text=text_to, 41 | ) 42 | 43 | return SettingsSteps.default_currency_position 44 | 45 | 46 | @register_update 47 | @chat_language 48 | def set_command(update: Update, context: CallbackContext, chat_info: dict, _: gettext): 49 | if update.message.text.endswith("___"): 50 | default_currency_position = False 51 | elif update.message.text.startswith("___"): 52 | default_currency_position = True 53 | else: 54 | update.message.reply_text(text="🧐") 55 | return SettingsSteps.default_currency_position 56 | 57 | if default_currency_position: 58 | position = f'___{chat_info["default_currency"]}' 59 | else: 60 | position = f'{chat_info["default_currency"]}___' 61 | 62 | db_session = Session() 63 | db_session.query(Chat).filter_by(id=update.message.chat_id).update( 64 | {"default_currency_position": default_currency_position} 65 | ) 66 | transaction.commit() 67 | 68 | text_to = _( 69 | "*%(position)s* - position where your default currency will be added." 70 | ) % {"position": position} 71 | 72 | update.message.reply_text(parse_mode=ParseMode.MARKDOWN, text=text_to) 73 | 74 | main_menu(update, chat_info, _) 75 | 76 | return SettingsSteps.main 77 | -------------------------------------------------------------------------------- /app/callbacks/personal_settings/language.py: -------------------------------------------------------------------------------- 1 | from gettext import gettext 2 | 3 | import transaction 4 | from telegram import ParseMode, ReplyKeyboardMarkup, Update 5 | from telegram.ext import CallbackContext 6 | 7 | from app.callbacks.personal_settings.main import SettingsSteps, main_menu 8 | from app.decorators import chat_language, register_update 9 | from app.keyboard import KeyboardSimpleClever 10 | from app.models import Chat 11 | from app.translations import get_translations 12 | from suite.conf import settings 13 | from suite.database import Session 14 | 15 | LANGUAGES_LIST = sorted(settings.LANGUAGES_NAME.keys()) 16 | LOCALE_NAME = {v: k for k, v in settings.LANGUAGES_NAME.items()} 17 | 18 | 19 | @register_update 20 | @chat_language 21 | def menu_callback( 22 | update: Update, context: CallbackContext, chat_info: dict, _: gettext 23 | ): 24 | if chat_info["locale"] in LOCALE_NAME: 25 | language_name = LOCALE_NAME[chat_info["locale"]] 26 | text_to = _("*%(language)s* is your language now.") % { 27 | "language": language_name 28 | } 29 | else: 30 | text_to = ( 31 | "Your language has no translation. Help fix it 👉 [poeditor.com](%(trans_link)s)" 32 | % {"trans_link": "https://poeditor.com/join/project/LLu8AztSPb"} 33 | ) 34 | 35 | keyboard = KeyboardSimpleClever(["↩️"] + LANGUAGES_LIST, 2).show() 36 | 37 | update.message.reply_text( 38 | parse_mode=ParseMode.MARKDOWN, 39 | reply_markup=ReplyKeyboardMarkup(keyboard), 40 | text=text_to, 41 | ) 42 | 43 | return SettingsSteps.language 44 | 45 | 46 | @register_update 47 | def set_callback(update: Update, context: CallbackContext, chat_info: dict): 48 | if update.message.text not in settings.LANGUAGES_NAME: 49 | update.message.reply_text(text="🧐") 50 | return SettingsSteps.language 51 | else: 52 | locale = settings.LANGUAGES_NAME[update.message.text] 53 | 54 | db_session = Session() 55 | db_session.query(Chat).filter_by(id=update.message.chat_id).update( 56 | {"locale": locale} 57 | ) 58 | transaction.commit() 59 | 60 | _ = get_translations(locale) 61 | text_to = _("*%(language)s* is your language now.") % { 62 | "language": LOCALE_NAME[locale] 63 | } 64 | 65 | update.message.reply_text(parse_mode=ParseMode.MARKDOWN, text=text_to) 66 | 67 | main_menu(update, chat_info, _) 68 | 69 | return SettingsSteps.main 70 | -------------------------------------------------------------------------------- /app/callbacks/personal_settings/main.py: -------------------------------------------------------------------------------- 1 | from gettext import gettext 2 | 3 | from telegram import ReplyKeyboardMarkup, Update 4 | from telegram.ext import CallbackContext 5 | 6 | from app.decorators import chat_language, register_update 7 | 8 | 9 | class SettingsSteps(object): 10 | main = 0 11 | language = 1 12 | default_currency = 2 13 | default_currency_position = 3 14 | onscreen_menu = 4 15 | onscreen_menu_visibility = 5 16 | onscreen_menu_edit_history = 6 17 | onscreen_menu_size = 7 18 | 19 | 20 | def main_menu(update: Update, chat_info: dict, _: gettext): 21 | update.message.reply_text( 22 | reply_markup=ReplyKeyboardMarkup( 23 | [ 24 | ["1. " + _("Language")], 25 | ["2. " + _("Default currency")], 26 | ["3. " + _("Default currency position")], 27 | ["4. " + _("On-screen menu below")], 28 | ["↩️"], 29 | ] 30 | ), 31 | text=_("What do you want to set up?"), 32 | ) 33 | 34 | 35 | @register_update 36 | @chat_language 37 | def settings_callback( 38 | update: Update, context: CallbackContext, chat_info: dict, _: gettext 39 | ): 40 | chat_id = update.message.chat_id 41 | 42 | if chat_id < 0: 43 | update.message.reply_text(_("The command is not available for group chats")) 44 | return 45 | 46 | main_menu(update, chat_info, _) 47 | 48 | return SettingsSteps.main 49 | -------------------------------------------------------------------------------- /app/callbacks/sources.py: -------------------------------------------------------------------------------- 1 | from telegram import ParseMode, Update 2 | from telegram.ext import CallbackContext, ConversationHandler 3 | 4 | from app.decorators import register_update 5 | 6 | 7 | @register_update 8 | def sources_callback(update: Update, context: CallbackContext, chat_info: dict): 9 | update.message.reply_text( 10 | disable_web_page_preview=True, 11 | parse_mode=ParseMode.MARKDOWN, 12 | text="""*Sources* 13 | 14 | https://bitfinex.com - 15min (API limits😭) 15 | [https://bittrex.com](https://bittrex.com/Account/Register?referralCode=YIV-CNI-13Q)- 1min 16 | [https://satang.pro](https://satang.pro/signup?referral=STZ3EEU2) - 1min 17 | [https://bitkub.com](https://www.bitkub.com/signup?ref=64572) - 1min 18 | https://sp-today.com - Aleppo - 60min 19 | https://fixer.io - 3hour 20 | https://openexchangerates.org - 60min""", 21 | ) 22 | 23 | return ConversationHandler.END 24 | -------------------------------------------------------------------------------- /app/callbacks/start.py: -------------------------------------------------------------------------------- 1 | from gettext import gettext 2 | 3 | import transaction 4 | from sqlalchemy.sql import true 5 | from telegram import ReplyKeyboardMarkup, Update 6 | from telegram.ext import CallbackContext, ConversationHandler 7 | 8 | from app.callbacks.tutorial import tutorial 9 | from app.decorators import chat_language, register_update 10 | from app.logic import get_keyboard 11 | from app.models import Chat 12 | from suite.database import Session 13 | 14 | 15 | @register_update 16 | @chat_language 17 | def start_callback( 18 | update: Update, context: CallbackContext, chat_info: dict, _: gettext 19 | ): 20 | if update.message.chat.type == "private": 21 | name = update.message.from_user.first_name 22 | else: 23 | name = _("humans") 24 | 25 | update.message.reply_text(text=_("Hello, %(name)s!") % {"name": name}) 26 | 27 | if chat_info["created"]: 28 | tutorial(update, _) 29 | 30 | else: 31 | if not chat_info["is_subscribed"]: 32 | Session().query(Chat).filter_by(id=update.message.chat_id).update( 33 | {"is_subscribed": true()} 34 | ) 35 | transaction.commit() 36 | 37 | keyboard = get_keyboard(update.message.chat_id) 38 | 39 | update.message.reply_text( 40 | reply_markup=ReplyKeyboardMarkup(keyboard) if keyboard else None, 41 | text=_("Have any question how to talk with me? 👉 /tutorial"), 42 | ) 43 | 44 | return ConversationHandler.END 45 | -------------------------------------------------------------------------------- /app/callbacks/stop.py: -------------------------------------------------------------------------------- 1 | from gettext import gettext 2 | 3 | import transaction 4 | from sqlalchemy.sql import false, true 5 | from telegram import Update 6 | from telegram.ext import CallbackContext, ConversationHandler 7 | 8 | from app.decorators import chat_language, register_update 9 | from app.models import Chat, Notification 10 | from suite.database import Session 11 | 12 | 13 | @register_update 14 | @chat_language 15 | def stop_callback( 16 | update: Update, context: CallbackContext, chat_info: dict, _: gettext 17 | ): 18 | if chat_info["is_subscribed"]: 19 | Session().query(Chat).filter_by(id=update.message.chat_id).update( 20 | {"is_subscribed": false()} 21 | ) 22 | transaction.commit() 23 | 24 | update.message.reply_text( 25 | text=_("You're unsubscribed. You always can subscribe again 👉 /start") 26 | ) 27 | 28 | Session().query(Notification).filter_by( 29 | is_active=true(), chat_id=update.message.chat_id 30 | ).update({"is_active": false()}) 31 | 32 | transaction.commit() 33 | 34 | return ConversationHandler.END 35 | -------------------------------------------------------------------------------- /app/callbacks/tutorial.py: -------------------------------------------------------------------------------- 1 | from gettext import gettext 2 | 3 | from telegram import ParseMode, ReplyKeyboardMarkup, Update 4 | from telegram.ext import CallbackContext, ConversationHandler 5 | 6 | from app.decorators import chat_language, register_update 7 | from app.logic import get_keyboard 8 | 9 | 10 | def tutorial(update, _): 11 | text_to = _("I am bot. I will help you to know a current exchange rates.") 12 | text_to += "\n\n" 13 | text_to += _( 14 | """Send me a message like this: 15 | *BTC USD* - to see the current exchange rate for pair 16 | *100 USD EUR* - to convert the amount from 100 USD to EUR""" 17 | ) 18 | text_to += "\n\n" 19 | text_to += _("Just text me message in private chat.") 20 | text_to += "\n\n" 21 | text_to += _( 22 | "In group chats use commands like this: 👉 /p USD EUR 👈 or simply /USDEUR" 23 | ) 24 | text_to += "\n\n" 25 | text_to += _("Inline mode is available. See how to use [here](%(link)s).") % { 26 | "link": "https://telegram.org/blog/inline-bots" 27 | } 28 | text_to += "\n\n" 29 | text_to += _("Also take a look here 👉 /help") 30 | 31 | keyboard = get_keyboard(update.message.chat_id) 32 | 33 | update.message.reply_text( 34 | reply_markup=ReplyKeyboardMarkup(keyboard) if keyboard else None, 35 | parse_mode=ParseMode.MARKDOWN, 36 | disable_web_page_preview=True, 37 | text=text_to, 38 | ) 39 | 40 | 41 | @register_update 42 | @chat_language 43 | def tutorial_callback( 44 | update: Update, context: CallbackContext, chat_info: dict, _: gettext 45 | ): 46 | tutorial(update, _) 47 | return ConversationHandler.END 48 | -------------------------------------------------------------------------------- /app/celery.py: -------------------------------------------------------------------------------- 1 | from celery import Celery 2 | from sqlalchemy import create_engine 3 | 4 | from app.sentry import init_sentry 5 | from app.translations import init_translations 6 | from suite.conf import settings 7 | from suite.database import init_sqlalchemy 8 | 9 | init_sentry() 10 | 11 | celery_app = Celery() 12 | celery_app.config_from_object(settings) 13 | celery_app.conf.ONCE = { 14 | "backend": "celery_once.backends.Redis", 15 | "settings": { 16 | "url": settings.CELERY_ONCE_URL, 17 | "default_timeout": settings.CELERY_ONCE_DEFAULT_TIMEOUT, 18 | }, 19 | } 20 | 21 | db_engine = create_engine(settings.DATABASE["url"]) 22 | init_sqlalchemy(db_engine) 23 | 24 | init_translations() 25 | -------------------------------------------------------------------------------- /app/constants.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | 3 | arrows = {"up": "⬆️", "down": "🔻"} 4 | 5 | decimal_precision = 24 6 | decimal_scale = 12 7 | 8 | BIGGEST_VALUE = Decimal(10 ** decimal_scale) - 1 9 | 10 | DONATION_EMOJIS = ["❤", "️🙏", "🛠", "🤘", "🤘🏿", "🍺", "☕", "️🥃", "👀", "👍", "🍔", "🍕"] 11 | -------------------------------------------------------------------------------- /app/converter/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/llybin/TelegramExchangeRatesBot/7cd5e8173915dd56d2c360595cf14f8850d6e853/app/converter/__init__.py -------------------------------------------------------------------------------- /app/converter/base.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from decimal import Decimal 3 | from typing import NamedTuple 4 | 5 | from app.parsers.base import PriceRequest 6 | 7 | 8 | class PriceRequestResult(NamedTuple): 9 | price_request: PriceRequest 10 | exchanges: list 11 | rate: Decimal 12 | last_trade_at: datetime 13 | rate_open: Decimal or None = None 14 | low24h: Decimal or None = None 15 | high24h: Decimal or None = None 16 | -------------------------------------------------------------------------------- /app/converter/converter.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from decimal import Decimal 3 | 4 | import sqlalchemy as sa 5 | from sqlalchemy import orm 6 | 7 | from app.constants import BIGGEST_VALUE 8 | from app.converter.base import PriceRequestResult 9 | from app.converter.exceptions import NoRatesException, OverflowException 10 | from app.models import Currency, Exchange, Rate 11 | from app.parsers.base import PriceRequest 12 | from suite.database import Session 13 | 14 | 15 | def convert(price_request: PriceRequest) -> PriceRequestResult: 16 | if price_request.currency == price_request.to_currency: 17 | return PriceRequestResult( 18 | price_request=price_request, 19 | exchanges=["Baba Vanga"], 20 | rate=Decimal("1"), 21 | last_trade_at=datetime(1996, 8, 11), 22 | ) 23 | 24 | if price_request.amount == 0: 25 | return PriceRequestResult( 26 | price_request=price_request, 27 | exchanges=["Baba Vanga"], 28 | rate=Decimal("0"), 29 | last_trade_at=datetime(1996, 8, 11), 30 | ) 31 | 32 | from_currency = Session.query(Currency).filter_by(code=price_request.currency).one() 33 | to_currency = ( 34 | Session.query(Currency).filter_by(code=price_request.to_currency).one() 35 | ) 36 | 37 | rate_obj = ( 38 | Session.query(Rate) 39 | .filter_by(from_currency=from_currency, to_currency=to_currency) 40 | .join(Exchange) 41 | .filter(Exchange.is_active == sa.true()) 42 | .order_by(sa.desc(Exchange.weight)) 43 | .first() 44 | ) 45 | 46 | if rate_obj: 47 | price_request_result = PriceRequestResult( 48 | price_request=price_request, 49 | exchanges=[rate_obj.exchange.name], 50 | rate=rate_obj.rate, 51 | rate_open=rate_obj.rate_open, 52 | last_trade_at=rate_obj.last_trade_at, 53 | low24h=rate_obj.low24h, 54 | high24h=rate_obj.high24h, 55 | ) 56 | 57 | else: 58 | rate0_model = orm.aliased(Rate) 59 | rate1_model = orm.aliased(Rate) 60 | exchange0_model = orm.aliased(Exchange) 61 | exchange1_model = orm.aliased(Exchange) 62 | 63 | rate_obj = ( 64 | Session.query( 65 | rate0_model, 66 | rate1_model, 67 | (exchange0_model.weight + exchange1_model.weight).label("w"), 68 | ) 69 | .filter_by(from_currency=from_currency) 70 | .join( 71 | rate1_model, 72 | sa.and_( 73 | rate1_model.from_currency_id == rate0_model.to_currency_id, 74 | rate1_model.to_currency == to_currency, 75 | ), 76 | ) 77 | .join( 78 | exchange0_model, 79 | sa.and_( 80 | exchange0_model.id == rate0_model.exchange_id, 81 | exchange0_model.is_active == sa.true(), 82 | ), 83 | ) 84 | .join( 85 | exchange1_model, 86 | sa.and_( 87 | exchange1_model.id == rate1_model.exchange_id, 88 | exchange1_model.is_active == sa.true(), 89 | ), 90 | ) 91 | .order_by(sa.desc("w")) 92 | .first() 93 | ) 94 | 95 | if rate_obj: 96 | rate = combine_values(rate_obj[0].rate, rate_obj[1].rate) 97 | rate_open = combine_values(rate_obj[0].rate_open, rate_obj[1].rate_open) 98 | low24h = high24h = None 99 | 100 | price_request_result = PriceRequestResult( 101 | price_request=price_request, 102 | exchanges=[rate_obj[0].exchange.name, rate_obj[1].exchange.name], 103 | rate=rate, 104 | rate_open=rate_open, 105 | last_trade_at=min(rate_obj[0].last_trade_at, rate_obj[1].last_trade_at), 106 | low24h=low24h, 107 | high24h=high24h, 108 | ) 109 | 110 | else: 111 | raise NoRatesException 112 | 113 | check_overflow(price_request_result) 114 | 115 | return price_request_result 116 | 117 | 118 | def check_overflow(prr: PriceRequestResult): 119 | for a in ["rate", "rate_open", "low24h", "high24h"]: 120 | value = getattr(prr, a) 121 | if value and value > BIGGEST_VALUE: 122 | raise OverflowException 123 | 124 | 125 | def combine_values( 126 | value0: Decimal or None, value1: Decimal or None 127 | ) -> (Decimal or None): 128 | if value0 and value1: 129 | return value0 * value1 130 | else: 131 | return None 132 | -------------------------------------------------------------------------------- /app/converter/exceptions.py: -------------------------------------------------------------------------------- 1 | class ConverterException(Exception): 2 | pass 3 | 4 | 5 | class NoRatesException(ConverterException): 6 | pass 7 | 8 | 9 | class OverflowException(ConverterException): 10 | pass 11 | -------------------------------------------------------------------------------- /app/decorators.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from functools import wraps 3 | 4 | import transaction 5 | from sqlalchemy.exc import IntegrityError 6 | from telegram import Update 7 | from telegram.ext import CallbackContext 8 | 9 | from app.models import Chat 10 | from app.translations import get_translations 11 | from suite.conf import settings 12 | from suite.database import Session 13 | 14 | 15 | def register_update(func): 16 | @wraps(func) 17 | def wrapper(update: Update, context: CallbackContext, *args, **kwargs): 18 | if not update.effective_user: 19 | # bots, may be exclude in filter messages 20 | return 21 | 22 | if update.effective_chat: 23 | # we need settings for a group chats, not for a specific user 24 | # private chat id == user id 25 | chat_id = update.effective_chat.id 26 | else: 27 | # inline commands, get settings for his private chat 28 | chat_id = update.effective_user.id 29 | 30 | if update.effective_user.language_code: 31 | # chats don't have language_code, that why we take from user, not so correct yes 32 | # they will able change language later 33 | # https://en.wikipedia.org/wiki/IETF_language_tag 34 | language_code = update.effective_user.language_code.lower() 35 | else: 36 | # some users don't have locale, set default 37 | language_code = settings.LANGUAGE_CODE 38 | 39 | db_session = Session() 40 | 41 | chat = db_session.query(Chat).filter_by(id=chat_id).first() 42 | 43 | if not chat: 44 | chat = Chat( 45 | id=chat_id, 46 | locale=language_code, 47 | is_show_keyboard=True 48 | if chat_id > 0 49 | else False, # never show keyboard for a group chats 50 | ) 51 | db_session.add(chat) 52 | try: 53 | transaction.commit() 54 | chat_created = True 55 | except IntegrityError: 56 | chat_created = False 57 | logging.exception("Error create chat, chat exists") 58 | transaction.abort() 59 | finally: 60 | chat = db_session.query(Chat).filter_by(id=chat_id).one() 61 | else: 62 | chat_created = False 63 | 64 | kwargs["chat_info"] = { 65 | "chat_id": chat.id, 66 | "created": chat_created, 67 | "locale": chat.locale, 68 | "is_subscribed": chat.is_subscribed, 69 | "is_show_keyboard": chat.is_show_keyboard, 70 | "keyboard_size": chat.keyboard_size, 71 | "default_currency": chat.default_currency, 72 | "default_currency_position": chat.default_currency_position, 73 | } 74 | 75 | return func(update, context, *args, **kwargs) 76 | 77 | return wrapper 78 | 79 | 80 | def chat_language(func): 81 | @wraps(func) 82 | def wrapper(update: Update, context: CallbackContext, *args, **kwargs): 83 | language_code = kwargs["chat_info"]["locale"] 84 | 85 | kwargs["_"] = get_translations(language_code) 86 | 87 | return func(update, context, *args, **kwargs) 88 | 89 | return wrapper 90 | -------------------------------------------------------------------------------- /app/exceptions.py: -------------------------------------------------------------------------------- 1 | class CurrencyNotSupportedException(Exception): 2 | pass 3 | 4 | 5 | class PriceRequesterException(Exception): 6 | pass 7 | 8 | 9 | class EmptyPriceRequestException(PriceRequesterException): 10 | pass 11 | -------------------------------------------------------------------------------- /app/exchanges/__init__.py: -------------------------------------------------------------------------------- 1 | from .bitfinex import BitfinexExchange 2 | from .bitkub import BitkubExchange 3 | from .bittrex import BittrexExchange 4 | from .fixer import FixerExchange 5 | from .openexchangerates import OpenExchangeRatesExchange 6 | from .satang import SatangExchange 7 | from .sp_today import SpTodayExchange 8 | 9 | __all__ = [ 10 | "BitfinexExchange", 11 | "BittrexExchange", 12 | "OpenExchangeRatesExchange", 13 | "FixerExchange", 14 | "BitkubExchange", 15 | "SpTodayExchange", 16 | "SatangExchange", 17 | ] 18 | -------------------------------------------------------------------------------- /app/exchanges/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from datetime import datetime 3 | from decimal import Decimal 4 | from typing import NamedTuple, Tuple 5 | 6 | 7 | class ECurrency(NamedTuple): 8 | code: str 9 | 10 | def __str__(self): 11 | return self.code 12 | 13 | 14 | class Pair(NamedTuple): 15 | from_currency: ECurrency 16 | to_currency: ECurrency 17 | 18 | def __str__(self): 19 | return f"{self.from_currency}-{self.to_currency}" 20 | 21 | 22 | class PairData(NamedTuple): 23 | pair: Pair 24 | rate: Decimal 25 | last_trade_at: datetime 26 | rate_open: Decimal or None = None 27 | low24h: Decimal or None = None 28 | high24h: Decimal or None = None 29 | 30 | 31 | def reverse_pair(pair: Pair) -> Pair: 32 | return Pair(pair.to_currency, pair.from_currency) 33 | 34 | 35 | def reverse_amount(rate: Decimal) -> Decimal or None: 36 | if not rate: 37 | return rate 38 | 39 | return Decimal("1") / rate 40 | 41 | 42 | def reverse_pair_data(pair_data: PairData) -> PairData: 43 | return PairData( 44 | pair=reverse_pair(pair_data.pair), 45 | rate=reverse_amount(pair_data.rate), 46 | last_trade_at=pair_data.last_trade_at, 47 | rate_open=reverse_amount(pair_data.rate_open), 48 | low24h=reverse_amount(pair_data.low24h), 49 | high24h=reverse_amount(pair_data.high24h), 50 | ) 51 | 52 | 53 | class Exchange(ABC): 54 | included_reversed_pairs = False 55 | 56 | @property 57 | @abstractmethod 58 | def name(self) -> str: 59 | pass 60 | 61 | @property 62 | @abstractmethod 63 | def list_pairs(self) -> Tuple[Pair]: 64 | pass 65 | 66 | @property 67 | @abstractmethod 68 | def list_currencies(self) -> Tuple[ECurrency]: 69 | pass 70 | 71 | def is_pair_exists(self, pair: Pair) -> bool: 72 | return pair in self.list_pairs 73 | 74 | def is_currency_exists(self, currency: ECurrency) -> bool: 75 | return currency in self.list_currencies 76 | 77 | @abstractmethod 78 | def get_pair_info(self, pair: Pair) -> PairData: 79 | pass 80 | -------------------------------------------------------------------------------- /app/exchanges/bitfinex.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from decimal import Decimal, DecimalException 3 | from typing import Tuple 4 | 5 | import requests 6 | from cached_property import cached_property 7 | from jsonschema import ValidationError, validate 8 | from ratelimit import limits, sleep_and_retry 9 | 10 | from app.exchanges.base import ECurrency, Exchange, Pair, PairData 11 | from app.exchanges.exceptions import APIErrorException, PairNotExistsException 12 | 13 | 14 | class BitfinexExchange(Exchange): 15 | """ 16 | https://docs.bitfinex.com 17 | 18 | If an IP address exceeds a certain number of requests per minute (between 10 and 90) 19 | to a specific REST API endpoint e.g., /ticker, the requesting IP address will be blocked 20 | for 10-60 seconds on that endpoint and the JSON response {"error": "ERR_RATE_LIMIT"} will be returned. 21 | """ 22 | 23 | name = "Bitfinex" 24 | 25 | @cached_property 26 | def _get_pairs(self) -> tuple: 27 | try: 28 | response = requests.get("https://api.bitfinex.com/v1/symbols") 29 | response.raise_for_status() 30 | pairs = response.json() 31 | except (requests.exceptions.RequestException, ValueError) as e: 32 | raise APIErrorException(e) 33 | 34 | try: 35 | schema = {"type": "array", "items": {"type": "string"}} 36 | validate(pairs, schema) 37 | except ValidationError as e: 38 | raise APIErrorException(e) 39 | 40 | return tuple(pairs) 41 | 42 | @cached_property 43 | def list_pairs(self) -> Tuple[Pair]: 44 | pairs = set() 45 | 46 | for x in self._get_pairs: 47 | x = x.upper() 48 | 49 | if ":" in x: 50 | from_currency, to_currency = x.split(":") 51 | else: 52 | from_currency, to_currency = x[:3], x[3:] 53 | 54 | pairs.add(Pair(ECurrency(from_currency), ECurrency(to_currency))) 55 | 56 | return tuple(pairs) 57 | 58 | @cached_property 59 | def list_currencies(self) -> Tuple[ECurrency]: 60 | currencies = set() 61 | 62 | for from_currency, to_currency in self.list_pairs: 63 | currencies.add(from_currency) 64 | currencies.add(to_currency) 65 | 66 | return tuple(currencies) 67 | 68 | @sleep_and_retry 69 | @limits(calls=1, period=5) 70 | def get_pair_info(self, pair: Pair) -> PairData: 71 | if not self.is_pair_exists(pair): 72 | raise PairNotExistsException(pair) 73 | 74 | request_pair = f"{pair.from_currency}{pair.to_currency}".lower() 75 | 76 | try: 77 | response = requests.get( 78 | f"https://api.bitfinex.com/v1/pubticker/{request_pair}" 79 | ) 80 | response.raise_for_status() 81 | data = response.json() 82 | except (requests.exceptions.RequestException, ValueError) as e: 83 | raise APIErrorException(e) 84 | 85 | try: 86 | schema = { 87 | "type": "object", 88 | "properties": { 89 | "mid": {"type": "string"}, 90 | "low": {"type": "string"}, 91 | "high": {"type": "string"}, 92 | "timestamp": {"type": "string"}, 93 | }, 94 | "required": ["mid", "low", "high", "timestamp"], 95 | } 96 | validate(data, schema) 97 | except ValidationError as e: 98 | raise APIErrorException(e) 99 | 100 | try: 101 | rate = Decimal(data["mid"]) 102 | low = Decimal(data["low"]) 103 | high = Decimal(data["high"]) 104 | last_trade_at = float(data["timestamp"]) 105 | except (DecimalException, ValueError) as e: 106 | raise APIErrorException(e) 107 | 108 | return PairData( 109 | pair=pair, 110 | rate=rate, 111 | low24h=low, 112 | high24h=high, 113 | last_trade_at=datetime.fromtimestamp(last_trade_at), 114 | ) 115 | -------------------------------------------------------------------------------- /app/exchanges/bitkub.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime 3 | from decimal import Decimal 4 | from typing import Tuple 5 | 6 | import requests 7 | from cached_property import cached_property 8 | from jsonschema import ValidationError, validate 9 | 10 | from app.exchanges.base import ECurrency, Exchange, Pair, PairData 11 | from app.exchanges.exceptions import APIErrorException, PairNotExistsException 12 | from app.queries import get_all_currency_codes 13 | 14 | 15 | class BitkubExchange(Exchange): 16 | """ 17 | https://github.com/bitkub/bitkub-official-api-docs 18 | """ 19 | 20 | name = "[bitkub.com](https://www.bitkub.com/signup?ref=64572)" 21 | 22 | @cached_property 23 | def _get_data(self) -> dict: 24 | try: 25 | response = requests.get("https://api.bitkub.com/api/market/ticker") 26 | response.raise_for_status() 27 | data = response.json() 28 | except (requests.exceptions.RequestException, ValueError) as e: 29 | raise APIErrorException(e) 30 | 31 | try: 32 | schema = { 33 | "type": "object", 34 | "patternProperties": { 35 | r"^.*_.*$": { 36 | "type": "object", 37 | "properties": { 38 | "lowestAsk": {"type": "number"}, 39 | "highestBid": {"type": "number"}, 40 | "isFrozen": {"type": "number"}, 41 | "low24hr": {"type": "number"}, 42 | "high24hr": {"type": "number"}, 43 | }, 44 | "required": [ 45 | "lowestAsk", 46 | "highestBid", 47 | "isFrozen", 48 | "low24hr", 49 | "high24hr", 50 | ], 51 | }, 52 | "not": {"required": ["error", ""]}, 53 | }, 54 | } 55 | validate(data, schema) 56 | except ValidationError as e: 57 | raise APIErrorException(e) 58 | 59 | result = {} 60 | all_currency_codes = get_all_currency_codes() 61 | for currencies, info in data.items(): 62 | if info["isFrozen"]: 63 | logging.info("Bitkub isFrozen: %s", currencies) 64 | continue 65 | 66 | # reverse 67 | to_currency, from_currency = currencies.split("_") 68 | 69 | if not info["lowestAsk"] or not info["highestBid"]: 70 | if ( 71 | to_currency in all_currency_codes 72 | and from_currency in all_currency_codes 73 | ): 74 | logging.info("Bitkub no Bid Ask: %s", info) 75 | continue 76 | 77 | result[Pair(ECurrency(from_currency), ECurrency(to_currency))] = info 78 | 79 | return result 80 | 81 | @cached_property 82 | def list_pairs(self) -> Tuple[Pair]: 83 | return tuple(self._get_data.keys()) 84 | 85 | @cached_property 86 | def list_currencies(self) -> Tuple[ECurrency]: 87 | currencies = set() 88 | 89 | for from_currency, to_currency in self.list_pairs: 90 | currencies.add(from_currency) 91 | currencies.add(to_currency) 92 | 93 | return tuple(currencies) 94 | 95 | def get_pair_info(self, pair: Pair) -> PairData: 96 | if not self.is_pair_exists(pair): 97 | raise PairNotExistsException(pair) 98 | 99 | pair_data = self._get_data[pair] 100 | 101 | mid = ( 102 | Decimal(str(pair_data["lowestAsk"])) + Decimal(str(pair_data["highestBid"])) 103 | ) / Decimal("2") 104 | 105 | if pair_data["low24hr"] and pair_data["high24hr"]: 106 | low24h = Decimal(str(pair_data["low24hr"])) 107 | high24h = Decimal(str(pair_data["high24hr"])) 108 | else: 109 | low24h = high24h = None 110 | 111 | return PairData( 112 | pair=pair, 113 | rate=mid, 114 | low24h=low24h, 115 | high24h=high24h, 116 | last_trade_at=datetime.utcnow(), 117 | ) 118 | -------------------------------------------------------------------------------- /app/exchanges/bittrex.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime 3 | from decimal import Decimal 4 | from typing import Tuple 5 | 6 | import requests 7 | from cached_property import cached_property 8 | from jsonschema import ValidationError, validate 9 | 10 | from app.exchanges.base import ECurrency, Exchange, Pair, PairData 11 | from app.exchanges.exceptions import ( 12 | APIChangedException, 13 | APIErrorException, 14 | PairNotExistsException, 15 | ) 16 | from app.queries import get_all_currency_codes 17 | 18 | 19 | class BittrexExchange(Exchange): 20 | """ 21 | https://bittrex.github.io/ 22 | 23 | Maximum of 60 API calls per minute. 24 | Calls after the limit will fail, with the limit resetting at the start of the next minute. 25 | """ 26 | 27 | name = ( 28 | "[bittrex.com](https://bittrex.com/Account/Register?referralCode=YIV-CNI-13Q)" 29 | ) 30 | 31 | @cached_property 32 | def _get_data(self) -> dict: 33 | try: 34 | response = requests.get( 35 | "https://api.bittrex.com/api/v1.1/public/getmarketsummaries" 36 | ) 37 | response.raise_for_status() 38 | data = response.json() 39 | except (requests.exceptions.RequestException, ValueError) as e: 40 | raise APIErrorException(e) 41 | 42 | try: 43 | schema = { 44 | "type": "object", 45 | "properties": { 46 | "success": {"type": "boolean"}, 47 | "result": { 48 | "type": "array", 49 | "items": { 50 | "type": "object", 51 | "properties": { 52 | "MarketName": {"type": "string"}, 53 | "High": {"type": ["number", "null"]}, 54 | "Low": {"type": ["number", "null"]}, 55 | "TimeStamp": {"type": "string"}, 56 | "Bid": {"type": ["number", "null"]}, 57 | "Ask": {"type": ["number", "null"]}, 58 | "PrevDay": {"type": ["number", "null"]}, 59 | }, 60 | "required": [ 61 | "MarketName", 62 | "High", 63 | "Low", 64 | "TimeStamp", 65 | "Bid", 66 | "Ask", 67 | "PrevDay", 68 | ], 69 | }, 70 | }, 71 | }, 72 | "required": ["success", "result"], 73 | } 74 | validate(data, schema) 75 | except ValidationError as e: 76 | raise APIErrorException(e) 77 | 78 | result = {} 79 | all_currency_codes = get_all_currency_codes() 80 | for x in data["result"]: 81 | # reverse 82 | to_currency, from_currency = x["MarketName"].upper().split("-") 83 | 84 | if not x["Bid"] or not x["Ask"]: 85 | if ( 86 | to_currency in all_currency_codes 87 | and from_currency in all_currency_codes 88 | ): 89 | logging.info("Bittrex no Bid Ask: %s", x) 90 | continue 91 | 92 | del x["MarketName"] 93 | result[Pair(ECurrency(from_currency), ECurrency(to_currency))] = x 94 | 95 | return result 96 | 97 | @cached_property 98 | def list_pairs(self) -> Tuple[Pair]: 99 | return tuple(self._get_data.keys()) 100 | 101 | @cached_property 102 | def list_currencies(self) -> Tuple[ECurrency]: 103 | currencies = set() 104 | 105 | for from_currency, to_currency in self.list_pairs: 106 | currencies.add(from_currency) 107 | currencies.add(to_currency) 108 | 109 | return tuple(currencies) 110 | 111 | def get_pair_info(self, pair: Pair) -> PairData: 112 | if not self.is_pair_exists(pair): 113 | raise PairNotExistsException(pair) 114 | 115 | pair_data = self._get_data[pair] 116 | 117 | mid = ( 118 | Decimal(str(pair_data["Bid"])) + Decimal(str(pair_data["Ask"])) 119 | ) / Decimal("2") 120 | 121 | try: 122 | ts_without_ms = pair_data["TimeStamp"].split(".")[0] 123 | last_trade_at = datetime.strptime(ts_without_ms, "%Y-%m-%dT%H:%M:%S") 124 | except ValueError: 125 | raise APIChangedException("TimeStamp format.") 126 | 127 | if pair_data["Low"] and pair_data["High"]: 128 | low24h = Decimal(str(pair_data["Low"])) 129 | high24h = Decimal(str(pair_data["High"])) 130 | else: 131 | low24h = high24h = None 132 | 133 | rate_open = Decimal(str(pair_data["PrevDay"])) if pair_data["PrevDay"] else None 134 | 135 | return PairData( 136 | pair=pair, 137 | rate=mid, 138 | rate_open=rate_open, 139 | low24h=low24h, 140 | high24h=high24h, 141 | last_trade_at=last_trade_at, 142 | ) 143 | -------------------------------------------------------------------------------- /app/exchanges/exceptions.py: -------------------------------------------------------------------------------- 1 | class ExchangeException(Exception): 2 | pass 3 | 4 | 5 | class PairNotExistsException(ExchangeException): 6 | pass 7 | 8 | 9 | class NoTokenException(ExchangeException): 10 | pass 11 | 12 | 13 | class APIErrorException(ExchangeException): 14 | pass 15 | 16 | 17 | class APIChangedException(ExchangeException): 18 | pass 19 | -------------------------------------------------------------------------------- /app/exchanges/fixer.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from decimal import Decimal 3 | from typing import Tuple 4 | 5 | import requests 6 | from cached_property import cached_property 7 | from jsonschema import ValidationError, validate 8 | 9 | from app.exchanges.base import ECurrency, Exchange, Pair, PairData 10 | from app.exchanges.exceptions import ( 11 | APIChangedException, 12 | APIErrorException, 13 | NoTokenException, 14 | PairNotExistsException, 15 | ) 16 | from suite.conf import settings 17 | 18 | 19 | class FixerExchange(Exchange): 20 | """ 21 | https://fixer.io 22 | 23 | Free Plan provides hourly updates up to 1,000 requests/month. 24 | """ 25 | 26 | name = "Fixer" 27 | 28 | @cached_property 29 | def _get_data(self) -> dict: 30 | if not settings.FIXER_TOKEN: 31 | raise NoTokenException 32 | 33 | try: 34 | response = requests.get( 35 | f"http://data.fixer.io/api/latest?access_key={settings.FIXER_TOKEN}" 36 | ) 37 | response.raise_for_status() 38 | data = response.json() 39 | except (requests.exceptions.RequestException, ValueError) as e: 40 | raise APIErrorException(e) 41 | 42 | try: 43 | schema = { 44 | "type": "object", 45 | "properties": { 46 | "base": {"type": "string"}, 47 | "timestamp": {"type": "number"}, 48 | "rates": { 49 | "type": "object", 50 | "patternProperties": {"^.*$": {"type": "number"}}, 51 | "not": {"required": [""]}, 52 | }, 53 | }, 54 | "required": ["base", "timestamp", "rates"], 55 | } 56 | validate(data, schema) 57 | except ValidationError as e: 58 | raise APIErrorException(e) 59 | 60 | if data["base"] != "EUR": 61 | raise APIChangedException("Base currency is not EUR") 62 | 63 | return data 64 | 65 | @cached_property 66 | def list_pairs(self) -> Tuple[Pair]: 67 | currencies = self._get_data["rates"].keys() 68 | base_currency = self._get_data["base"].upper() 69 | 70 | return tuple( 71 | Pair(ECurrency(base_currency), ECurrency(x.upper())) for x in currencies 72 | ) 73 | 74 | @cached_property 75 | def list_currencies(self) -> Tuple[ECurrency]: 76 | currencies = self._get_data["rates"].keys() 77 | base_currency = self._get_data["base"].upper() 78 | 79 | return (ECurrency(base_currency),) + tuple( 80 | ECurrency(x.upper()) for x in currencies 81 | ) 82 | 83 | def get_pair_info(self, pair: Pair) -> PairData: 84 | if not self.is_pair_exists(pair): 85 | raise PairNotExistsException(pair) 86 | 87 | return PairData( 88 | pair=pair, 89 | rate=Decimal(str(self._get_data["rates"][pair.to_currency.code])), 90 | last_trade_at=datetime.fromtimestamp(self._get_data["timestamp"]), 91 | ) 92 | -------------------------------------------------------------------------------- /app/exchanges/openexchangerates.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from decimal import Decimal 3 | from typing import Tuple 4 | 5 | import requests 6 | from cached_property import cached_property 7 | from jsonschema import ValidationError, validate 8 | 9 | from app.exchanges.base import ECurrency, Exchange, Pair, PairData 10 | from app.exchanges.exceptions import ( 11 | APIChangedException, 12 | APIErrorException, 13 | NoTokenException, 14 | PairNotExistsException, 15 | ) 16 | from suite.conf import settings 17 | 18 | 19 | class OpenExchangeRatesExchange(Exchange): 20 | """ 21 | https://openexchangerates.org/ 22 | 23 | Free Plan provides hourly updates up to 1,000 requests/month. 24 | """ 25 | 26 | name = "OpenExchangeRates" 27 | 28 | @cached_property 29 | def _get_data(self) -> dict: 30 | if not settings.OPENEXCHANGERATES_TOKEN: 31 | raise NoTokenException 32 | 33 | try: 34 | response = requests.get( 35 | f"http://openexchangerates.org/api/latest.json?app_id={settings.OPENEXCHANGERATES_TOKEN}" 36 | ) 37 | response.raise_for_status() 38 | data = response.json() 39 | except (requests.exceptions.RequestException, ValueError) as e: 40 | raise APIErrorException(e) 41 | 42 | try: 43 | schema = { 44 | "type": "object", 45 | "properties": { 46 | "base": {"type": "string"}, 47 | "timestamp": {"type": "number"}, 48 | "rates": { 49 | "type": "object", 50 | "patternProperties": {"^.*$": {"type": "number"}}, 51 | "not": {"required": [""]}, 52 | }, 53 | }, 54 | "required": ["base", "timestamp", "rates"], 55 | } 56 | validate(data, schema) 57 | except ValidationError as e: 58 | raise APIErrorException(e) 59 | 60 | if data["base"] != "USD": 61 | raise APIChangedException("Base currency is not USD") 62 | 63 | return data 64 | 65 | @cached_property 66 | def list_pairs(self) -> Tuple[Pair]: 67 | currencies = self._get_data["rates"].keys() 68 | base_currency = self._get_data["base"].upper() 69 | 70 | return tuple( 71 | Pair(ECurrency(base_currency), ECurrency(x.upper())) for x in currencies 72 | ) 73 | 74 | @cached_property 75 | def list_currencies(self) -> Tuple[ECurrency]: 76 | currencies = self._get_data["rates"].keys() 77 | base_currency = self._get_data["base"].upper() 78 | 79 | return (ECurrency(base_currency),) + tuple( 80 | ECurrency(x.upper()) for x in currencies 81 | ) 82 | 83 | def get_pair_info(self, pair: Pair) -> PairData: 84 | if not self.is_pair_exists(pair): 85 | raise PairNotExistsException(pair) 86 | 87 | return PairData( 88 | pair=pair, 89 | rate=Decimal(str(self._get_data["rates"][pair.to_currency.code])), 90 | last_trade_at=datetime.fromtimestamp(self._get_data["timestamp"]), 91 | ) 92 | -------------------------------------------------------------------------------- /app/exchanges/satang.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from decimal import Decimal 3 | from typing import Tuple 4 | 5 | import requests 6 | from cached_property import cached_property 7 | from jsonschema import ValidationError, validate 8 | 9 | from app.exchanges.base import ECurrency, Exchange, Pair, PairData 10 | from app.exchanges.exceptions import APIErrorException, PairNotExistsException 11 | 12 | 13 | class SatangExchange(Exchange): 14 | """ 15 | https://docs.satang.pro/apis 16 | """ 17 | 18 | name = "[satang.pro](https://satang.pro/signup?referral=STZ3EEU2)" 19 | 20 | @cached_property 21 | def _get_data(self) -> dict: 22 | try: 23 | response = requests.get("https://api.tdax.com/api/orderbook-tickers/") 24 | response.raise_for_status() 25 | data = response.json() 26 | except (requests.exceptions.RequestException, ValueError) as e: 27 | raise APIErrorException(e) 28 | 29 | try: 30 | # TODO: [bid][price] [ask][price] 31 | schema = { 32 | "type": "object", 33 | "patternProperties": { 34 | r"^.*_.*$": { 35 | "type": "object", 36 | "properties": { 37 | "bid": {"type": "object"}, 38 | "ask": {"type": "object"}, 39 | }, 40 | "required": ["bid", "ask"], 41 | }, 42 | }, 43 | } 44 | validate(data, schema) 45 | except ValidationError as e: 46 | raise APIErrorException(e) 47 | 48 | result = {} 49 | for currencies, info in data.items(): 50 | from_currency, to_currency = currencies.split("_") 51 | 52 | result[Pair(ECurrency(from_currency), ECurrency(to_currency))] = info 53 | 54 | return result 55 | 56 | @cached_property 57 | def list_pairs(self) -> Tuple[Pair]: 58 | return tuple(self._get_data.keys()) 59 | 60 | @cached_property 61 | def list_currencies(self) -> Tuple[ECurrency]: 62 | currencies = set() 63 | 64 | for from_currency, to_currency in self.list_pairs: 65 | currencies.add(from_currency) 66 | currencies.add(to_currency) 67 | 68 | return tuple(currencies) 69 | 70 | def get_pair_info(self, pair: Pair) -> PairData: 71 | if not self.is_pair_exists(pair): 72 | raise PairNotExistsException(pair) 73 | 74 | pair_data = self._get_data[pair] 75 | 76 | mid = ( 77 | Decimal(str(pair_data["ask"]["price"])) 78 | + Decimal(str(pair_data["bid"]["price"])) 79 | ) / Decimal("2") 80 | 81 | low24h = high24h = None 82 | 83 | return PairData( 84 | pair=pair, 85 | rate=mid, 86 | low24h=low24h, 87 | high24h=high24h, 88 | last_trade_at=datetime.utcnow(), 89 | ) 90 | -------------------------------------------------------------------------------- /app/exchanges/sp_today.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime 3 | from decimal import Decimal 4 | from typing import Tuple 5 | 6 | import requests 7 | from cached_property import cached_property 8 | from jsonschema import ValidationError, validate 9 | 10 | from app.exchanges.base import ECurrency, Exchange, Pair, PairData 11 | from app.exchanges.exceptions import APIErrorException, PairNotExistsException 12 | 13 | 14 | class SpTodayExchange(Exchange): 15 | """ 16 | https://www.sp-today.com 17 | """ 18 | 19 | name = "sp-today" 20 | 21 | @cached_property 22 | def _get_data(self) -> dict: 23 | try: 24 | response = requests.get("https://sp-today.com/app_api/cur_aleppo.json") 25 | response.raise_for_status() 26 | data = response.json() 27 | except (requests.exceptions.RequestException, ValueError) as e: 28 | raise APIErrorException(e) 29 | 30 | try: 31 | schema = { 32 | "type": "array", 33 | "items": { 34 | "type": "object", 35 | "properties": { 36 | "name": {"type": "string"}, 37 | "bid": {"type": "string"}, 38 | "ask": {"type": "string"}, 39 | }, 40 | "required": ["name", "bid", "ask"], 41 | }, 42 | } 43 | validate(data, schema) 44 | except ValidationError as e: 45 | raise APIErrorException(e) 46 | 47 | result = {} 48 | for x in data: 49 | result[Pair(ECurrency(x["name"]), ECurrency("SYP"))] = x 50 | 51 | return result 52 | 53 | @cached_property 54 | def list_pairs(self) -> Tuple[Pair]: 55 | return tuple(self._get_data.keys()) 56 | 57 | @cached_property 58 | def list_currencies(self) -> Tuple[ECurrency]: 59 | currencies = set() 60 | 61 | for from_currency, to_currency in self.list_pairs: 62 | currencies.add(from_currency) 63 | currencies.add(to_currency) 64 | 65 | return tuple(currencies) 66 | 67 | def get_pair_info(self, pair: Pair) -> PairData: 68 | if not self.is_pair_exists(pair): 69 | raise PairNotExistsException(pair) 70 | 71 | pair_data = self._get_data[pair] 72 | 73 | mid = (Decimal(pair_data["ask"]) + Decimal(pair_data["bid"])) / Decimal("2") 74 | 75 | return PairData(pair=pair, rate=mid, last_trade_at=datetime.utcnow()) 76 | -------------------------------------------------------------------------------- /app/exchanges/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/llybin/TelegramExchangeRatesBot/7cd5e8173915dd56d2c360595cf14f8850d6e853/app/exchanges/tests/__init__.py -------------------------------------------------------------------------------- /app/exchanges/tests/fixtures/vcr/bitfinex/get_pair_200.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: ['*/*'] 6 | Accept-Encoding: ['gzip, deflate'] 7 | Connection: [keep-alive] 8 | User-Agent: [python-requests/2.21.0] 9 | method: GET 10 | uri: https://api.bitfinex.com/v1/symbols 11 | response: 12 | body: {string: '["btcusd","ltcusd","ltcbtc","ethusd","ethbtc","etcbtc","etcusd","rrtusd","rrtbtc","zecusd","zecbtc","xmrusd","xmrbtc","dshusd","dshbtc","btceur","btcjpy","xrpusd","xrpbtc","iotusd","iotbtc","ioteth","eosusd","eosbtc","eoseth","sanusd","sanbtc","saneth","omgusd","omgbtc","omgeth","neousd","neobtc","neoeth","etpusd","etpbtc","etpeth","qtmusd","qtmbtc","qtmeth","avtusd","avtbtc","avteth","edousd","edobtc","edoeth","btgusd","btgbtc","datusd","datbtc","dateth","qshusd","qshbtc","qsheth","yywusd","yywbtc","yyweth","gntusd","gntbtc","gnteth","sntusd","sntbtc","snteth","ioteur","batusd","batbtc","bateth","mnausd","mnabtc","mnaeth","funusd","funbtc","funeth","zrxusd","zrxbtc","zrxeth","tnbusd","tnbbtc","tnbeth","spkusd","spkbtc","spketh","trxusd","trxbtc","trxeth","rcnusd","rcnbtc","rcneth","rlcusd","rlcbtc","rlceth","aidusd","aidbtc","aideth","sngusd","sngbtc","sngeth","repusd","repbtc","repeth","elfusd","elfbtc","elfeth","btcgbp","etheur","ethjpy","ethgbp","neoeur","neojpy","neogbp","eoseur","eosjpy","eosgbp","iotjpy","iotgbp","iosusd","iosbtc","ioseth","aiousd","aiobtc","aioeth","requsd","reqbtc","reqeth","rdnusd","rdnbtc","rdneth","lrcusd","lrcbtc","lrceth","waxusd","waxbtc","waxeth","daiusd","daibtc","daieth","agiusd","agibtc","agieth","bftusd","bftbtc","bfteth","mtnusd","mtnbtc","mtneth","odeusd","odebtc","odeeth","antusd","antbtc","anteth","dthusd","dthbtc","dtheth","mitusd","mitbtc","miteth","stjusd","stjbtc","stjeth","xlmusd","xlmeur","xlmjpy","xlmgbp","xlmbtc","xlmeth","xvgusd","xvgeur","xvgjpy","xvggbp","xvgbtc","xvgeth","bciusd","bcibtc","mkrusd","mkrbtc","mkreth","kncusd","kncbtc","knceth","poausd","poabtc","poaeth","lymusd","lymbtc","lymeth","utkusd","utkbtc","utketh","veeusd","veebtc","veeeth","dadusd","dadbtc","dadeth","orsusd","orsbtc","orseth","aucusd","aucbtc","auceth","poyusd","poybtc","poyeth","fsnusd","fsnbtc","fsneth","cbtusd","cbtbtc","cbteth","zcnusd","zcnbtc","zcneth","senusd","senbtc","seneth","ncausd","ncabtc","ncaeth","cndusd","cndbtc","cndeth","ctxusd","ctxbtc","ctxeth","paiusd","paibtc","seeusd","seebtc","seeeth","essusd","essbtc","esseth","atmusd","atmbtc","atmeth","hotusd","hotbtc","hoteth","dtausd","dtabtc","dtaeth","iqxusd","iqxbtc","iqxeos","wprusd","wprbtc","wpreth","zilusd","zilbtc","zileth","bntusd","bntbtc","bnteth","absusd","abseth","xrausd","xraeth","manusd","maneth","bbnusd","bbneth","niousd","nioeth","dgxusd","dgxeth","vetusd","vetbtc","veteth","utnusd","utneth","tknusd","tkneth","gotusd","goteur","goteth","xtzusd","xtzbtc","cnnusd","cnneth","boxusd","boxeth","trxeur","trxgbp","trxjpy","mgousd","mgoeth","rteusd","rteeth","yggusd","yggeth","mlnusd","mlneth","wtcusd","wtceth","csxusd","csxeth","omnusd","omnbtc","intusd","inteth","drnusd","drneth","pnkusd","pnketh","dgbusd","dgbbtc","bsvusd","bsvbtc","babusd","babbtc","wlousd","wloxlm","vldusd","vldeth","enjusd","enjeth","onlusd","onleth","rbtusd","rbtbtc","ustusd","euteur","eutusd","gsdusd","udcusd","tsdusd","paxusd","rifusd","rifbtc","pasusd","paseth","vsyusd","vsybtc","zrxdai","mkrdai","omgdai","bttusd","bttbtc"]'} 13 | headers: 14 | CF-RAY: [4b4caa380b5b8365-BKK] 15 | Cache-Control: ['max-age=0, private, must-revalidate'] 16 | Connection: [keep-alive] 17 | Content-Type: [application/json; charset=utf-8] 18 | Date: ['Sat, 09 Mar 2019 11:21:22 GMT'] 19 | ETag: [W/"f1653a629945492e3f044f55f5b45226"] 20 | Expect-CT: ['max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct"'] 21 | Server: [cloudflare] 22 | Set-Cookie: ['__cfduid=d913febe752136293b56f80000e4f4d721552130481; expires=Sun, 23 | 08-Mar-20 11:21:21 GMT; path=/; domain=.bitfinex.com; HttpOnly'] 24 | Strict-Transport-Security: [max-age=31536000] 25 | Transfer-Encoding: [chunked] 26 | Vary: [Accept-Encoding] 27 | X-Content-Type-Options: [nosniff] 28 | X-Download-Options: [noopen] 29 | X-Frame-Options: [SAMEORIGIN] 30 | X-Permitted-Cross-Domain-Policies: [none] 31 | X-Request-Id: [bdbafbde-1f06-4296-8642-8cfab734d254] 32 | X-Runtime: ['0.003918'] 33 | X-XSS-Protection: [1; mode=block] 34 | content-length: ['3052'] 35 | status: {code: 200, message: OK} 36 | - request: 37 | body: null 38 | headers: 39 | Accept: ['*/*'] 40 | Accept-Encoding: ['gzip, deflate'] 41 | Connection: [keep-alive] 42 | User-Agent: [python-requests/2.21.0] 43 | method: GET 44 | uri: https://api.bitfinex.com/v1/pubticker/btcusd 45 | response: 46 | body: {string: '{"mid":"3996.05","bid":"3996.0","ask":"3996.1","last_price":"3996.1","low":"3850.0","high":"4021.0","volume":"9567.9947337399999735","timestamp":"1552130472.9966455"}'} 47 | headers: 48 | CF-RAY: [4b4caa3a4bf1838f-BKK] 49 | Cache-Control: ['max-age=0, private, must-revalidate'] 50 | Connection: [keep-alive] 51 | Content-Type: [application/json; charset=utf-8] 52 | Date: ['Sat, 09 Mar 2019 11:21:22 GMT'] 53 | ETag: [W/"260eb3d96bf96ed24e6757c36d449a44"] 54 | Expect-CT: ['max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct"'] 55 | Server: [cloudflare] 56 | Set-Cookie: ['__cfduid=dcb8e8cb99775bea0af1ce315fb9384941552130482; expires=Sun, 57 | 08-Mar-20 11:21:22 GMT; path=/; domain=.bitfinex.com; HttpOnly'] 58 | Strict-Transport-Security: [max-age=31536000] 59 | Transfer-Encoding: [chunked] 60 | X-Content-Type-Options: [nosniff] 61 | X-Download-Options: [noopen] 62 | X-Frame-Options: [SAMEORIGIN] 63 | X-Permitted-Cross-Domain-Policies: [none] 64 | X-Request-Id: [aa589fd7-14e7-4734-9c11-ff2aaa48658f] 65 | X-Runtime: ['0.004814'] 66 | X-XSS-Protection: [1; mode=block] 67 | content-length: ['166'] 68 | status: {code: 200, message: OK} 69 | version: 1 70 | -------------------------------------------------------------------------------- /app/exchanges/tests/fixtures/vcr/bitfinex/symbols_200.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - '*/*' 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Connection: 10 | - keep-alive 11 | User-Agent: 12 | - python-requests/2.22.0 13 | method: GET 14 | uri: https://api.bitfinex.com/v1/symbols 15 | response: 16 | body: 17 | string: '["btcusd","ltcusd","ltcbtc","ethusd","ethbtc","etcbtc","etcusd","rrtusd","rrtbtc","zecusd","zecbtc","xmrusd","xmrbtc","dshusd","dshbtc","btceur","btcjpy","xrpusd","xrpbtc","iotusd","iotbtc","ioteth","eosusd","eosbtc","eoseth","sanusd","sanbtc","saneth","omgusd","omgbtc","omgeth","neousd","neobtc","neoeth","etpusd","etpbtc","etpeth","qtmusd","qtmbtc","qtmeth","avtusd","avtbtc","avteth","edousd","edobtc","edoeth","btgusd","btgbtc","datusd","datbtc","dateth","qshusd","qshbtc","qsheth","yywusd","yywbtc","yyweth","gntusd","gntbtc","gnteth","sntusd","sntbtc","snteth","ioteur","batusd","batbtc","bateth","mnausd","mnabtc","mnaeth","funusd","funbtc","funeth","zrxusd","zrxbtc","zrxeth","tnbusd","tnbbtc","tnbeth","spkusd","spkbtc","spketh","trxusd","trxbtc","trxeth","rcnusd","rcnbtc","rcneth","rlcusd","rlcbtc","rlceth","aidusd","aidbtc","aideth","sngusd","sngbtc","sngeth","repusd","repbtc","repeth","elfusd","elfbtc","elfeth","necusd","necbtc","neceth","btcgbp","etheur","ethjpy","ethgbp","neoeur","neojpy","neogbp","eoseur","eosjpy","eosgbp","iotjpy","iotgbp","iosusd","iosbtc","ioseth","aiousd","aiobtc","aioeth","requsd","reqbtc","reqeth","rdnusd","rdnbtc","rdneth","lrcusd","lrcbtc","lrceth","waxusd","waxbtc","waxeth","daiusd","daibtc","daieth","agiusd","agibtc","agieth","bftusd","bftbtc","bfteth","mtnusd","mtnbtc","mtneth","odeusd","odebtc","odeeth","antusd","antbtc","anteth","dthusd","dthbtc","dtheth","mitusd","mitbtc","miteth","stjusd","stjbtc","stjeth","xlmusd","xlmeur","xlmjpy","xlmgbp","xlmbtc","xlmeth","xvgusd","xvgeur","xvgjpy","xvggbp","xvgbtc","xvgeth","bciusd","bcibtc","mkrusd","mkrbtc","mkreth","kncusd","kncbtc","knceth","poausd","poabtc","poaeth","evtusd","lymusd","lymbtc","lymeth","utkusd","utkbtc","utketh","veeusd","veebtc","veeeth","dadusd","dadbtc","dadeth","orsusd","orsbtc","orseth","aucusd","aucbtc","auceth","poyusd","poybtc","poyeth","fsnusd","fsnbtc","fsneth","cbtusd","cbtbtc","cbteth","zcnusd","zcnbtc","zcneth","senusd","senbtc","seneth","ncausd","ncabtc","ncaeth","cndusd","cndbtc","cndeth","ctxusd","ctxbtc","ctxeth","paiusd","paibtc","seeusd","seebtc","seeeth","essusd","essbtc","esseth","atmusd","atmbtc","atmeth","hotusd","hotbtc","hoteth","dtausd","dtabtc","dtaeth","iqxusd","iqxbtc","iqxeos","wprusd","wprbtc","wpreth","zilusd","zilbtc","zileth","bntusd","bntbtc","bnteth","absusd","abseth","xrausd","xraeth","manusd","maneth","bbnusd","bbneth","niousd","nioeth","dgxusd","dgxeth","vetusd","vetbtc","veteth","utnusd","utneth","tknusd","tkneth","gotusd","goteur","goteth","xtzusd","xtzbtc","cnnusd","cnneth","boxusd","boxeth","trxeur","trxgbp","trxjpy","mgousd","mgoeth","rteusd","rteeth","yggusd","yggeth","mlnusd","mlneth","wtcusd","wtceth","csxusd","csxeth","omnusd","omnbtc","intusd","inteth","drnusd","drneth","pnkusd","pnketh","dgbusd","dgbbtc","bsvusd","bsvbtc","babusd","babbtc","wlousd","wloxlm","vldusd","vldeth","enjusd","enjeth","onlusd","onleth","rbtusd","rbtbtc","ustusd","euteur","eutusd","gsdusd","udcusd","tsdusd","paxusd","rifusd","rifbtc","pasusd","paseth","vsyusd","vsybtc","zrxdai","mkrdai","omgdai","bttusd","bttbtc","btcust","ethust","clousd","clobtc","impusd","impeth","ltcust","eosust","babust","scrusd","screth","gnousd","gnoeth","genusd","geneth","atousd","atobtc","atoeth","wbtusd","xchusd","eususd","wbteth","xcheth","euseth","leousd","leobtc","leoust","leoeos","leoeth","astusd","asteth","foausd","foaeth","ufrusd","ufreth","zbtusd","zbtust","okbusd","uskusd","gtxusd","kanusd","okbust","okbeth","okbbtc","uskust","usketh","uskbtc","uskeos","gtxust","kanust","ampusd","algusd","algbtc","algust","btcxch","swmusd","swmeth","triusd","trieth","loousd","looeth","ampust","dusk:usd","dusk:btc","uosusd","uosbtc","rrbusd","rrbust","dtxusd","dtxust","ampbtc","fttusd","fttust","paxust","udcust","tsdust","btcf0:ustf0","ethf0:ustf0"]' 18 | headers: 19 | CF-RAY: 20 | - 5106c22a4c25c8db-BKK 21 | Cache-Control: 22 | - max-age=0, private, must-revalidate 23 | Connection: 24 | - keep-alive 25 | Content-Type: 26 | - application/json; charset=utf-8 27 | Date: 28 | - Tue, 03 Sep 2019 09:39:47 GMT 29 | ETag: 30 | - W/"3d07bb6eb164c55934d76fa822ad5388" 31 | Expect-CT: 32 | - max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct" 33 | Server: 34 | - cloudflare 35 | Set-Cookie: 36 | - __cfduid=d40cdffc1efe93b81373e6734f1cbdd5c1567503586; expires=Wed, 02-Sep-20 37 | 09:39:46 GMT; path=/; domain=.bitfinex.com; HttpOnly 38 | Strict-Transport-Security: 39 | - max-age=31536000 40 | Transfer-Encoding: 41 | - chunked 42 | Vary: 43 | - Accept-Encoding 44 | X-Content-Type-Options: 45 | - nosniff 46 | X-Download-Options: 47 | - noopen 48 | X-Frame-Options: 49 | - SAMEORIGIN 50 | X-Permitted-Cross-Domain-Policies: 51 | - none 52 | X-Request-Id: 53 | - 0e19b58e-ed58-434f-98b6-64425e2d72cc 54 | X-Runtime: 55 | - '0.003964' 56 | X-XSS-Protection: 57 | - 1; mode=block 58 | content-length: 59 | - '3804' 60 | status: 61 | code: 200 62 | message: OK 63 | version: 1 64 | -------------------------------------------------------------------------------- /app/exchanges/tests/fixtures/vcr/fixer/query_200.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - '*/*' 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Connection: 10 | - keep-alive 11 | User-Agent: 12 | - python-requests/2.21.0 13 | method: GET 14 | uri: http://data.fixer.io/api/latest 15 | response: 16 | body: 17 | string: '{"success":true,"timestamp":1553201225,"base":"EUR","date":"2019-03-21","rates":{"AED":4.177296,"AFN":85.816025,"ALL":125.502621,"AMD":552.846054,"ANG":2.087538,"AOA":360.820113,"ARS":46.675006,"AUD":1.599347,"AWG":2.048302,"AZN":1.939135,"BAM":1.95281,"BBD":2.264619,"BDT":95.798197,"BGN":1.956295,"BHD":0.428699,"BIF":2082.98977,"BMD":1.137314,"BND":1.536059,"BOB":7.855254,"BRL":4.321849,"BSD":1.137712,"BTC":0.000285,"BTN":78.157719,"BWP":12.103247,"BYN":2.386881,"BYR":22291.345613,"BZD":2.292253,"CAD":1.520895,"CDF":1853.820737,"CHF":1.128625,"CLF":0.028487,"CLP":759.153883,"CNY":7.618181,"COP":3512.592904,"CRC":679.072849,"CUC":1.137314,"CUP":30.138809,"CVE":110.660993,"CZK":25.673761,"DJF":202.123604,"DKK":7.462511,"DOP":57.627089,"DZD":134.839737,"EGP":19.619687,"ERN":17.059983,"ETB":32.566995,"EUR":1,"FJD":2.417816,"FKP":0.864393,"GBP":0.867844,"GEL":3.053664,"GGP":0.867949,"GHS":5.931055,"GIP":0.864392,"GMD":56.523445,"GNF":5319.215675,"GTQ":8.745488,"GYD":236.595065,"HKD":8.925125,"HNL":27.898907,"HRK":7.421427,"HTG":94.023994,"HUF":314.795893,"IDR":16058.867349,"ILS":4.096433,"IMP":0.867949,"INR":78.18041,"IQD":1353.403126,"IRR":47886.58723,"ISK":134.225775,"JEP":0.867949,"JMD":142.357699,"JOD":0.806338,"JPY":126.013201,"KES":114.639501,"KGS":79.334783,"KHR":4537.88137,"KMF":491.8315,"KPW":1023.675654,"KRW":1283.378675,"KWD":0.345056,"KYD":0.948053,"KZT":430.450818,"LAK":9763.261567,"LBP":1718.934126,"LKR":202.543989,"LRD":184.38691,"LSL":16.354764,"LTL":3.358191,"LVL":0.68795,"LYD":1.575147,"MAD":10.905816,"MDL":19.64311,"MGA":4031.776096,"MKD":61.551694,"MMK":1740.542543,"MNT":2992.317924,"MOP":9.195351,"MRO":406.020745,"MUR":39.119021,"MVR":17.620665,"MWK":823.432065,"MXN":21.462133,"MYR":4.60589,"MZN":71.912547,"NAD":16.354562,"NGN":408.850948,"NIO":37.394759,"NOK":9.615992,"NPR":124.797066,"NZD":1.65403,"OMR":0.438095,"PAB":1.137598,"PEN":3.745741,"PGK":3.841272,"PHP":59.760102,"PKR":159.422952,"PLN":4.284885,"PYG":6994.703753,"QAR":4.141243,"RON":4.754195,"RSD":117.962541,"RUB":72.631005,"RWF":1009.365777,"SAR":4.267424,"SBD":9.1618,"SCR":15.561296,"SDG":54.166815,"SEK":10.437172,"SGD":1.53431,"SHP":1.502283,"SLL":10093.657749,"SOS":661.916851,"SRD":8.482111,"STD":23941.132207,"SVC":9.954796,"SYP":585.716196,"SZL":16.172659,"THB":36.064106,"TJS":10.738344,"TMT":3.980597,"TND":3.430422,"TOP":2.564755,"TRY":6.216446,"TTD":7.728558,"TWD":35.040564,"TZS":2664.269412,"UAH":30.929606,"UGX":4208.857525,"USD":1.137314,"UYU":37.804543,"UZS":9536.374127,"VEF":11.358918,"VND":26385.105742,"VUV":129.459069,"WST":2.964217,"XAF":654.956075,"XAG":0.073544,"XAU":0.000869,"XCD":3.074101,"XDR":0.816079,"XOF":661.9168,"XPF":119.635228,"YER":284.726802,"ZAR":16.161965,"ZMK":10237.185202,"ZMW":13.680739,"ZWL":366.618722}}' 18 | headers: 19 | Access-Control-Allow-Methods: 20 | - GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS 21 | Access-Control-Allow-Origin: 22 | - '*' 23 | Connection: 24 | - keep-alive 25 | Content-Length: 26 | - '2764' 27 | Content-Type: 28 | - application/json; Charset=UTF-8 29 | Date: 30 | - Thu, 21 Mar 2019 21:46:00 GMT 31 | ETag: 32 | - 0699c5a47d9fd2c417ea5f5d6f5a4021 33 | Last-Modified: 34 | - Thu, 21 Mar 2019 20:47:05 GMT 35 | Server: 36 | - nginx 37 | status: 38 | code: 200 39 | message: OK 40 | version: 1 41 | -------------------------------------------------------------------------------- /app/exchanges/tests/fixtures/vcr/openexchangerates/query_200.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - '*/*' 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Connection: 10 | - keep-alive 11 | User-Agent: 12 | - python-requests/2.21.0 13 | method: GET 14 | uri: http://openexchangerates.org/api/latest.json 15 | response: 16 | body: 17 | string: "{\n \"disclaimer\": \"Usage subject to terms: https://openexchangerates.org/terms\"\ 18 | ,\n \"license\": \"https://openexchangerates.org/license\",\n \"timestamp\"\ 19 | : 1553202000,\n \"base\": \"USD\",\n \"rates\": {\n \"AED\": 3.673096,\n\ 20 | \ \"AFN\": 75.454952,\n \"ALL\": 110.35,\n \"AMD\": 485.886296,\n\ 21 | \ \"ANG\": 1.823137,\n \"AOA\": 317.2565,\n \"ARS\": 41.0267,\n \ 22 | \ \"AUD\": 1.4062,\n \"AWG\": 1.800999,\n \"AZN\": 1.7025,\n \"\ 23 | BAM\": 1.717009,\n \"BBD\": 2,\n \"BDT\": 84.232,\n \"BGN\": 1.722515,\n\ 24 | \ \"BHD\": 0.376874,\n \"BIF\": 1831.5,\n \"BMD\": 1,\n \"BND\"\ 25 | : 1.350602,\n \"BOB\": 6.906796,\n \"BRL\": 3.7936,\n \"BSD\": 1,\n\ 26 | \ \"BTC\": 0.000250939028,\n \"BTN\": 68.584273,\n \"BWP\": 10.698013,\n\ 27 | \ \"BYN\": 2.098717,\n \"BZD\": 2.015521,\n \"CAD\": 1.33649,\n \ 28 | \ \"CDF\": 1630.5,\n \"CHF\": 0.992042,\n \"CLF\": 0.024214,\n \ 29 | \ \"CLP\": 667.489964,\n \"CNH\": 6.7071,\n \"CNY\": 6.6984,\n \"\ 30 | COP\": 3092.722479,\n \"CRC\": 597.087461,\n \"CUC\": 1,\n \"CUP\"\ 31 | : 25.75,\n \"CVE\": 97.3,\n \"CZK\": 22.5738,\n \"DJF\": 178,\n \ 32 | \ \"DKK\": 6.560711,\n \"DOP\": 50.67,\n \"DZD\": 118.539471,\n \ 33 | \ \"EGP\": 17.254,\n \"ERN\": 14.998097,\n \"ETB\": 28.635,\n \"\ 34 | EUR\": 0.8792,\n \"FJD\": 2.11869,\n \"FKP\": 0.7628,\n \"GBP\":\ 35 | \ 0.7628,\n \"GEL\": 2.685,\n \"GGP\": 0.7628,\n \"GHS\": 5.215,\n\ 36 | \ \"GIP\": 0.7628,\n \"GMD\": 49.675,\n \"GNF\": 9230,\n \"GTQ\"\ 37 | : 7.689564,\n \"GYD\": 208.031589,\n \"HKD\": 7.84745,\n \"HNL\"\ 38 | : 24.529886,\n \"HRK\": 6.5203,\n \"HTG\": 82.693237,\n \"HUF\":\ 39 | \ 276.8,\n \"IDR\": 14108.335669,\n \"ILS\": 3.60155,\n \"IMP\":\ 40 | \ 0.7628,\n \"INR\": 68.6725,\n \"IQD\": 1190,\n \"IRR\": 42105,\n\ 41 | \ \"ISK\": 117.999868,\n \"JEP\": 0.7628,\n \"JMD\": 124.51,\n \ 42 | \ \"JOD\": 0.709004,\n \"JPY\": 110.81560828,\n \"KES\": 100.79,\n\ 43 | \ \"KGS\": 68.677087,\n \"KHR\": 3990,\n \"KMF\": 432.697298,\n \ 44 | \ \"KPW\": 900,\n \"KRW\": 1128.42,\n \"KWD\": 0.303503,\n \"KYD\"\ 45 | : 0.833594,\n \"KZT\": 378.48,\n \"LAK\": 8580,\n \"LBP\": 1507.5,\n\ 46 | \ \"LKR\": 178.148978,\n \"LRD\": 162.249473,\n \"LSL\": 14.38,\n\ 47 | \ \"LYD\": 1.385,\n \"MAD\": 9.587864,\n \"MDL\": 17.232552,\n \ 48 | \ \"MGA\": 3545,\n \"MKD\": 54.117935,\n \"MMK\": 1530.39776,\n \ 49 | \ \"MNT\": 2512.714637,\n \"MOP\": 8.085216,\n \"MRO\": 357,\n \"\ 50 | MRU\": 36.55,\n \"MUR\": 34.3995,\n \"MVR\": 15.500007,\n \"MWK\"\ 51 | : 724.012713,\n \"MXN\": 18.8595,\n \"MYR\": 4.049814,\n \"MZN\"\ 52 | : 62.899849,\n \"NAD\": 14.38,\n \"NGN\": 359.5,\n \"NIO\": 32.88,\n\ 53 | \ \"NOK\": 8.45381,\n \"NPR\": 109.730993,\n \"NZD\": 1.453911,\n\ 54 | \ \"OMR\": 0.384978,\n \"PAB\": 1,\n \"PEN\": 3.2935,\n \"PGK\"\ 55 | : 3.3775,\n \"PHP\": 52.591444,\n \"PKR\": 140.175,\n \"PLN\": 3.76845,\n\ 56 | \ \"PYG\": 6150.24562,\n \"QAR\": 3.641259,\n \"RON\": 4.181772,\n\ 57 | \ \"RSD\": 103.676031,\n \"RUB\": 63.8675,\n \"RWF\": 895,\n \"\ 58 | SAR\": 3.7502,\n \"SBD\": 8.055639,\n \"SCR\": 13.674478,\n \"SDG\"\ 59 | : 47.715,\n \"SEK\": 9.17338,\n \"SGD\": 1.3491,\n \"SHP\": 0.7628,\n\ 60 | \ \"SLL\": 8390,\n \"SOS\": 582,\n \"SRD\": 7.458,\n \"SSP\":\ 61 | \ 130.2634,\n \"STD\": 21050.59961,\n \"STN\": 21.6,\n \"SVC\": 8.752871,\n\ 62 | \ \"SYP\": 515.02323,\n \"SZL\": 14.351042,\n \"THB\": 31.7355,\n\ 63 | \ \"TJS\": 9.441888,\n \"TMT\": 3.499986,\n \"TND\": 3.016233,\n\ 64 | \ \"TOP\": 2.259725,\n \"TRY\": 5.465513,\n \"TTD\": 6.79545,\n \ 65 | \ \"TWD\": 30.81195,\n \"TZS\": 2342.6,\n \"UAH\": 27.2,\n \"UGX\"\ 66 | : 3700.665389,\n \"USD\": 1,\n \"UYU\": 33.351957,\n \"UZS\": 8385,\n\ 67 | \ \"VEF\": 248487.642241,\n \"VES\": 3291.516389,\n \"VND\": 23234.934858,\n\ 68 | \ \"VUV\": 111.130321,\n \"WST\": 2.606888,\n \"XAF\": 576.717394,\n\ 69 | \ \"XAG\": 0.06464333,\n \"XAU\": 0.00076368,\n \"XCD\": 2.70295,\n\ 70 | \ \"XDR\": 0.7157,\n \"XOF\": 576.717394,\n \"XPD\": 0.00062086,\n\ 71 | \ \"XPF\": 104.916468,\n \"XPT\": 0.00116257,\n \"YER\": 250.350747,\n\ 72 | \ \"ZAR\": 14.213955,\n \"ZMW\": 12.028463,\n \"ZWL\": 322.355011\n\ 73 | \ }\n}" 74 | headers: 75 | Access-Control-Allow-Origin: 76 | - '*' 77 | Cache-Control: 78 | - public 79 | Connection: 80 | - keep-alive 81 | Content-Length: 82 | - '3663' 83 | Content-Type: 84 | - application/json; charset=utf-8 85 | Date: 86 | - Thu, 21 Mar 2019 21:46:01 GMT 87 | ETag: 88 | - '"16f63f55d44a8a92cba42b5b5376593a"' 89 | Last-Modified: 90 | - Thu, 21 Mar 2019 21:00:00 GMT 91 | Server: 92 | - nginx/1.12.2 93 | status: 94 | code: 200 95 | message: OK 96 | version: 1 97 | -------------------------------------------------------------------------------- /app/exchanges/tests/fixtures/vcr/satang/query_200.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - '*/*' 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Connection: 10 | - keep-alive 11 | User-Agent: 12 | - python-requests/2.22.0 13 | method: GET 14 | uri: https://api.tdax.com/api/orderbook-tickers/ 15 | response: 16 | body: 17 | string: '{"BCH_THB":{"bid":{"price":"11900","amount":"0.005042016806722689"},"ask":{"price":"15349.9999","amount":"2"}},"BNB_THB":{"bid":{"price":"710","amount":"74.99"},"ask":{"price":"750","amount":"0.61"}},"BTC_THB":{"bid":{"price":"304600","amount":"0.05"},"ask":{"price":"304999","amount":"0.1478813007255761492"}},"DOGE_THB":{"bid":{"price":"0.08659999","amount":"0.873122133108"},"ask":{"price":"0.093","amount":"6360.8188057652711"}},"ETH_THB":{"bid":{"price":"7503.00000001","amount":"3"},"ask":{"price":"8000","amount":"2.6788"}},"JFIN_THB":{"bid":{"price":"1.41000001","amount":"14.184397062522006"},"ask":{"price":"1.62","amount":"1611.09"}},"LTC_THB":{"bid":{"price":"2005.0001","amount":"3"},"ask":{"price":"2400","amount":"0.98"}},"USDT_THB":{"bid":{"price":"30.8000002","amount":"4975.63"},"ask":{"price":"31.19899999","amount":"9584.379819312275152"}},"XLM_THB":{"bid":{"price":"2.2","amount":"0.4090909090905"},"ask":{"price":"2.3888799","amount":"6000"}},"XRP_THB":{"bid":{"price":"8.57001","amount":"2000"},"ask":{"price":"8.9999999","amount":"6000"}},"XZC_THB":{"bid":{"price":"190","amount":"0.15"},"ask":{"price":"213.57","amount":"84.51"}}} 18 | 19 | ' 20 | headers: 21 | Access-Control-Allow-Origin: 22 | - '*' 23 | Access-Control-Expose-Headers: 24 | - X-New-Access-Token,X-New-Secret,X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset 25 | CF-Cache-Status: 26 | - DYNAMIC 27 | CF-RAY: 28 | - 566592896b96c8d3-BKK 29 | Connection: 30 | - keep-alive 31 | Content-Type: 32 | - application/json; charset=UTF-8 33 | Date: 34 | - Mon, 17 Feb 2020 06:05:56 GMT 35 | Expect-CT: 36 | - max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct" 37 | Server: 38 | - cloudflare 39 | Set-Cookie: 40 | - __cfduid=d6d208edf046f96031bcfe39c4f96a6a71581919556; expires=Wed, 18-Mar-20 41 | 06:05:56 GMT; path=/; domain=.tdax.com; HttpOnly; SameSite=Lax; Secure 42 | Strict-Transport-Security: 43 | - max-age=0 44 | Transfer-Encoding: 45 | - chunked 46 | Vary: 47 | - Origin 48 | X-Content-Type-Options: 49 | - nosniff 50 | X-Frame-Options: 51 | - DENY 52 | content-length: 53 | - '1156' 54 | status: 55 | code: 200 56 | message: OK 57 | version: 1 58 | -------------------------------------------------------------------------------- /app/exchanges/tests/fixtures/vcr/sp_today/query_200.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - '*/*' 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Connection: 10 | - keep-alive 11 | User-Agent: 12 | - python-requests/2.24.0 13 | method: GET 14 | uri: https://sp-today.com/app_api/cur_aleppo.json 15 | response: 16 | body: 17 | string: '[{"name":"USD","ask":"2080","bid":"2130","arrow":"1","ar_name":"\u062f\u0648\u0644\u0627\u0631 18 | \u0623\u0645\u0631\u064a\u0643\u064a","icon":"us.png","change":"60","change_percentage":"2.90"},{"name":"EUR","ask":"2478","bid":"2542","arrow":"1","ar_name":"\u064a\u0648\u0631\u0648","icon":"euro.png","change":"90","change_percentage":"3.67"},{"name":"TRY","ask":"281","bid":"290","arrow":"1","ar_name":"\u0644\u064a\u0631\u0629 19 | \u062a\u0631\u0643\u064a\u0629","icon":"tr.png","change":"10","change_percentage":"3.57"},{"name":"EGP","ask":"129","bid":"134","arrow":"1","ar_name":"\u062c\u0646\u064a\u0647 20 | \u0645\u0635\u0631\u064a","icon":"eg.png","change":"4","change_percentage":"3.08"},{"name":"SAR","ask":"552","bid":"568","arrow":"1","ar_name":"\u0631\u064a\u0627\u0644 21 | \u0633\u0639\u0648\u062f\u064a","icon":"sa.png","change":"15","change_percentage":"2.71"},{"name":"JOD","ask":"2937","bid":"3016","arrow":"1","ar_name":"\u062f\u064a\u0646\u0627\u0631 22 | \u0623\u0631\u062f\u0646\u064a","icon":"jo.png","change":"86","change_percentage":"2.94"},{"name":"AED","ask":"563","bid":"580","arrow":"1","ar_name":"\u062f\u0631\u0647\u0645 23 | \u0625\u0645\u0627\u0631\u0627\u062a\u064a","icon":"ae.png","change":"16","change_percentage":"2.84"},{"name":"QAR","ask":"568","bid":"585","arrow":"1","ar_name":"\u0631\u064a\u0627\u0644 24 | \u0642\u0637\u0631\u064a","icon":"qar.png","change":"16","change_percentage":"2.81"},{"name":"BHD","ask":"5538","bid":"5691","arrow":"1","ar_name":"\u062f\u064a\u0646\u0627\u0631 25 | \u0628\u062d\u0631\u064a\u0646\u064a","icon":"bh.png","change":"172","change_percentage":"3.12"},{"name":"LYD","ask":"1536","bid":"1578","arrow":"1","ar_name":"\u062f\u064a\u0646\u0627\u0631 26 | \u0644\u064a\u0628\u064a ","icon":"libya.png","change":"33","change_percentage":"2.14"},{"name":"KWD","ask":"6801","bid":"6990","arrow":"1","ar_name":"\u062f\u064a\u0646\u0627\u0631 27 | \u0643\u0648\u064a\u062a\u064a","icon":"kw.png","change":"209","change_percentage":"3.08"},{"name":"OMR","ask":"5397","bid":"5547","arrow":"1","ar_name":"\u0631\u064a\u0627\u0644 28 | \u0639\u0645\u0627\u0646\u064a","icon":"omr.png","change":"146","change_percentage":"2.70"},{"name":"GBP","ask":"2741","bid":"2817","arrow":"1","ar_name":"\u062c\u0646\u064a\u0647 29 | \u0627\u0633\u062a\u0631\u0644\u064a\u0646\u064a","icon":"gb.png","change":"105","change_percentage":"3.87"},{"name":"SEK","ask":"238","bid":"246","arrow":"1","ar_name":"\u0643\u0631\u0648\u0646 30 | \u0633\u0648\u064a\u062f\u064a","icon":"sweden.png","change":"8","change_percentage":"3.36"},{"name":"CAD","ask":"1570","bid":"1616","arrow":"1","ar_name":"\u062f\u0648\u0644\u0627\u0631 31 | \u0643\u0646\u062f\u064a","icon":"cad.png","change":"52","change_percentage":"3.32"},{"name":"NOK","ask":"232","bid":"241","arrow":"1","ar_name":"\u0643\u0631\u0648\u0646 32 | \u0646\u0631\u0648\u064a\u062c\u064a","icon":"nok.png","change":"8","change_percentage":"3.43"},{"name":"DKK","ask":"330","bid":"341","arrow":"1","ar_name":"\u0643\u0631\u0648\u0646 33 | \u062f\u064a\u0646\u0645\u0627\u0631\u0643\u064a","icon":"dkk.png","change":"12","change_percentage":"3.65"}]' 34 | headers: 35 | Connection: 36 | - keep-alive 37 | Content-Type: 38 | - application/json 39 | Date: 40 | - Tue, 18 Aug 2020 19:12:43 GMT 41 | ETag: 42 | - W/"5f3be84c-c02" 43 | Last-Modified: 44 | - Tue, 18 Aug 2020 14:40:12 GMT 45 | Server: 46 | - nginx 47 | Transfer-Encoding: 48 | - chunked 49 | Vary: 50 | - Accept-Encoding 51 | content-length: 52 | - '3074' 53 | status: 54 | code: 200 55 | message: OK 56 | version: 1 57 | -------------------------------------------------------------------------------- /app/exchanges/tests/test_base.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from decimal import Decimal 3 | 4 | from app.exchanges.base import ( 5 | ECurrency, 6 | Pair, 7 | PairData, 8 | reverse_amount, 9 | reverse_pair, 10 | reverse_pair_data, 11 | ) 12 | from suite.test.testcases import SimpleTestCase 13 | 14 | 15 | class ReverseFunctionsTest(SimpleTestCase): 16 | def test_reverse_pair(self): 17 | pair = Pair(ECurrency("BTC"), ECurrency("USD")) 18 | reversed_pair = Pair(ECurrency("USD"), ECurrency("BTC")) 19 | 20 | self.assertEqual(reverse_pair(pair), reversed_pair) 21 | 22 | def test_reverse_amount(self): 23 | self.assertEqual(reverse_amount(Decimal("1") / Decimal("3")), Decimal("3")) 24 | 25 | def test_reverse_pair_data(self): 26 | pair_data = PairData( 27 | pair=Pair(ECurrency("BTC"), ECurrency("USD")), 28 | rate=Decimal("1") / Decimal("3"), 29 | last_trade_at=datetime(2019, 3, 9, 12), 30 | rate_open=Decimal("1") / Decimal("2"), 31 | low24h=Decimal("1") / Decimal("4"), 32 | high24h=Decimal("1") / Decimal("8"), 33 | ) 34 | 35 | pair_data_reversed = PairData( 36 | pair=Pair(ECurrency("USD"), ECurrency("BTC")), 37 | rate=Decimal("3"), 38 | last_trade_at=datetime(2019, 3, 9, 12), 39 | rate_open=Decimal("2"), 40 | low24h=Decimal("4"), 41 | high24h=Decimal("8"), 42 | ) 43 | 44 | self.assertEqual(reverse_pair_data(pair_data), pair_data_reversed) 45 | -------------------------------------------------------------------------------- /app/exchanges/tests/test_bitfinex.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from decimal import Decimal 3 | 4 | from vcr import VCR 5 | 6 | from app.exchanges.base import ECurrency, Pair, PairData 7 | from app.exchanges.bitfinex import BitfinexExchange 8 | from app.exchanges.exceptions import PairNotExistsException 9 | from suite.test.testcases import SimpleTestCase 10 | 11 | my_vcr = VCR( 12 | cassette_library_dir="app/exchanges/tests/fixtures/vcr/bitfinex", 13 | record_mode="once", 14 | decode_compressed_response=True, 15 | ) 16 | 17 | 18 | class BitfinexTest(SimpleTestCase): 19 | def test_name(self): 20 | self.assertEqual(BitfinexExchange.name, "Bitfinex") 21 | 22 | @my_vcr.use_cassette("symbols_200.yaml") 23 | def test_list_currencies(self): 24 | currencies = BitfinexExchange().list_currencies 25 | self.assertEqual(len(currencies), 160) 26 | self.assertTrue(ECurrency(code="BTC") in currencies) 27 | self.assertTrue(ECurrency(code="USD") in currencies) 28 | 29 | @my_vcr.use_cassette("symbols_200.yaml") 30 | def test_list_pairs(self): 31 | pairs = BitfinexExchange().list_pairs 32 | self.assertEqual(len(pairs), 421) 33 | self.assertTrue(Pair(ECurrency("BTC"), ECurrency("USD")) in pairs) 34 | self.assertFalse(Pair(ECurrency("USD"), ECurrency("BTC")) in pairs) 35 | 36 | @my_vcr.use_cassette("symbols_200.yaml") 37 | def test_is_pair_exists(self): 38 | exchange = BitfinexExchange() 39 | self.assertTrue( 40 | exchange.is_pair_exists(Pair(ECurrency("BTC"), ECurrency("USD"))) 41 | ) 42 | 43 | self.assertFalse( 44 | exchange.is_pair_exists(Pair(ECurrency("USD"), ECurrency("BTC"))) 45 | ) 46 | self.assertFalse( 47 | exchange.is_pair_exists(Pair(ECurrency("usd"), ECurrency("BTC"))) 48 | ) 49 | self.assertFalse( 50 | exchange.is_pair_exists(Pair(ECurrency("usd"), ECurrency("MONEY"))) 51 | ) 52 | 53 | @my_vcr.use_cassette("symbols_200.yaml") 54 | def test_is_currency_exists(self): 55 | exchange = BitfinexExchange() 56 | self.assertTrue(exchange.is_currency_exists(ECurrency(code="BTC"))) 57 | self.assertTrue(exchange.is_currency_exists(ECurrency(code="USD"))) 58 | 59 | self.assertFalse(exchange.is_currency_exists(ECurrency(code="usd"))) 60 | self.assertFalse(exchange.is_currency_exists(ECurrency(code="MONEY"))) 61 | 62 | @my_vcr.use_cassette("get_pair_200.yaml") 63 | def test_get_pair_info(self): 64 | pair = Pair(ECurrency("BTC"), ECurrency("USD")) 65 | self.assertEqual( 66 | BitfinexExchange().get_pair_info(pair), 67 | PairData( 68 | pair=pair, 69 | rate=Decimal("3996.05"), 70 | rate_open=None, 71 | low24h=Decimal("3850.0"), 72 | high24h=Decimal("4021.0"), 73 | last_trade_at=datetime(2019, 3, 9, 11, 21, 12, 996645), 74 | ), 75 | ) 76 | 77 | @my_vcr.use_cassette("get_pair_200.yaml") 78 | def test_get_pair_info_no_pair(self): 79 | pair = Pair(ECurrency("USD"), ECurrency("BTC")) 80 | with self.assertRaises(PairNotExistsException): 81 | BitfinexExchange().get_pair_info(pair) 82 | -------------------------------------------------------------------------------- /app/exchanges/tests/test_bitkub.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from decimal import Decimal 3 | 4 | from freezegun import freeze_time 5 | from vcr import VCR 6 | 7 | from app.exchanges.base import ECurrency, Pair, PairData 8 | from app.exchanges.bitkub import BitkubExchange 9 | from app.exchanges.exceptions import PairNotExistsException 10 | from suite.test.testcases import SimpleTestCase 11 | 12 | my_vcr = VCR( 13 | cassette_library_dir="app/exchanges/tests/fixtures/vcr/bitkub", 14 | record_mode="once", 15 | decode_compressed_response=True, 16 | ) 17 | 18 | 19 | class BitkubExchangeTest(SimpleTestCase): 20 | def test_name(self): 21 | self.assertEqual( 22 | BitkubExchange.name, "[bitkub.com](https://www.bitkub.com/signup?ref=64572)" 23 | ) 24 | 25 | @my_vcr.use_cassette("query_200.yaml") 26 | def test_list_currencies(self): 27 | currencies = BitkubExchange().list_currencies 28 | self.assertEqual(len(currencies), 26) 29 | self.assertTrue(ECurrency(code="BTC") in currencies) 30 | self.assertTrue(ECurrency(code="THB") in currencies) 31 | 32 | @my_vcr.use_cassette("query_200.yaml") 33 | def test_list_pairs(self): 34 | pairs = BitkubExchange().list_pairs 35 | self.assertEqual(len(pairs), 25) 36 | self.assertTrue(Pair(ECurrency("BTC"), ECurrency("THB")) in pairs) 37 | self.assertFalse(Pair(ECurrency("THB"), ECurrency("BTC")) in pairs) 38 | 39 | @my_vcr.use_cassette("query_200.yaml") 40 | def test_is_pair_exists(self): 41 | exchange = BitkubExchange() 42 | self.assertTrue( 43 | exchange.is_pair_exists(Pair(ECurrency("BTC"), ECurrency("THB"))) 44 | ) 45 | 46 | self.assertFalse( 47 | exchange.is_pair_exists(Pair(ECurrency("THB"), ECurrency("BTC"))) 48 | ) 49 | self.assertFalse( 50 | exchange.is_pair_exists(Pair(ECurrency("thb"), ECurrency("BTC"))) 51 | ) 52 | self.assertFalse( 53 | exchange.is_pair_exists(Pair(ECurrency("btc"), ECurrency("MONEY"))) 54 | ) 55 | 56 | @my_vcr.use_cassette("query_200.yaml") 57 | def test_is_currency_exists(self): 58 | exchange = BitkubExchange() 59 | self.assertTrue(exchange.is_currency_exists(ECurrency(code="BTC"))) 60 | self.assertTrue(exchange.is_currency_exists(ECurrency(code="THB"))) 61 | 62 | self.assertFalse(exchange.is_currency_exists(ECurrency(code="thb"))) 63 | self.assertFalse(exchange.is_currency_exists(ECurrency(code="MONEY"))) 64 | 65 | @my_vcr.use_cassette("query_200.yaml") 66 | @freeze_time("2019-03-17 22:14:15", tz_offset=0) 67 | def test_get_pair_info(self): 68 | pair = Pair(ECurrency("BTC"), ECurrency("THB")) 69 | self.assertEqual( 70 | BitkubExchange().get_pair_info(pair), 71 | PairData( 72 | pair=pair, 73 | rate=Decimal("300353.515"), 74 | rate_open=None, 75 | low24h=Decimal("281470"), 76 | high24h=Decimal("304000"), 77 | last_trade_at=datetime(2019, 3, 17, 22, 14, 15, 0), 78 | ), 79 | ) 80 | 81 | @my_vcr.use_cassette("query_200.yaml") 82 | def test_get_pair_info_no_pair(self): 83 | pair = Pair(ECurrency("USD"), ECurrency("BTC")) 84 | with self.assertRaises(PairNotExistsException): 85 | BitkubExchange().get_pair_info(pair) 86 | -------------------------------------------------------------------------------- /app/exchanges/tests/test_bittrex.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from decimal import Decimal 3 | 4 | from vcr import VCR 5 | 6 | from app.exchanges.base import ECurrency, Pair, PairData 7 | from app.exchanges.bittrex import BittrexExchange 8 | from app.exchanges.exceptions import PairNotExistsException 9 | from suite.test.testcases import SimpleTestCase 10 | 11 | my_vcr = VCR( 12 | cassette_library_dir="app/exchanges/tests/fixtures/vcr/bittrex", 13 | record_mode="once", 14 | decode_compressed_response=True, 15 | ) 16 | 17 | 18 | class BittrexTest(SimpleTestCase): 19 | def test_name(self): 20 | self.assertEqual( 21 | BittrexExchange.name, 22 | "[bittrex.com](https://bittrex.com/Account/Register?referralCode=YIV-CNI-13Q)", 23 | ) 24 | 25 | @my_vcr.use_cassette("query_200.yaml") 26 | def test_list_currencies(self): 27 | currencies = BittrexExchange().list_currencies 28 | self.assertEqual(len(currencies), 239) 29 | self.assertTrue(ECurrency(code="BTC") in currencies) 30 | self.assertTrue(ECurrency(code="USD") in currencies) 31 | 32 | @my_vcr.use_cassette("query_200.yaml") 33 | def test_list_pairs(self): 34 | pairs = BittrexExchange().list_pairs 35 | self.assertEqual(len(pairs), 331) 36 | self.assertTrue(Pair(ECurrency("BTC"), ECurrency("USD")) in pairs) 37 | self.assertFalse(Pair(ECurrency("USD"), ECurrency("BTC")) in pairs) 38 | 39 | @my_vcr.use_cassette("query_200.yaml") 40 | def test_is_pair_exists(self): 41 | exchange = BittrexExchange() 42 | self.assertTrue( 43 | exchange.is_pair_exists(Pair(ECurrency("BTC"), ECurrency("USD"))) 44 | ) 45 | 46 | self.assertFalse( 47 | exchange.is_pair_exists(Pair(ECurrency("USD"), ECurrency("BTC"))) 48 | ) 49 | self.assertFalse( 50 | exchange.is_pair_exists(Pair(ECurrency("usd"), ECurrency("BTC"))) 51 | ) 52 | self.assertFalse( 53 | exchange.is_pair_exists(Pair(ECurrency("usd"), ECurrency("MONEY"))) 54 | ) 55 | 56 | @my_vcr.use_cassette("query_200.yaml") 57 | def test_is_currency_exists(self): 58 | exchange = BittrexExchange() 59 | self.assertTrue(exchange.is_currency_exists(ECurrency(code="BTC"))) 60 | self.assertTrue(exchange.is_currency_exists(ECurrency(code="USD"))) 61 | 62 | self.assertFalse(exchange.is_currency_exists(ECurrency(code="usd"))) 63 | self.assertFalse(exchange.is_currency_exists(ECurrency(code="MONEY"))) 64 | 65 | @my_vcr.use_cassette("query_200.yaml") 66 | def test_get_pair_info(self): 67 | pair = Pair(ECurrency("BTC"), ECurrency("USD")) 68 | self.assertEqual( 69 | BittrexExchange().get_pair_info(pair), 70 | PairData( 71 | pair=pair, 72 | rate=Decimal("3909.439"), 73 | rate_open=Decimal("3879.0"), 74 | low24h=Decimal("3773.806"), 75 | high24h=Decimal("3923.994"), 76 | last_trade_at=datetime(2019, 3, 9, 13, 47, 19, 0), 77 | ), 78 | ) 79 | 80 | @my_vcr.use_cassette("query_200.yaml") 81 | def test_get_pair_info_no_pair(self): 82 | pair = Pair(ECurrency("USD"), ECurrency("BTC")) 83 | with self.assertRaises(PairNotExistsException): 84 | BittrexExchange().get_pair_info(pair) 85 | 86 | @my_vcr.use_cassette("null_high_low.yaml") 87 | def test_null_high_low(self): 88 | pair = Pair(ECurrency("BTC"), ECurrency("USD")) 89 | self.assertEqual( 90 | BittrexExchange().get_pair_info(pair), 91 | PairData( 92 | pair=pair, 93 | rate=Decimal("5132.308"), 94 | rate_open=Decimal("5001.301"), 95 | low24h=None, 96 | high24h=None, 97 | last_trade_at=datetime(2019, 4, 7, 7, 54, 34), 98 | ), 99 | ) 100 | 101 | @my_vcr.use_cassette("null_bid_ask.yaml") 102 | def test_null_bid_ask(self): 103 | pair = Pair(ECurrency("BTC"), ECurrency("USD")) 104 | with self.assertRaises(PairNotExistsException): 105 | BittrexExchange().get_pair_info(pair) 106 | 107 | @my_vcr.use_cassette("null_prevday.yaml") 108 | def test_null_prevday(self): 109 | pair = Pair(ECurrency("BTC"), ECurrency("USD")) 110 | self.assertEqual( 111 | BittrexExchange().get_pair_info(pair), 112 | PairData( 113 | pair=pair, 114 | rate=Decimal("5132.308"), 115 | rate_open=None, 116 | low24h=None, 117 | high24h=None, 118 | last_trade_at=datetime(2019, 4, 7, 7, 54, 34), 119 | ), 120 | ) 121 | -------------------------------------------------------------------------------- /app/exchanges/tests/test_fixer.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from decimal import Decimal 3 | 4 | from vcr import VCR 5 | 6 | from app.exchanges.base import ECurrency, Pair, PairData 7 | from app.exchanges.exceptions import PairNotExistsException 8 | from app.exchanges.fixer import FixerExchange 9 | from suite.test.testcases import SimpleTestCase 10 | from suite.test.utils import override_settings 11 | 12 | my_vcr = VCR( 13 | cassette_library_dir="app/exchanges/tests/fixtures/vcr/fixer", 14 | record_mode="once", 15 | decode_compressed_response=True, 16 | filter_query_parameters=["access_key"], 17 | ) 18 | 19 | 20 | @override_settings(FIXER_TOKEN="FAKE-TOKEN") 21 | class FixerTest(SimpleTestCase): 22 | def test_name(self): 23 | self.assertEqual(FixerExchange.name, "Fixer") 24 | 25 | @my_vcr.use_cassette("query_200.yaml") 26 | def test_list_currencies(self): 27 | currencies = FixerExchange().list_currencies 28 | self.assertEqual(len(currencies), 169) 29 | self.assertTrue(ECurrency(code="EUR") in currencies) 30 | self.assertTrue(ECurrency(code="USD") in currencies) 31 | 32 | @my_vcr.use_cassette("query_200.yaml") 33 | def test_list_pairs(self): 34 | pairs = FixerExchange().list_pairs 35 | self.assertEqual(len(pairs), 168) 36 | self.assertTrue(Pair(ECurrency("EUR"), ECurrency("USD")) in pairs) 37 | 38 | @my_vcr.use_cassette("query_200.yaml") 39 | def test_is_pair_exists(self): 40 | exchange = FixerExchange() 41 | self.assertTrue( 42 | exchange.is_pair_exists(Pair(ECurrency("EUR"), ECurrency("USD"))) 43 | ) 44 | 45 | self.assertFalse( 46 | exchange.is_pair_exists(Pair(ECurrency("eur"), ECurrency("USD"))) 47 | ) 48 | self.assertFalse( 49 | exchange.is_pair_exists(Pair(ECurrency("eur"), ECurrency("MONEY"))) 50 | ) 51 | 52 | @my_vcr.use_cassette("query_200.yaml") 53 | def test_is_currency_exists(self): 54 | exchange = FixerExchange() 55 | self.assertTrue(exchange.is_currency_exists(ECurrency(code="EUR"))) 56 | self.assertTrue(exchange.is_currency_exists(ECurrency(code="USD"))) 57 | 58 | self.assertFalse(exchange.is_currency_exists(ECurrency(code="usd"))) 59 | self.assertFalse(exchange.is_currency_exists(ECurrency(code="MONEY"))) 60 | 61 | @my_vcr.use_cassette("query_200.yaml") 62 | def test_get_pair_info(self): 63 | pair = Pair(ECurrency("EUR"), ECurrency("USD")) 64 | self.assertEqual( 65 | FixerExchange().get_pair_info(pair), 66 | PairData( 67 | pair=pair, 68 | rate=Decimal("1.137314"), 69 | rate_open=None, 70 | last_trade_at=datetime(2019, 3, 21, 20, 47, 5), 71 | ), 72 | ) 73 | 74 | @my_vcr.use_cassette("query_200.yaml") 75 | def test_get_pair_info_no_pair(self): 76 | pair = Pair(ECurrency("MONEY"), ECurrency("EUR")) 77 | with self.assertRaises(PairNotExistsException): 78 | FixerExchange().get_pair_info(pair) 79 | -------------------------------------------------------------------------------- /app/exchanges/tests/test_openexchangerates.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from decimal import Decimal 3 | 4 | from vcr import VCR 5 | 6 | from app.exchanges.base import ECurrency, Pair, PairData 7 | from app.exchanges.exceptions import PairNotExistsException 8 | from app.exchanges.openexchangerates import OpenExchangeRatesExchange 9 | from suite.test.testcases import SimpleTestCase 10 | from suite.test.utils import override_settings 11 | 12 | my_vcr = VCR( 13 | cassette_library_dir="app/exchanges/tests/fixtures/vcr/openexchangerates", 14 | record_mode="once", 15 | decode_compressed_response=True, 16 | filter_query_parameters=["app_id"], 17 | ) 18 | 19 | 20 | @override_settings(OPENEXCHANGERATES_TOKEN="FAKE-TOKEN") 21 | class OpenExchangeRatesTest(SimpleTestCase): 22 | def test_name(self): 23 | self.assertEqual(OpenExchangeRatesExchange.name, "OpenExchangeRates") 24 | 25 | @my_vcr.use_cassette("query_200.yaml") 26 | def test_list_currencies(self): 27 | currencies = OpenExchangeRatesExchange().list_currencies 28 | self.assertEqual(len(currencies), 172) 29 | self.assertTrue(ECurrency(code="EUR") in currencies) 30 | self.assertTrue(ECurrency(code="USD") in currencies) 31 | 32 | @my_vcr.use_cassette("query_200.yaml") 33 | def test_list_pairs(self): 34 | pairs = OpenExchangeRatesExchange().list_pairs 35 | self.assertEqual(len(pairs), 171) 36 | self.assertTrue(Pair(ECurrency("USD"), ECurrency("EUR")) in pairs) 37 | 38 | @my_vcr.use_cassette("query_200.yaml") 39 | def test_is_pair_exists(self): 40 | exchange = OpenExchangeRatesExchange() 41 | self.assertTrue( 42 | exchange.is_pair_exists(Pair(ECurrency("USD"), ECurrency("EUR"))) 43 | ) 44 | 45 | self.assertFalse( 46 | exchange.is_pair_exists(Pair(ECurrency("usd"), ECurrency("EUR"))) 47 | ) 48 | self.assertFalse( 49 | exchange.is_pair_exists(Pair(ECurrency("USD"), ECurrency("MONEY"))) 50 | ) 51 | 52 | @my_vcr.use_cassette("query_200.yaml") 53 | def test_is_currency_exists(self): 54 | exchange = OpenExchangeRatesExchange() 55 | self.assertTrue(exchange.is_currency_exists(ECurrency(code="EUR"))) 56 | self.assertTrue(exchange.is_currency_exists(ECurrency(code="USD"))) 57 | 58 | self.assertFalse(exchange.is_currency_exists(ECurrency(code="usd"))) 59 | self.assertFalse(exchange.is_currency_exists(ECurrency(code="MONEY"))) 60 | 61 | @my_vcr.use_cassette("query_200.yaml") 62 | def test_get_pair_info(self): 63 | pair = Pair(ECurrency("USD"), ECurrency("EUR")) 64 | self.assertEqual( 65 | OpenExchangeRatesExchange().get_pair_info(pair), 66 | PairData( 67 | pair=pair, 68 | rate=Decimal("0.8792"), 69 | rate_open=None, 70 | last_trade_at=datetime(2019, 3, 21, 21, 0), 71 | ), 72 | ) 73 | 74 | @my_vcr.use_cassette("query_200.yaml") 75 | def test_get_pair_info_no_pair(self): 76 | pair = Pair(ECurrency("MONEY"), ECurrency("USD")) 77 | with self.assertRaises(PairNotExistsException): 78 | OpenExchangeRatesExchange().get_pair_info(pair) 79 | -------------------------------------------------------------------------------- /app/exchanges/tests/test_satang.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from decimal import Decimal 3 | 4 | from freezegun import freeze_time 5 | from vcr import VCR 6 | 7 | from app.exchanges.base import ECurrency, Pair, PairData 8 | from app.exchanges.exceptions import PairNotExistsException 9 | from app.exchanges.satang import SatangExchange 10 | from suite.test.testcases import SimpleTestCase 11 | 12 | my_vcr = VCR( 13 | cassette_library_dir="app/exchanges/tests/fixtures/vcr/satang", 14 | record_mode="once", 15 | decode_compressed_response=True, 16 | ) 17 | 18 | 19 | class SatangExchangeTest(SimpleTestCase): 20 | def test_name(self): 21 | self.assertEqual( 22 | SatangExchange.name, 23 | "[satang.pro](https://satang.pro/signup?referral=STZ3EEU2)", 24 | ) 25 | 26 | @my_vcr.use_cassette("query_200.yaml") 27 | def test_list_currencies(self): 28 | currencies = SatangExchange().list_currencies 29 | self.assertEqual(len(currencies), 12) 30 | self.assertTrue(ECurrency(code="BTC") in currencies) 31 | self.assertTrue(ECurrency(code="THB") in currencies) 32 | 33 | @my_vcr.use_cassette("query_200.yaml") 34 | def test_list_pairs(self): 35 | pairs = SatangExchange().list_pairs 36 | self.assertEqual(len(pairs), 11) 37 | self.assertTrue(Pair(ECurrency("BTC"), ECurrency("THB")) in pairs) 38 | self.assertFalse(Pair(ECurrency("THB"), ECurrency("BTC")) in pairs) 39 | 40 | @my_vcr.use_cassette("query_200.yaml") 41 | def test_is_pair_exists(self): 42 | exchange = SatangExchange() 43 | self.assertTrue( 44 | exchange.is_pair_exists(Pair(ECurrency("BTC"), ECurrency("THB"))) 45 | ) 46 | 47 | self.assertFalse( 48 | exchange.is_pair_exists(Pair(ECurrency("THB"), ECurrency("BTC"))) 49 | ) 50 | self.assertFalse( 51 | exchange.is_pair_exists(Pair(ECurrency("thb"), ECurrency("BTC"))) 52 | ) 53 | self.assertFalse( 54 | exchange.is_pair_exists(Pair(ECurrency("btc"), ECurrency("MONEY"))) 55 | ) 56 | 57 | @my_vcr.use_cassette("query_200.yaml") 58 | def test_is_currency_exists(self): 59 | exchange = SatangExchange() 60 | self.assertTrue(exchange.is_currency_exists(ECurrency(code="BTC"))) 61 | self.assertTrue(exchange.is_currency_exists(ECurrency(code="THB"))) 62 | 63 | self.assertFalse(exchange.is_currency_exists(ECurrency(code="thb"))) 64 | self.assertFalse(exchange.is_currency_exists(ECurrency(code="MONEY"))) 65 | 66 | @my_vcr.use_cassette("query_200.yaml") 67 | @freeze_time("2020-02-17 22:14:15", tz_offset=0) 68 | def test_get_pair_info(self): 69 | pair = Pair(ECurrency("BTC"), ECurrency("THB")) 70 | self.assertEqual( 71 | SatangExchange().get_pair_info(pair), 72 | PairData( 73 | pair=pair, 74 | rate=Decimal("304799.5"), 75 | last_trade_at=datetime(2020, 2, 17, 22, 14, 15), 76 | rate_open=None, 77 | low24h=None, 78 | high24h=None, 79 | ), 80 | ) 81 | 82 | @my_vcr.use_cassette("query_200.yaml") 83 | def test_get_pair_info_no_pair(self): 84 | pair = Pair(ECurrency("USD"), ECurrency("BTC")) 85 | with self.assertRaises(PairNotExistsException): 86 | SatangExchange().get_pair_info(pair) 87 | -------------------------------------------------------------------------------- /app/exchanges/tests/test_sp_today.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from decimal import Decimal 3 | 4 | from freezegun import freeze_time 5 | from vcr import VCR 6 | 7 | from app.exchanges.base import ECurrency, Pair, PairData 8 | from app.exchanges.exceptions import PairNotExistsException 9 | from app.exchanges.sp_today import SpTodayExchange 10 | from suite.test.testcases import SimpleTestCase 11 | 12 | my_vcr = VCR( 13 | cassette_library_dir="app/exchanges/tests/fixtures/vcr/sp_today", 14 | record_mode="once", 15 | decode_compressed_response=True, 16 | ) 17 | 18 | 19 | class SpTodayExchangeTest(SimpleTestCase): 20 | def test_name(self): 21 | self.assertEqual(SpTodayExchange.name, "sp-today") 22 | 23 | @my_vcr.use_cassette("query_200.yaml") 24 | def test_list_currencies(self): 25 | currencies = SpTodayExchange().list_currencies 26 | self.assertEqual(len(currencies), 18) 27 | self.assertTrue(ECurrency(code="SYP") in currencies) 28 | self.assertTrue(ECurrency(code="USD") in currencies) 29 | 30 | @my_vcr.use_cassette("query_200.yaml") 31 | def test_list_pairs(self): 32 | pairs = SpTodayExchange().list_pairs 33 | self.assertEqual(len(pairs), 17) 34 | self.assertTrue(Pair(ECurrency("USD"), ECurrency("SYP")) in pairs) 35 | self.assertTrue(Pair(ECurrency("EUR"), ECurrency("SYP")) in pairs) 36 | self.assertFalse(Pair(ECurrency("EUR"), ECurrency("USD")) in pairs) 37 | 38 | @my_vcr.use_cassette("query_200.yaml") 39 | def test_is_pair_exists(self): 40 | exchange = SpTodayExchange() 41 | self.assertTrue( 42 | exchange.is_pair_exists(Pair(ECurrency("USD"), ECurrency("SYP"))) 43 | ) 44 | 45 | self.assertFalse( 46 | exchange.is_pair_exists(Pair(ECurrency("SYP"), ECurrency("EUR"))) 47 | ) 48 | self.assertFalse( 49 | exchange.is_pair_exists(Pair(ECurrency("usd"), ECurrency("syp"))) 50 | ) 51 | self.assertFalse( 52 | exchange.is_pair_exists(Pair(ECurrency("syp"), ECurrency("MONEY"))) 53 | ) 54 | 55 | @my_vcr.use_cassette("query_200.yaml") 56 | def test_is_currency_exists(self): 57 | exchange = SpTodayExchange() 58 | self.assertTrue(exchange.is_currency_exists(ECurrency(code="SYP"))) 59 | self.assertTrue(exchange.is_currency_exists(ECurrency(code="USD"))) 60 | self.assertTrue(exchange.is_currency_exists(ECurrency(code="EUR"))) 61 | 62 | self.assertFalse(exchange.is_currency_exists(ECurrency(code="thb"))) 63 | self.assertFalse(exchange.is_currency_exists(ECurrency(code="MONEY"))) 64 | 65 | @my_vcr.use_cassette("query_200.yaml") 66 | @freeze_time("2019-03-17 22:14:15", tz_offset=0) 67 | def test_get_pair_info(self): 68 | pair = Pair(ECurrency("USD"), ECurrency("SYP")) 69 | self.assertEqual( 70 | SpTodayExchange().get_pair_info(pair), 71 | PairData( 72 | pair=pair, 73 | rate=Decimal("2105"), 74 | rate_open=None, 75 | last_trade_at=datetime(2019, 3, 17, 22, 14, 15, 0), 76 | ), 77 | ) 78 | 79 | @my_vcr.use_cassette("query_200.yaml") 80 | def test_get_pair_info_no_pair(self): 81 | pair = Pair(ECurrency("SYP"), ECurrency("BTC")) 82 | with self.assertRaises(PairNotExistsException): 83 | SpTodayExchange().get_pair_info(pair) 84 | -------------------------------------------------------------------------------- /app/formatter/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/llybin/TelegramExchangeRatesBot/7cd5e8173915dd56d2c360595cf14f8850d6e853/app/formatter/__init__.py -------------------------------------------------------------------------------- /app/formatter/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/llybin/TelegramExchangeRatesBot/7cd5e8173915dd56d2c360595cf14f8850d6e853/app/formatter/tests/__init__.py -------------------------------------------------------------------------------- /app/helpers.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from importlib import import_module 3 | from typing import Type 4 | 5 | from sqlalchemy.orm.exc import NoResultFound 6 | 7 | from app.exceptions import CurrencyNotSupportedException 8 | from app.exchanges.base import PairData 9 | from app.models import Currency, Rate 10 | from suite.database import Session 11 | 12 | 13 | def import_app_module(name: str) -> Type: 14 | components = name.rsplit(".", 1) 15 | return getattr(import_module(components[0]), components[1]) 16 | 17 | 18 | def rate_from_pair_data(pair_data: PairData, exchange_id: int) -> Rate: 19 | db_session = Session() 20 | try: 21 | from_currency = ( 22 | db_session.query(Currency) 23 | .filter_by(is_active=True, code=pair_data.pair.from_currency) 24 | .one() 25 | ) 26 | to_currency = ( 27 | db_session.query(Currency) 28 | .filter_by(is_active=True, code=pair_data.pair.to_currency) 29 | .one() 30 | ) 31 | except NoResultFound: 32 | raise CurrencyNotSupportedException(pair_data.pair) 33 | 34 | return Rate( 35 | exchange_id=exchange_id, 36 | from_currency=from_currency, 37 | to_currency=to_currency, 38 | rate=pair_data.rate, 39 | rate_open=pair_data.rate_open, 40 | low24h=pair_data.low24h, 41 | high24h=pair_data.high24h, 42 | last_trade_at=pair_data.last_trade_at, 43 | ) 44 | 45 | 46 | def fill_rate_open(new_rate: Rate, current_rate: Rate or None) -> Rate: 47 | if new_rate.rate_open: 48 | logging.debug( 49 | "rate_open provided by exchange: %d, pair: %d-%d", 50 | new_rate.exchange_id, 51 | new_rate.from_currency.id, 52 | new_rate.to_currency.id, 53 | ) 54 | return new_rate 55 | 56 | if not current_rate: 57 | if new_rate.last_trade_at.hour == 0: 58 | new_rate.rate_open = new_rate.rate 59 | logging.info( 60 | "Set new rate_open for exchange: %d, pair: %d-%d", 61 | new_rate.exchange_id, 62 | new_rate.from_currency.id, 63 | new_rate.to_currency.id, 64 | ) 65 | else: 66 | if new_rate.last_trade_at.date() == current_rate.last_trade_at.date(): 67 | new_rate.rate_open = current_rate.rate_open 68 | logging.debug( 69 | "Set existed rate_open for exchange: %d, pair: %d-%d", 70 | new_rate.exchange_id, 71 | new_rate.from_currency.id, 72 | new_rate.to_currency.id, 73 | ) 74 | elif new_rate.last_trade_at.hour == 0: 75 | new_rate.rate_open = new_rate.rate 76 | logging.info( 77 | "Set new rate_open for exchange: %d, pair: %d-%d", 78 | new_rate.exchange_id, 79 | new_rate.from_currency.id, 80 | new_rate.to_currency.id, 81 | ) 82 | else: 83 | logging.info( 84 | "Reset rate_open for exchange: %d, pair: %d-%d", 85 | new_rate.exchange_id, 86 | new_rate.from_currency.id, 87 | new_rate.to_currency.id, 88 | ) 89 | 90 | return new_rate 91 | -------------------------------------------------------------------------------- /app/keyboard.py: -------------------------------------------------------------------------------- 1 | from math import ceil 2 | 3 | 4 | class KeyboardArrows(object): 5 | def __init__(self, data, height=4, width=5, offset=0): 6 | self.data = data 7 | self.offset = max(offset, 0) 8 | self.height = height 9 | self.width = width 10 | self.page_max = height * width 11 | self.be = " " 12 | self.bl = "◀" 13 | self.br = "▶" 14 | 15 | def prev(self): 16 | self.offset -= self.page_max 17 | self.offset += self.__left_button_available() + self.__right_button_available() 18 | self.offset = max(self.offset, 0) 19 | 20 | def next(self): 21 | self.offset -= self.__left_button_available() + self.__right_button_available() 22 | self.offset += self.page_max 23 | 24 | def __left_button_available(self): 25 | return 1 if self.offset > 0 else 0 26 | 27 | def __right_button_available(self): 28 | return 1 if len(self.data) > self.offset + self.page_max - 1 else 0 29 | 30 | def show(self): 31 | right_button = self.__right_button_available() 32 | left_button = self.__left_button_available() 33 | 34 | limit_data = self.page_max - (right_button + left_button) 35 | data_page = self.data[self.offset : self.offset + limit_data] 36 | 37 | data_page += [self.be] * (limit_data - len(data_page)) 38 | 39 | if right_button: 40 | data_page.append(self.br) 41 | 42 | if left_button: 43 | data_page.insert(len(data_page) - 4, self.bl) 44 | 45 | keyboard = [] 46 | for i in range(0, len(data_page), self.width): 47 | keyboard.append(data_page[i : i + self.width]) 48 | 49 | return keyboard 50 | 51 | 52 | class KeyboardSimpleClever(object): 53 | def __init__(self, data, width=3, height=None): 54 | self.data = data 55 | self.width = width 56 | self.height = height 57 | self.be = " " 58 | 59 | def show(self): 60 | if not self.height: 61 | self.height = int(ceil(len(self.data) / self.width)) 62 | 63 | data_page = self.data 64 | data_page += [self.be] * (self.height * self.width - len(self.data)) 65 | 66 | keyboard = [] 67 | for i in range(0, len(self.data), self.width): 68 | keyboard.append(self.data[i : i + self.width]) 69 | 70 | return keyboard 71 | -------------------------------------------------------------------------------- /app/logic.py: -------------------------------------------------------------------------------- 1 | from app.helpers import import_app_module 2 | from app.keyboard import KeyboardSimpleClever 3 | from app.models import Chat 4 | from app.parsers.base import PriceRequest 5 | from app.parsers.exceptions import ValidationException 6 | from app.queries import get_last_request 7 | from suite.conf import settings 8 | from suite.database import Session 9 | 10 | 11 | def get_keyboard(chat_id: int, symbol="") -> list or None: 12 | if chat_id < 0: 13 | return None 14 | 15 | chat = Session.query(Chat).filter_by(id=chat_id).first() 16 | 17 | if not chat.is_show_keyboard: 18 | return None 19 | 20 | else: 21 | last_requests = get_last_request(chat_id) 22 | 23 | if last_requests: 24 | last_reqs_list = [ 25 | f"{symbol}{x.from_currency.code} {x.to_currency.code}" 26 | for x in last_requests 27 | ] 28 | width = int(chat.keyboard_size.split("x")[0]) 29 | return KeyboardSimpleClever(last_reqs_list, width).show() 30 | 31 | return None 32 | 33 | 34 | PARSERS = [import_app_module(parser_path) for parser_path in settings.BOT_PARSERS] 35 | 36 | 37 | def start_parse( 38 | text: str, 39 | chat_id: int, 40 | locale: str, 41 | default_currency: str, 42 | default_currency_position: bool, 43 | ) -> PriceRequest: 44 | for parser in PARSERS: 45 | try: 46 | return parser( 47 | text, chat_id, locale, default_currency, default_currency_position 48 | ).parse() 49 | except ValidationException: 50 | pass 51 | 52 | raise ValidationException 53 | -------------------------------------------------------------------------------- /app/parsers/__init__.py: -------------------------------------------------------------------------------- 1 | from .extend_regex_parser import ExtendRegexParser 2 | from .last_request_parser import LastRequestParser 3 | from .regex_parser import RegexParser 4 | 5 | __all__ = ["RegexParser", "LastRequestParser", "ExtendRegexParser"] 6 | -------------------------------------------------------------------------------- /app/parsers/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from decimal import Decimal 3 | from typing import NamedTuple 4 | 5 | from app.translations import transform_locale 6 | 7 | 8 | class DirectionWriting(object): 9 | UNKNOWN = None 10 | LEFT2RIGHT = "LEFT2RIGHT" 11 | RIGHT2LEFT = "RIGHT2LEFT" 12 | 13 | 14 | class PriceRequest(NamedTuple): 15 | amount: Decimal or None 16 | currency: str 17 | to_currency: str or None 18 | parser_name: str 19 | direction_writing: DirectionWriting = DirectionWriting.UNKNOWN 20 | 21 | 22 | class Parser(ABC): 23 | text: str 24 | chat_id: int 25 | default_currency: str 26 | default_currency_position: bool 27 | locale: str 28 | 29 | def __init__( 30 | self, 31 | text: str, 32 | chat_id: int, 33 | locale: str, 34 | default_currency: str, 35 | default_currency_position: bool, 36 | ): 37 | self.text = text 38 | self.chat_id = chat_id 39 | self.default_currency = default_currency 40 | self.default_currency_position = default_currency_position 41 | self.locale = transform_locale(locale) 42 | 43 | @property 44 | @abstractmethod 45 | def name(self) -> str: 46 | pass 47 | 48 | @abstractmethod 49 | def parse(self) -> PriceRequest: 50 | pass 51 | -------------------------------------------------------------------------------- /app/parsers/exceptions.py: -------------------------------------------------------------------------------- 1 | class ValidationException(Exception): 2 | pass 3 | 4 | 5 | class WrongFormatException(ValidationException): 6 | pass 7 | 8 | 9 | class UnknownCurrencyException(ValidationException): 10 | pass 11 | -------------------------------------------------------------------------------- /app/parsers/extend_regex_parser.py: -------------------------------------------------------------------------------- 1 | from app.parsers.regex_parser import RegexParser 2 | 3 | REPLACES = ( 4 | ("GOLD", "XAU"), 5 | ("SILVER", "XAG"), 6 | ("IRAQ", "IQD"), 7 | ("£", "GBP"), 8 | ("$", "USD"), 9 | ("DOLLAR", "USD"), 10 | ("ДОЛЛАР", "USD"), 11 | ("ДОЛАР", "USD"), 12 | ("ДОЛЛАРОВ", "USD"), 13 | ("ДОЛАРОВ", "USD"), 14 | ("€", "EUR"), 15 | ("EURO", "EUR"), 16 | ("ЕВРО", "EUR"), 17 | ("฿", "THB"), 18 | ("BHT", "THB"), 19 | ("BAHT", "THB"), 20 | ("БАТ", "THB"), 21 | ("БАТА", "THB"), 22 | ("БАТОВ", "THB"), 23 | ("BITCOIN", "BTC"), 24 | ("LITECOIN", "LTC"), 25 | ("₽", "RUB"), 26 | ("RUR", "RUB"), 27 | ("RUS", "RUB"), 28 | ("RUBL", "RUB"), 29 | ("РУБЛЬ", "RUB"), 30 | ("РУБЛЕЙ", "RUB"), 31 | ("РУБЛЯ", "RUB"), 32 | ("BLR", "BYN"), 33 | ("SUM", "UZS"), 34 | ("SOM", "UZS"), 35 | ("¥", "CNY"), 36 | ("RMB", "CNY"), 37 | ("CNH", "CNY"), 38 | ("CN¥", "CNY"), 39 | ("₴", "UAH"), 40 | ("GRN", "UAH"), 41 | ("UKR", "UAH"), 42 | ("GRV", "UAH"), 43 | ("ГРН", "UAH"), 44 | ("HRN", "UAH"), 45 | ("GRIVNA", "UAH"), 46 | ("ГРИВНА", "UAH"), 47 | ("ГРИВЕН", "UAH"), 48 | ("HRYVNIA", "UAH"), 49 | ("HRYVNYA", "UAH"), 50 | ("₩", "KRW"), 51 | ("WON", "KRW"), 52 | ) 53 | 54 | 55 | class ExtendRegexParser(RegexParser): 56 | name = "ExtendRegexParser" 57 | 58 | def __init__(self, *args, **kwargs): 59 | super().__init__(*args, **kwargs) 60 | self.text = self.text.upper() 61 | for orig, correct in REPLACES: 62 | self.text = self.text.replace(orig, correct) 63 | -------------------------------------------------------------------------------- /app/parsers/last_request_parser.py: -------------------------------------------------------------------------------- 1 | """ 2 | Last request parser, when user send only amount 3 | 4 | """ 5 | 6 | import re 7 | 8 | from babel.core import Locale 9 | 10 | from app.models import ChatRequests 11 | from app.parsers.base import DirectionWriting, Parser, PriceRequest 12 | from app.parsers.exceptions import WrongFormatException 13 | from app.parsers.regex_parser import parse_amount 14 | from suite.database import Session 15 | 16 | # len("123,456,789,012.123456789012") == 28 17 | AMOUNT_PATTERN = r"^[\d\.,\'\s]{1,28}$" 18 | AMOUNT_PATTERN_COMPILED = re.compile(AMOUNT_PATTERN) 19 | 20 | 21 | class LastRequestParser(Parser): 22 | name = "LastRequestParser" 23 | 24 | def parse(self) -> PriceRequest: 25 | text = self.text 26 | 27 | obj = AMOUNT_PATTERN_COMPILED.match(text) 28 | if not obj: 29 | raise WrongFormatException 30 | 31 | amount = obj[0] 32 | 33 | if amount: 34 | amount = parse_amount(amount, self.locale) 35 | 36 | last_request = ( 37 | Session.query(ChatRequests) 38 | .filter_by(chat_id=self.chat_id) 39 | .order_by(ChatRequests.modified_at.desc()) 40 | .first() 41 | ) 42 | 43 | if not last_request: 44 | raise WrongFormatException 45 | 46 | locale = Locale(self.locale) 47 | 48 | if locale.character_order == "right-to-left": 49 | direction_writing = DirectionWriting.RIGHT2LEFT 50 | else: 51 | direction_writing = DirectionWriting.LEFT2RIGHT 52 | 53 | return PriceRequest( 54 | amount=amount, 55 | currency=last_request.from_currency.code, 56 | to_currency=last_request.to_currency.code, 57 | parser_name=self.name, 58 | direction_writing=direction_writing, 59 | ) 60 | -------------------------------------------------------------------------------- /app/parsers/regex_parser.py: -------------------------------------------------------------------------------- 1 | """ 2 | Regex parser 3 | 4 | FORMATS: 5 | "USD EUR" 6 | "USDEUR" 7 | "EUR" 8 | "1000.00 USD EUR" 9 | "1000.00USDEUR" 10 | "1000USD" 11 | "1000.00 EUR" 12 | "EUR USD 1000.00" 13 | "EURUSD1000.00" 14 | "EUR 1000.00" 15 | "EUR100" 16 | "1,000.00 USD" 17 | "1.000,00 USD" 18 | "1 000,00 USD" 19 | """ 20 | 21 | import re 22 | from decimal import Decimal, InvalidOperation 23 | 24 | from babel.core import Locale 25 | from babel.numbers import get_decimal_symbol, get_group_symbol 26 | 27 | from app.constants import BIGGEST_VALUE 28 | from app.parsers.base import DirectionWriting, Parser, PriceRequest 29 | from app.parsers.exceptions import UnknownCurrencyException, WrongFormatException 30 | from app.queries import get_all_currency_codes 31 | 32 | CURRENCY_SEPARATORS_LIST = (r"\s", " to ", " in ", "=", " = ") 33 | CURRENCY_SEPARATORS_STR = "|".join(CURRENCY_SEPARATORS_LIST) 34 | 35 | # len("123,456,789,012.123456789012") == 28 36 | AMOUNT_PATTERN = r"[\d\.,\'\s]{1,28}" 37 | 38 | REQUEST_PATTERN = ( 39 | r"^" 40 | r"(%(amount)s)?" 41 | r"\s?" 42 | r"(" 43 | r"[a-zA-Z]{2,6}" 44 | r"((%(sep)s)?" 45 | r"[a-zA-Z]{2,6})" 46 | r"?)" 47 | r"\s?" 48 | r"(%(amount)s)?" 49 | r"$" % {"sep": CURRENCY_SEPARATORS_STR, "amount": AMOUNT_PATTERN} 50 | ) 51 | 52 | REQUEST_PATTERN_COMPILED = re.compile(REQUEST_PATTERN, re.IGNORECASE) 53 | 54 | # # usd eur | 100 usd eur | 100.22 usd eur | eur usd 100.33 55 | PRICE_REQUEST_LEFT_AMOUNT = 0 # None | 100 | 100.22 | None 56 | PRICE_REQUEST_CURRENCIES = 1 # usd eur | usd eur | usd eur | eur usd 57 | PRICE_REQUEST_RIGHT_AMOUNT = 4 # None | None | None | 100.33 58 | 59 | 60 | # https://github.com/python-babel/babel/issues/637 61 | def parse_decimal(string, locale): 62 | locale = Locale.parse(locale) 63 | decimal_symbol = get_decimal_symbol(locale) 64 | group_symbol = get_group_symbol(locale) 65 | group_symbol = " " if group_symbol == "\xa0" else group_symbol 66 | return Decimal(string.replace(group_symbol, "").replace(decimal_symbol, ".")) 67 | 68 | 69 | def parse_amount(text: str, locale: str) -> Decimal: 70 | locales = [locale, "en", "ru", "de"] 71 | for l in locales: 72 | try: 73 | number = parse_decimal(text, locale=l) 74 | if number > BIGGEST_VALUE: 75 | raise WrongFormatException 76 | else: 77 | return number 78 | except InvalidOperation: 79 | continue 80 | 81 | raise WrongFormatException 82 | 83 | 84 | class RegexParser(Parser): 85 | name = "RegexParser" 86 | 87 | def __init__(self, *args, **kwargs): 88 | super().__init__(*args, **kwargs) 89 | 90 | self.all_currencies = get_all_currency_codes() 91 | 92 | def is_currency_recognized(self, currency: str) -> bool: 93 | return currency in self.all_currencies 94 | 95 | @staticmethod 96 | def split_currencies(text: str) -> list: 97 | default_sep = " " 98 | 99 | for s in CURRENCY_SEPARATORS_LIST[1:]: 100 | text = text.replace(s.upper(), default_sep) 101 | 102 | return text.split() 103 | 104 | def parse(self) -> PriceRequest: 105 | text = self.text 106 | 107 | obj = REQUEST_PATTERN_COMPILED.match(text) 108 | if not obj: 109 | raise WrongFormatException 110 | 111 | groups = obj.groups() 112 | 113 | if groups[PRICE_REQUEST_LEFT_AMOUNT] and groups[PRICE_REQUEST_RIGHT_AMOUNT]: 114 | raise WrongFormatException 115 | 116 | if groups[PRICE_REQUEST_LEFT_AMOUNT]: 117 | direction_writing = DirectionWriting.LEFT2RIGHT 118 | 119 | elif groups[PRICE_REQUEST_RIGHT_AMOUNT]: 120 | direction_writing = DirectionWriting.RIGHT2LEFT 121 | 122 | else: 123 | direction_writing = DirectionWriting.UNKNOWN 124 | 125 | amount = groups[PRICE_REQUEST_LEFT_AMOUNT] or groups[PRICE_REQUEST_RIGHT_AMOUNT] 126 | 127 | if amount: 128 | amount = parse_amount(amount, self.locale) 129 | 130 | text = groups[PRICE_REQUEST_CURRENCIES] 131 | text = text.upper() 132 | 133 | currencies = self.split_currencies(text) 134 | 135 | if len(currencies) == 2: 136 | currency, to_currency = currencies[0], currencies[1] 137 | if not self.is_currency_recognized( 138 | currency 139 | ) or not self.is_currency_recognized(to_currency): 140 | raise UnknownCurrencyException 141 | 142 | elif len(currencies) == 1 and self.is_currency_recognized(currencies[0]): 143 | if self.default_currency_position: 144 | currency, to_currency = currencies[0], self.default_currency 145 | else: 146 | currency, to_currency = self.default_currency, currencies[0] 147 | 148 | if direction_writing == DirectionWriting.RIGHT2LEFT: 149 | currency, to_currency = to_currency, currency 150 | 151 | else: 152 | currencies = currencies[0] 153 | for x in range(2, len(currencies) - 1): 154 | currency, to_currency = currencies[:x], currencies[x:] 155 | if self.is_currency_recognized( 156 | currency 157 | ) and self.is_currency_recognized(to_currency): 158 | break 159 | else: 160 | raise WrongFormatException 161 | 162 | if direction_writing == DirectionWriting.RIGHT2LEFT: 163 | currency, to_currency = to_currency, currency 164 | 165 | return PriceRequest( 166 | amount=amount, 167 | currency=currency, 168 | to_currency=to_currency, 169 | parser_name=self.name, 170 | direction_writing=direction_writing, 171 | ) 172 | -------------------------------------------------------------------------------- /app/parsers/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/llybin/TelegramExchangeRatesBot/7cd5e8173915dd56d2c360595cf14f8850d6e853/app/parsers/tests/__init__.py -------------------------------------------------------------------------------- /app/queries.py: -------------------------------------------------------------------------------- 1 | from app.cache import region 2 | from app.models import Chat, ChatRequests, Currency 3 | from suite.database import Session 4 | 5 | 6 | @region.cache_on_arguments(expiration_time=300) 7 | def get_all_currency_codes(): 8 | codes = ( 9 | Session.query(Currency.code).filter_by(is_active=True).order_by(Currency.code) 10 | ) 11 | return [x[0] for x in codes] 12 | 13 | 14 | @region.cache_on_arguments(expiration_time=300) 15 | def get_all_currencies(): 16 | return ( 17 | Session.query(Currency.code, Currency.name) 18 | .filter_by(is_active=True) 19 | .order_by(Currency.name) 20 | .all() 21 | ) 22 | 23 | 24 | def get_keyboard_size(chat_id): 25 | chat = Session.query(Chat.keyboard_size).filter_by(id=chat_id).first() 26 | w, h = chat[0].split("x") 27 | return int(w) * int(h) 28 | 29 | 30 | def get_last_request(chat_id): 31 | size = get_keyboard_size(chat_id) 32 | 33 | return ( 34 | Session.query(ChatRequests) 35 | .filter_by(chat_id=chat_id) 36 | .order_by(ChatRequests.times.desc(), ChatRequests.modified_at.asc()) 37 | .limit(size) 38 | .all() 39 | ) 40 | 41 | 42 | def have_last_request(chat_id): 43 | return Session.query(ChatRequests).filter_by(chat_id=chat_id).first() 44 | -------------------------------------------------------------------------------- /app/sentry.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import sentry_sdk 4 | from sentry_sdk.integrations.celery import CeleryIntegration 5 | from sentry_sdk.integrations.logging import LoggingIntegration 6 | from sentry_sdk.integrations.redis import RedisIntegration 7 | from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration 8 | from sentry_sdk.integrations.tornado import TornadoIntegration 9 | 10 | from suite.conf import settings 11 | 12 | 13 | def before_send(event, hint): 14 | """Filtering""" 15 | for x in event["breadcrumbs"]: 16 | if x["category"] == "httplib": 17 | x["data"]["url"] = x["data"]["url"].replace( 18 | settings.BOT_TOKEN, "" 19 | ) 20 | x["data"]["url"] = x["data"]["url"].replace( 21 | settings.DEVELOPER_BOT_TOKEN, "" 22 | ) 23 | 24 | return event 25 | 26 | 27 | def init_sentry(): 28 | if settings.SENTRY_URL: 29 | sentry_logging = LoggingIntegration( 30 | level=logging.INFO, # Capture info and above as breadcrumbs 31 | event_level=logging.WARNING, # Send errors as events 32 | ) 33 | 34 | sentry_sdk.init( 35 | dsn=settings.SENTRY_URL, 36 | before_send=before_send, 37 | integrations=[ 38 | CeleryIntegration(), 39 | sentry_logging, 40 | TornadoIntegration(), 41 | RedisIntegration(), 42 | SqlalchemyIntegration(), 43 | ], 44 | ) 45 | -------------------------------------------------------------------------------- /app/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/llybin/TelegramExchangeRatesBot/7cd5e8173915dd56d2c360595cf14f8850d6e853/app/tests/__init__.py -------------------------------------------------------------------------------- /app/tests/test_helpers.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from decimal import Decimal 3 | 4 | from sqlalchemy import inspect 5 | 6 | from app.exchanges.base import ECurrency, Pair, PairData 7 | from app.helpers import fill_rate_open, rate_from_pair_data 8 | from app.models import Currency, Rate 9 | from suite.test.testcases import SimpleTestCase 10 | 11 | 12 | class RateFromPairDataTest(SimpleTestCase): 13 | def test_ok(self): 14 | pair_data = PairData( 15 | pair=Pair(ECurrency("BTC"), ECurrency("USD")), 16 | rate=Decimal("1") / Decimal("3"), 17 | last_trade_at=datetime(2019, 3, 9, 12), 18 | rate_open=Decimal("1") / Decimal("2"), 19 | low24h=Decimal("1") / Decimal("4"), 20 | high24h=Decimal("1") / Decimal("8"), 21 | ) 22 | 23 | rate_obj = rate_from_pair_data(pair_data, exchange_id=1) 24 | 25 | inst = inspect(rate_obj) 26 | self.assertSetEqual( 27 | {c_attr.key for c_attr in inst.mapper.column_attrs}, 28 | { 29 | "id", 30 | "exchange_id", 31 | "from_currency_id", 32 | "to_currency_id", 33 | "rate", 34 | "rate_open", 35 | "low24h", 36 | "high24h", 37 | "last_trade_at", 38 | "created_at", 39 | "modified_at", 40 | }, 41 | ) 42 | 43 | self.assertEqual(rate_obj.exchange_id, 1) 44 | self.assertEqual(rate_obj.from_currency.code, pair_data.pair.from_currency.code) 45 | self.assertEqual(rate_obj.to_currency.code, pair_data.pair.to_currency.code) 46 | self.assertEqual(rate_obj.rate, pair_data.rate) 47 | self.assertEqual(rate_obj.rate_open, pair_data.rate_open) 48 | self.assertEqual(rate_obj.low24h, pair_data.low24h) 49 | self.assertEqual(rate_obj.high24h, pair_data.high24h) 50 | self.assertEqual(rate_obj.last_trade_at, pair_data.last_trade_at) 51 | 52 | 53 | class FillRateOpenTest(SimpleTestCase): 54 | def setUp(self): 55 | self.current_rate = Rate( 56 | from_currency=Currency(code="BTC"), 57 | to_currency=Currency(code="USD"), 58 | rate=Decimal("2"), 59 | rate_open=Decimal("11"), 60 | last_trade_at=datetime(2019, 3, 8, 11, 10, 0), 61 | ) 62 | 63 | self.new_rate = Rate( 64 | from_currency=Currency(code="BTC"), 65 | to_currency=Currency(code="USD"), 66 | rate=Decimal("1"), 67 | last_trade_at=datetime(2019, 3, 9, 12, 11, 0), 68 | ) 69 | 70 | def test_first_create_midnight_no_open_rate(self): 71 | self.assertEqual(self.new_rate.rate_open, None) 72 | 73 | self.new_rate.last_trade_at = datetime(2019, 3, 9, 0, 0, 0) 74 | 75 | self.new_rate = fill_rate_open(new_rate=self.new_rate, current_rate=None) 76 | 77 | self.assertEqual(self.new_rate.rate_open, self.new_rate.rate) 78 | 79 | def test_first_create_not_midnight_no_open_rate(self): 80 | self.assertEqual(self.new_rate.rate_open, None) 81 | 82 | self.new_rate.last_trade_at = datetime(2019, 3, 9, 1, 0, 0) 83 | 84 | self.new_rate = fill_rate_open(new_rate=self.new_rate, current_rate=None) 85 | 86 | self.assertEqual(self.new_rate.rate_open, None) 87 | 88 | def test_first_create_midnight_open_rate_exists(self): 89 | self.new_rate.rate_open = Decimal("10") 90 | self.new_rate.last_trade_at = datetime(2019, 3, 9, 0, 0, 0) 91 | 92 | self.new_rate = fill_rate_open(new_rate=self.new_rate, current_rate=None) 93 | 94 | self.assertEqual(self.new_rate.rate_open, Decimal("10")) 95 | 96 | def test_first_create_not_midnight_open_rate_exists(self): 97 | self.new_rate.rate_open = Decimal("10") 98 | self.new_rate.last_trade_at = datetime(2019, 3, 9, 1, 0, 0) 99 | 100 | self.new_rate = fill_rate_open(new_rate=self.new_rate, current_rate=None) 101 | 102 | self.assertEqual(self.new_rate.rate_open, Decimal("10")) 103 | 104 | def test_not_first_create_midnight_open_rate_exists(self): 105 | self.new_rate.rate_open = Decimal("10") 106 | self.new_rate.last_trade_at = datetime(2019, 3, 9, 0, 0, 0) 107 | 108 | self.new_rate = fill_rate_open( 109 | new_rate=self.new_rate, current_rate=self.current_rate 110 | ) 111 | 112 | self.assertEqual(self.new_rate.rate_open, Decimal("10")) 113 | 114 | def test_not_first_create_midnight_no_open_rate_first_set(self): 115 | self.new_rate.last_trade_at = datetime(2019, 3, 10, 0, 0, 0) 116 | 117 | self.new_rate = fill_rate_open( 118 | new_rate=self.new_rate, current_rate=self.current_rate 119 | ) 120 | 121 | self.assertEqual(self.new_rate.rate_open, Decimal("1")) 122 | 123 | def test_not_first_create_not_midnight_no_open_rate_first_set(self): 124 | self.new_rate.last_trade_at = datetime(2019, 3, 10, 1, 0, 0) 125 | 126 | self.new_rate = fill_rate_open( 127 | new_rate=self.new_rate, current_rate=self.current_rate 128 | ) 129 | 130 | self.assertEqual(self.new_rate.rate_open, None) 131 | 132 | def test_not_first_create_midnight_no_open_rate_not_first_set(self): 133 | self.current_rate.last_trade_at = datetime(2019, 3, 10, 0, 5, 0) 134 | self.new_rate.last_trade_at = datetime(2019, 3, 10, 0, 10, 0) 135 | 136 | self.new_rate = fill_rate_open( 137 | new_rate=self.new_rate, current_rate=self.current_rate 138 | ) 139 | 140 | self.assertEqual(self.new_rate.rate_open, Decimal("11")) 141 | -------------------------------------------------------------------------------- /app/tests/test_logic.py: -------------------------------------------------------------------------------- 1 | from app.logic import PARSERS 2 | from suite.conf import settings 3 | from suite.test.testcases import SimpleTestCase 4 | 5 | 6 | class ParsingTest(SimpleTestCase): 7 | def test_parsers(self): 8 | self.assertEqual( 9 | list(map(lambda x: x.__name__, PARSERS)), 10 | list(map(lambda x: x.rsplit(".", 1)[1], settings.BOT_PARSERS)), 11 | ) 12 | -------------------------------------------------------------------------------- /app/tests/test_main.py: -------------------------------------------------------------------------------- 1 | from telegram import Update 2 | 3 | from app.main import error_callback 4 | from suite.test.testcases import SimpleTestCase 5 | 6 | 7 | class MainTest(SimpleTestCase): 8 | def test_error_handler(self): 9 | class CallbackContext(object): 10 | error = "error msg" 11 | 12 | self.assertIsNone(error_callback(Update("0"), CallbackContext())) 13 | -------------------------------------------------------------------------------- /app/tests/test_queries.py: -------------------------------------------------------------------------------- 1 | from app.queries import get_all_currency_codes 2 | from suite.test.testcases import SimpleTestCase 3 | 4 | 5 | class GetAllCurrenciesTest(SimpleTestCase): 6 | def test_get_all_currencies(self): 7 | self.assertEqual(len(get_all_currency_codes()), 211) 8 | -------------------------------------------------------------------------------- /app/tests/test_tasks.py: -------------------------------------------------------------------------------- 1 | from app.tasks import PairData 2 | from suite.test.testcases import SimpleTestCase 3 | 4 | 5 | class TasksTest(SimpleTestCase): 6 | def test_todo(self): 7 | self.assertTrue(PairData) 8 | -------------------------------------------------------------------------------- /app/tests/test_translations.py: -------------------------------------------------------------------------------- 1 | from app.translations import get_translations, init_translations, transform_locale 2 | from suite.test.testcases import SimpleTestCase 3 | 4 | 5 | class TranslationsTest(SimpleTestCase): 6 | def setUp(self) -> None: 7 | init_translations() 8 | 9 | def test_1_exists(self): 10 | self.assertTrue(get_translations("en")) 11 | 12 | def test_1_not_exists(self): 13 | self.assertTrue(get_translations("qq")) 14 | 15 | def test_2_exists(self): 16 | self.assertTrue(get_translations("en-us")) 17 | 18 | def test_2_not_exists(self): 19 | self.assertTrue(get_translations("qq-qq")) 20 | 21 | def test_2_4_exists(self): 22 | self.assertTrue(get_translations("zh-hans")) 23 | 24 | def test_2_4_not_exists(self): 25 | self.assertTrue(get_translations("qq-qqqq")) 26 | 27 | def test_3_exists(self): 28 | self.assertTrue(get_translations("zh-hans-sg")) 29 | 30 | def test_3_not_exists(self): 31 | self.assertTrue(get_translations("qq-qqqq-qq")) 32 | 33 | 34 | class TransformLocaleTest(SimpleTestCase): 35 | def setUp(self) -> None: 36 | init_translations() 37 | 38 | def test_1(self): 39 | self.assertEqual(transform_locale("ru"), "ru") 40 | 41 | def test_2(self): 42 | self.assertEqual(transform_locale("en-us"), "en_US") 43 | 44 | def test_2_4(self): 45 | self.assertEqual(transform_locale("zh-hans"), "zh_Hans") 46 | 47 | def test_3(self): 48 | self.assertEqual(transform_locale("zh-hans-sg"), "zh_Hans_SG") 49 | 50 | def test_unknown(self): 51 | self.assertEqual(transform_locale("zh-hans-sg-any"), "en") 52 | self.assertEqual(transform_locale("zh-h"), "en") 53 | -------------------------------------------------------------------------------- /app/translations.py: -------------------------------------------------------------------------------- 1 | import gettext 2 | import logging 3 | 4 | from suite.conf import settings 5 | 6 | _ = gettext.gettext 7 | 8 | translations = dict() 9 | 10 | 11 | def init_translations(): 12 | for l in settings.LANGUAGES: 13 | # zh_Hans to zh-hans 14 | key = l.lower().replace("_", "-") 15 | translations[key] = gettext.translation( 16 | "messages", localedir="locale", languages=[l] 17 | ) 18 | 19 | 20 | def get_translations(language_code: str) -> gettext: 21 | if language_code in translations: 22 | return translations[language_code].gettext 23 | 24 | elif language_code[:2] in translations: 25 | # en-us 26 | return translations[language_code[:2]].gettext 27 | 28 | elif language_code[:7] in translations: 29 | # zh-hans-sg 30 | return translations[language_code[:7]].gettext 31 | 32 | else: 33 | logging.info("No translations for language: %s", language_code) 34 | return gettext.gettext 35 | 36 | 37 | def transform_locale(locale: str) -> str: 38 | """ Transform format locale 39 | See available formats: site-packages/babel/locale-data 40 | """ 41 | locale_parts = locale.split("-") 42 | len_parts = len(locale_parts) 43 | 44 | if len_parts == 1: 45 | # zh 46 | return locale_parts[0] 47 | 48 | elif len_parts == 2: 49 | len_second = len(locale_parts[1]) 50 | if len_second == 2: 51 | # br-pt -> br_PT 52 | return f"{locale_parts[0].lower()}_{locale_parts[1].upper()}" 53 | elif len_second > 2: 54 | # zh-hans -> zh_Hans 55 | return f"{locale_parts[0].lower()}_{locale_parts[1].capitalize()}" 56 | 57 | elif len_parts == 3: 58 | # zh-hans-sg -> zh_Hans_SG 59 | return f"{locale_parts[0].lower()}_{locale_parts[1].capitalize()}_{locale_parts[2].upper()}" 60 | 61 | logging.error("Unknown format locale: %s", locale) 62 | return settings.LANGUAGE_CODE 63 | -------------------------------------------------------------------------------- /babel.cfg: -------------------------------------------------------------------------------- 1 | [ignore: old_code/**] 2 | [ignore: **/tests/**] 3 | 4 | [python: **.py] 5 | encoding = utf-8 6 | -------------------------------------------------------------------------------- /docker-cmd.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | set -o errexit 4 | set -o nounset 5 | 6 | if [ "$APP_MIGRATE" = "on" ]; then 7 | echo "Apply database migrations" 8 | python manage.py db migrate 9 | fi 10 | 11 | if [ "$START_APP" = "on" ]; then 12 | echo "Starting server" 13 | python manage.py start 14 | fi 15 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | db: 4 | image: postgres:10.11 5 | hostname: db 6 | restart: always 7 | volumes: 8 | - postgresql_data:/var/lib/postgresql/data 9 | 10 | redis: 11 | image: redis:6.0.5 12 | hostname: redis 13 | restart: always 14 | volumes: 15 | - redis_data:/data 16 | 17 | service: 18 | build: 19 | context: . 20 | dockerfile: Dockerfile 21 | stdin_open: true 22 | tty: true 23 | restart: always 24 | volumes: 25 | - .:/app 26 | env_file: 27 | - .env 28 | environment: 29 | APP_MIGRATE: "on" 30 | START_APP: "on" 31 | depends_on: 32 | - db 33 | - redis 34 | 35 | celery: 36 | build: 37 | context: . 38 | dockerfile: Dockerfile 39 | restart: always 40 | command: ["celery", "-A", "app.celery", "worker", "-l", "info", "-c", "2"] 41 | volumes: 42 | - .:/app 43 | env_file: 44 | - .env 45 | depends_on: 46 | - db 47 | - redis 48 | 49 | celery-beat: 50 | build: 51 | context: . 52 | dockerfile: Dockerfile 53 | command: ["celery", "-A", "app.celery", "beat", "-l", "info", "-s", "/tmp/celerybeat-schedule.db", "--pidfile", "/tmp/celeryd.pid"] 54 | volumes: 55 | - .:/app 56 | env_file: 57 | - .env 58 | depends_on: 59 | - db 60 | - redis 61 | 62 | volumes: 63 | postgresql_data: 64 | redis_data: 65 | -------------------------------------------------------------------------------- /docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | set -o errexit 4 | set -o nounset 5 | 6 | cmd="$*" 7 | 8 | # TODO: get rid hardcode 9 | db_ready() { 10 | dockerize -wait "tcp://db:5432" -timeout 10s 11 | } 12 | 13 | until db_ready; do 14 | echo >&2 'DB is unavailable - sleeping' 15 | done 16 | 17 | echo >&2 'DB is up - continuing...' 18 | 19 | # TODO: BROKER_URL 20 | 21 | # TODO: get rid hardcode 22 | redis_ready() { 23 | dockerize -wait "tcp://redis:6379" -timeout 10s 24 | } 25 | 26 | until redis_ready; do 27 | echo >&2 'REDIS is unavailable - sleeping' 28 | done 29 | 30 | echo >&2 'REDIS is up - continuing...' 31 | 32 | # Evaluating passed command (do not touch): 33 | # shellcheck disable=SC2086 34 | exec $cmd 35 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | 4 | if __name__ == "__main__": 5 | os.environ.setdefault("SETTINGS_MODULE", "app.settings") 6 | 7 | from suite.management import cli 8 | 9 | cli() 10 | -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | 3 | from logging.config import fileConfig 4 | 5 | from alembic import context 6 | from sqlalchemy import engine_from_config, pool 7 | 8 | from app.models import BaseObject 9 | 10 | # this is the Alembic Config object, which provides 11 | # access to the values within the .ini file in use. 12 | config = context.config 13 | 14 | # Interpret the config file for Python logging. 15 | # This line sets up loggers basically. 16 | fileConfig(config.config_file_name) 17 | 18 | # add your model's MetaData object here 19 | # for 'autogenerate' support 20 | # from myapp import mymodel 21 | # target_metadata = mymodel.Base.metadata 22 | target_metadata = BaseObject.metadata 23 | 24 | # other values from the config, defined by the needs of env.py, 25 | # can be acquired: 26 | # my_important_option = config.get_main_option("my_important_option") 27 | # ... etc. 28 | 29 | 30 | def run_migrations_offline(): 31 | """Run migrations in 'offline' mode. 32 | 33 | This configures the context with just a URL 34 | and not an Engine, though an Engine is acceptable 35 | here as well. By skipping the Engine creation 36 | we don't even need a DBAPI to be available. 37 | 38 | Calls to context.execute() here emit the given string to the 39 | script output. 40 | 41 | """ 42 | url = config.get_main_option("sqlalchemy.url") 43 | context.configure( 44 | url=url, 45 | target_metadata=target_metadata, 46 | literal_binds=True, 47 | transaction_per_migration=True, 48 | ) 49 | 50 | with context.begin_transaction(): 51 | context.run_migrations() 52 | 53 | 54 | def run_migrations_online(): 55 | """Run migrations in 'online' mode. 56 | 57 | In this scenario we need to create an Engine 58 | and associate a connection with the context. 59 | 60 | """ 61 | connectable = engine_from_config( 62 | config.get_section(config.config_ini_section), 63 | prefix="sqlalchemy.", 64 | poolclass=pool.NullPool, 65 | ) 66 | 67 | with connectable.connect() as connection: 68 | context.configure( 69 | connection=connection, 70 | target_metadata=target_metadata, 71 | transaction_per_migration=True, 72 | ) 73 | 74 | with context.begin_transaction(): 75 | context.run_migrations() 76 | 77 | 78 | if context.is_offline_mode(): 79 | run_migrations_offline() 80 | else: 81 | run_migrations_online() 82 | -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /migrations/versions/20190306160951_initial.py: -------------------------------------------------------------------------------- 1 | """initial 2 | 3 | Revision ID: 20190306160951 4 | Revises: 5 | Create Date: 2019-03-06 16:09:51.510721 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "20190306160951" 13 | down_revision = None 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | op.create_table( 21 | "chat_rates", 22 | sa.Column("id", sa.Integer(), nullable=False), 23 | sa.Column("chat_id", sa.BigInteger(), nullable=False), 24 | sa.Column("currencies", sa.Text(), nullable=False), 25 | sa.Column("cnt", sa.Integer(), server_default="1", nullable=False), 26 | sa.Column( 27 | "updated", sa.TIMESTAMP(), server_default=sa.text("now()"), nullable=False 28 | ), 29 | sa.PrimaryKeyConstraint("id"), 30 | ) 31 | op.create_table( 32 | "chats", 33 | sa.Column("id", sa.BigInteger(), nullable=False, autoincrement=False), 34 | sa.Column("first_name", sa.Text(), nullable=True), 35 | sa.Column("username", sa.Text(), nullable=True), 36 | sa.Column("locale", sa.Text(), nullable=False), 37 | sa.Column("is_subscribed", sa.Boolean(), nullable=False), 38 | sa.Column("is_console_mode", sa.Boolean(), nullable=False), 39 | sa.Column( 40 | "created", sa.TIMESTAMP(), server_default=sa.text("now()"), nullable=False 41 | ), 42 | sa.Column( 43 | "updated", sa.TIMESTAMP(), server_default=sa.text("now()"), nullable=False 44 | ), 45 | sa.PrimaryKeyConstraint("id"), 46 | ) 47 | op.create_table( 48 | "messages", 49 | sa.Column("id", sa.Integer(), nullable=False), 50 | sa.Column("chat_id", sa.BigInteger(), nullable=False), 51 | sa.Column("user_id", sa.BigInteger(), nullable=False), 52 | sa.Column("message", sa.Text(), nullable=False), 53 | sa.Column("tag", sa.Text(), nullable=True), 54 | sa.Column( 55 | "created", sa.TIMESTAMP(), server_default=sa.text("now()"), nullable=False 56 | ), 57 | sa.PrimaryKeyConstraint("id"), 58 | ) 59 | # ### end Alembic commands ### 60 | 61 | 62 | def downgrade(): 63 | # ### commands auto generated by Alembic - please adjust! ### 64 | op.drop_table("messages") 65 | op.drop_table("chats") 66 | op.drop_table("chat_rates") 67 | # ### end Alembic commands ### 68 | -------------------------------------------------------------------------------- /migrations/versions/20190306162241_rename_tables.py: -------------------------------------------------------------------------------- 1 | """rename_tables 2 | 3 | Revision ID: 20190306162241 4 | Revises: 20190306160951 5 | Create Date: 2019-03-06 16:22:41.721964 6 | 7 | """ 8 | from alembic import op 9 | 10 | # revision identifiers, used by Alembic. 11 | revision = "20190306162241" 12 | down_revision = "20190306160951" 13 | branch_labels = None 14 | depends_on = None 15 | 16 | 17 | def upgrade(): 18 | op.execute("SELECT setval('chat_rates_id_seq', (SELECT MAX(id) from chat_rates))") 19 | op.rename_table("chat_rates", "chat_requests") 20 | op.execute("ALTER SEQUENCE chat_rates_id_seq RENAME TO chat_requests_id_seq") 21 | op.execute("ALTER INDEX chat_rates_pkey RENAME TO chat_requests_pkey") 22 | 23 | op.execute("SELECT setval('messages_id_seq', (SELECT MAX(id) from messages))") 24 | op.rename_table("messages", "requests_log") 25 | op.execute("ALTER SEQUENCE messages_id_seq RENAME TO requests_log_id_seq") 26 | op.execute("ALTER INDEX messages_pkey RENAME TO requests_log_pkey") 27 | 28 | 29 | def downgrade(): 30 | op.rename_table("chat_requests", "chat_rates") 31 | op.execute("ALTER SEQUENCE chat_requests_id_seq RENAME TO chat_rates_id_seq") 32 | op.execute("ALTER INDEX chat_requests_pkey RENAME TO chat_rates_pkey") 33 | 34 | op.rename_table("requests_log", "messages") 35 | op.execute("ALTER SEQUENCE request_logs_id_seq RENAME TO messages_id_seq") 36 | op.execute("ALTER INDEX request_logs_pkey RENAME TO messages_pkey") 37 | -------------------------------------------------------------------------------- /migrations/versions/20190306164236_chat_request_chat_foreigns.py: -------------------------------------------------------------------------------- 1 | """chat_request_chat_foreigns 2 | 3 | Revision ID: 20190306164236 4 | Revises: 20190306162241 5 | Create Date: 2019-03-06 16:42:36.594307 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | from sqlalchemy import orm 11 | from sqlalchemy.ext.declarative import declarative_base 12 | from sqlalchemy.orm.session import Session 13 | 14 | # revision identifiers, used by Alembic. 15 | revision = "20190306164236" 16 | down_revision = "20190306162241" 17 | branch_labels = None 18 | depends_on = None 19 | 20 | Base = declarative_base() 21 | 22 | 23 | class Chat(Base): 24 | __tablename__ = "chats" 25 | 26 | id = sa.Column(sa.BigInteger, primary_key=True, autoincrement=False) 27 | 28 | 29 | class ChatRequests(Base): 30 | __tablename__ = "chat_requests" 31 | 32 | id = sa.Column(sa.Integer, primary_key=True) 33 | chat_id = sa.Column(sa.BigInteger, sa.ForeignKey("chats.id"), nullable=False) 34 | chat = orm.relationship("Chat") 35 | 36 | 37 | class RequestsLog(Base): 38 | __tablename__ = "requests_log" 39 | 40 | id = sa.Column(sa.Integer, primary_key=True) 41 | chat_id = sa.Column(sa.BigInteger, sa.ForeignKey("chats.id"), nullable=False) 42 | chat = orm.relationship("Chat") 43 | 44 | 45 | def upgrade(): 46 | op.drop_column("requests_log", "user_id") 47 | op.alter_column("requests_log", "created", new_column_name="created_at") 48 | 49 | op.alter_column("chat_requests", "updated", new_column_name="modified_at") 50 | op.alter_column("chats", "created", new_column_name="created_at") 51 | op.alter_column("chats", "updated", new_column_name="modified_at") 52 | 53 | session = Session(bind=op.get_bind()) 54 | 55 | # checking relations chat_requests on chats 56 | for x in session.query(ChatRequests).yield_per(1000): 57 | if not x.chat: 58 | print(f"delete bad data from chat_requests {x}") 59 | session.delete(x) 60 | 61 | op.create_foreign_key(None, "chat_requests", "chats", ["chat_id"], ["id"]) 62 | 63 | # checking relations requests_log on chats 64 | for x in session.query(RequestsLog).yield_per(1000): 65 | if not x.chat: 66 | print(f"delete bad data from requests_log: {x}") 67 | session.delete(x) 68 | 69 | op.create_foreign_key(None, "requests_log", "chats", ["chat_id"], ["id"]) 70 | 71 | 72 | def downgrade(): 73 | pass 74 | -------------------------------------------------------------------------------- /migrations/versions/20190311151631_exchange_rates.py: -------------------------------------------------------------------------------- 1 | """exchange_rates 2 | 3 | Revision ID: 20190311151631 4 | Revises: 20190306193447 5 | Create Date: 2019-03-11 15:16:31.245365 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | from sqlalchemy.ext.declarative import declarative_base 11 | from sqlalchemy.orm.session import Session 12 | 13 | from app.exchanges import BitfinexExchange, BittrexExchange, OpenExchangeRatesExchange 14 | 15 | # revision identifiers, used by Alembic. 16 | revision = "20190311151631" 17 | down_revision = "20190306193447" 18 | branch_labels = None 19 | depends_on = None 20 | 21 | Base = declarative_base() 22 | 23 | 24 | class Exchange(Base): 25 | __tablename__ = "exchanges" 26 | 27 | id = sa.Column(sa.Integer, primary_key=True) 28 | name = sa.Column(sa.Text, nullable=False, index=True) 29 | weight = sa.Column(sa.Integer, nullable=False) 30 | is_active = sa.Column(sa.Boolean, nullable=False) 31 | 32 | 33 | def upgrade(): 34 | # ### commands auto generated by Alembic - please adjust! ### 35 | op.create_table( 36 | "exchanges", 37 | sa.Column("id", sa.Integer(), nullable=False), 38 | sa.Column("name", sa.Text(), nullable=False), 39 | sa.Column("weight", sa.Integer(), nullable=False), 40 | sa.Column("is_active", sa.Boolean(), nullable=False), 41 | sa.PrimaryKeyConstraint("id"), 42 | ) 43 | op.create_index(op.f("ix_exchanges_name"), "exchanges", ["name"], unique=False) 44 | op.create_table( 45 | "rates", 46 | sa.Column("id", sa.Integer(), nullable=False), 47 | sa.Column("exchange_id", sa.Integer(), nullable=False), 48 | sa.Column("from_currency_id", sa.Integer(), nullable=False), 49 | sa.Column("to_currency_id", sa.Integer(), nullable=False), 50 | sa.Column("rate", sa.Numeric(precision=24, scale=12), nullable=False), 51 | sa.Column("rate_open", sa.Numeric(precision=24, scale=12), nullable=True), 52 | sa.Column("low24h", sa.Numeric(precision=24, scale=12), nullable=True), 53 | sa.Column("high24h", sa.Numeric(precision=24, scale=12), nullable=True), 54 | sa.Column("last_trade_at", sa.TIMESTAMP(), nullable=False), 55 | sa.Column( 56 | "created_at", 57 | sa.TIMESTAMP(), 58 | server_default=sa.text("now()"), 59 | nullable=False, 60 | ), 61 | sa.Column( 62 | "modified_at", 63 | sa.TIMESTAMP(), 64 | server_default=sa.text("now()"), 65 | nullable=False, 66 | ), 67 | sa.ForeignKeyConstraint(["exchange_id"], ["exchanges.id"]), 68 | sa.ForeignKeyConstraint(["from_currency_id"], ["currencies.id"]), 69 | sa.ForeignKeyConstraint(["to_currency_id"], ["currencies.id"]), 70 | sa.PrimaryKeyConstraint("id"), 71 | sa.UniqueConstraint("exchange_id", "from_currency_id", "to_currency_id"), 72 | ) 73 | # ### end Alembic commands ### 74 | 75 | exchanges = [ 76 | Exchange(name=BitfinexExchange.name, is_active=True, weight=30), 77 | Exchange(name=BittrexExchange.name, is_active=True, weight=20), 78 | Exchange(name=OpenExchangeRatesExchange.name, is_active=False, weight=10), 79 | ] 80 | session = Session(bind=op.get_bind()) 81 | session.add_all(exchanges) 82 | session.flush() 83 | 84 | 85 | def downgrade(): 86 | # ### commands auto generated by Alembic - please adjust! ### 87 | op.drop_table("rates") 88 | op.drop_index(op.f("ix_exchanges_name"), table_name="exchanges") 89 | op.drop_table("exchanges") 90 | # ### end Alembic commands ### 91 | -------------------------------------------------------------------------------- /migrations/versions/20190314103043_requests_log_refact.py: -------------------------------------------------------------------------------- 1 | """requests_log_refact 2 | 3 | Revision ID: 20190314103043 4 | Revises: 20190311151631 5 | Create Date: 2019-03-14 10:30:43.853502 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | from sqlalchemy.ext.declarative import declarative_base 11 | from sqlalchemy.orm.session import Session 12 | from sqlalchemy.sql.expression import func 13 | 14 | # revision identifiers, used by Alembic. 15 | revision = "20190314103043" 16 | down_revision = "20190311151631" 17 | branch_labels = None 18 | depends_on = None 19 | 20 | MAX_LEN_MSG_REQUESTS_LOG = 100 21 | 22 | Base = declarative_base() 23 | 24 | 25 | class RequestsLog(Base): 26 | __tablename__ = "requests_log" 27 | 28 | id = sa.Column(sa.Integer, primary_key=True) 29 | message = sa.Column(sa.Text, nullable=False) 30 | tag = sa.Column(sa.Text, nullable=False) 31 | 32 | 33 | def upgrade(): 34 | session = Session(bind=op.get_bind()) 35 | session.query(RequestsLog).filter( 36 | func.length(RequestsLog.message) > MAX_LEN_MSG_REQUESTS_LOG 37 | ).delete(synchronize_session=False) 38 | session.query(RequestsLog).filter_by(tag=None).update({"tag": ""}) 39 | 40 | # ### commands auto generated by Alembic - please adjust! ### 41 | op.alter_column("requests_log", "tag", existing_type=sa.TEXT(), nullable=False) 42 | # ### end Alembic commands ### 43 | 44 | 45 | def downgrade(): 46 | # ### commands auto generated by Alembic - please adjust! ### 47 | op.alter_column("requests_log", "tag", existing_type=sa.TEXT(), nullable=True) 48 | # ### end Alembic commands ### 49 | -------------------------------------------------------------------------------- /migrations/versions/20190314175025_events.py: -------------------------------------------------------------------------------- 1 | """events 2 | 3 | Revision ID: 20190314175025 4 | Revises: 20190314103043 5 | Create Date: 2019-03-14 17:50:25.703943 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "20190314175025" 13 | down_revision = "20190314103043" 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | op.create_table( 21 | "events", 22 | sa.Column("id", sa.Integer(), nullable=False), 23 | sa.Column("chat_id", sa.BigInteger(), nullable=False), 24 | sa.Column("event", sa.Text(), nullable=False), 25 | sa.Column( 26 | "created_at", 27 | sa.TIMESTAMP(), 28 | server_default=sa.text("now()"), 29 | nullable=False, 30 | ), 31 | sa.ForeignKeyConstraint(["chat_id"], ["chats.id"]), 32 | sa.PrimaryKeyConstraint("id"), 33 | sa.UniqueConstraint("chat_id", "event"), 34 | ) 35 | # ### end Alembic commands ### 36 | 37 | 38 | def downgrade(): 39 | # ### commands auto generated by Alembic - please adjust! ### 40 | op.drop_table("events") 41 | # ### end Alembic commands ### 42 | -------------------------------------------------------------------------------- /migrations/versions/20190314182214_chat_settings.py: -------------------------------------------------------------------------------- 1 | """chat_settings 2 | 3 | Revision ID: 20190314182214 4 | Revises: 20190314175025 5 | Create Date: 2019-03-14 18:22:14.635279 6 | 7 | """ 8 | 9 | import sqlalchemy as sa 10 | from alembic import op 11 | from sqlalchemy.ext.declarative import declarative_base 12 | from sqlalchemy.orm.session import Session 13 | 14 | # revision identifiers, used by Alembic. 15 | revision = "20190314182214" 16 | down_revision = "20190314175025" 17 | branch_labels = None 18 | depends_on = None 19 | 20 | Base = declarative_base() 21 | 22 | 23 | class Chat(Base): 24 | __tablename__ = "chats" 25 | 26 | id = sa.Column(sa.BigInteger, primary_key=True, autoincrement=False) 27 | default_currency = sa.Column(sa.Text, default="USD", nullable=False) 28 | default_currency_position = sa.Column(sa.Boolean, default=True, nullable=False) 29 | created_at = sa.Column(sa.TIMESTAMP, server_default=sa.func.now(), nullable=False) 30 | modified_at = sa.Column( 31 | sa.TIMESTAMP, 32 | server_default=sa.func.now(), 33 | onupdate=sa.func.now(), 34 | nullable=False, 35 | ) 36 | 37 | 38 | def upgrade(): 39 | # ### commands auto generated by Alembic - please adjust! ### 40 | op.add_column("chats", sa.Column("default_currency", sa.Text(), nullable=True)) 41 | op.add_column( 42 | "chats", sa.Column("default_currency_position", sa.Boolean(), nullable=True) 43 | ) 44 | # ### end Alembic commands ### 45 | 46 | session = Session(bind=op.get_bind()) 47 | session.query(Chat).update( 48 | { 49 | "default_currency": "USD", 50 | "default_currency_position": True, 51 | "modified_at": Chat.modified_at, 52 | } 53 | ) 54 | 55 | op.alter_column("chats", "default_currency", nullable=False) 56 | op.alter_column("chats", "default_currency_position", nullable=False) 57 | 58 | 59 | def downgrade(): 60 | pass 61 | -------------------------------------------------------------------------------- /migrations/versions/20190321212933_fixer_exchange.py: -------------------------------------------------------------------------------- 1 | """fixer_exchange 2 | 3 | Revision ID: 20190321212933 4 | Revises: 20190314182214 5 | Create Date: 2019-03-21 21:29:33.972277 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | from sqlalchemy.ext.declarative import declarative_base 11 | from sqlalchemy.orm.session import Session 12 | 13 | from app.exchanges import FixerExchange 14 | 15 | # revision identifiers, used by Alembic. 16 | revision = "20190321212933" 17 | down_revision = "20190314182214" 18 | branch_labels = None 19 | depends_on = None 20 | 21 | Base = declarative_base() 22 | 23 | 24 | class Exchange(Base): 25 | __tablename__ = "exchanges" 26 | 27 | id = sa.Column(sa.Integer, primary_key=True) 28 | name = sa.Column(sa.Text, nullable=False, index=True) 29 | weight = sa.Column(sa.Integer, nullable=False) 30 | is_active = sa.Column(sa.Boolean, nullable=False) 31 | 32 | 33 | def upgrade(): 34 | session = Session(bind=op.get_bind()) 35 | session.add(Exchange(name=FixerExchange.name, is_active=False, weight=10)) 36 | session.flush() 37 | 38 | 39 | def downgrade(): 40 | # ### commands auto generated by Alembic - please adjust! ### 41 | pass 42 | # ### end Alembic commands ### 43 | -------------------------------------------------------------------------------- /migrations/versions/20190324223003_convert_locales.py: -------------------------------------------------------------------------------- 1 | """convert_locales 2 | 3 | Revision ID: 20190324223003 4 | Revises: 20190321212933 5 | Create Date: 2019-03-24 22:30:03.772439 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | from sqlalchemy.ext.declarative import declarative_base 11 | from sqlalchemy.orm.session import Session 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = "20190324223003" 15 | down_revision = "20190321212933" 16 | branch_labels = None 17 | depends_on = None 18 | 19 | Base = declarative_base() 20 | 21 | 22 | class Chat(Base): 23 | __tablename__ = "chats" 24 | 25 | id = sa.Column(sa.BigInteger, primary_key=True, autoincrement=False) 26 | locale = sa.Column(sa.Text, default="en", nullable=False) 27 | created_at = sa.Column(sa.TIMESTAMP, server_default=sa.func.now(), nullable=False) 28 | modified_at = sa.Column( 29 | sa.TIMESTAMP, 30 | server_default=sa.func.now(), 31 | onupdate=sa.func.now(), 32 | nullable=False, 33 | ) 34 | 35 | 36 | def upgrade(): 37 | session = Session(bind=op.get_bind()) 38 | # I know prefer write SQL for big data, lazy :P and can migrate without downtime 39 | for x in session.query(Chat).yield_per(1000): 40 | language = x.locale.lower().replace("_", "-") 41 | if language == "zh": 42 | language = "zh-hant" 43 | if language == "zh-cn": 44 | language = "zh-hans" 45 | session.query(Chat).filter_by(id=x.id).update( 46 | {"locale": language, "modified_at": Chat.modified_at} 47 | ) 48 | 49 | session.flush() 50 | 51 | 52 | def downgrade(): 53 | # ### commands auto generated by Alembic - please adjust! ### 54 | pass 55 | # ### end Alembic commands ### 56 | -------------------------------------------------------------------------------- /migrations/versions/20190326060130_add_btt_currency.py: -------------------------------------------------------------------------------- 1 | """add_btt_currency 2 | 3 | Revision ID: 20190326060130 4 | Revises: 20190324223003 5 | Create Date: 2019-03-26 06:01:30.627169 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | from sqlalchemy.ext.declarative import declarative_base 11 | from sqlalchemy.orm.session import Session 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = "20190326060130" 15 | down_revision = "20190324223003" 16 | branch_labels = None 17 | depends_on = None 18 | 19 | Base = declarative_base() 20 | 21 | 22 | class Currency(Base): 23 | """ 24 | https://en.wikipedia.org/wiki/ISO_4217 25 | 26 | See: migrations/versions/20190306193447_currencies_chat_request_foreigns.py 27 | """ 28 | 29 | __tablename__ = "currencies" 30 | 31 | id = sa.Column(sa.Integer, primary_key=True) 32 | code = sa.Column(sa.Text, unique=True, nullable=False) 33 | name = sa.Column(sa.Text, nullable=False) 34 | is_active = sa.Column(sa.Boolean, index=True, nullable=False) 35 | is_crypto = sa.Column(sa.Boolean, index=True, nullable=False) 36 | 37 | 38 | def upgrade(): 39 | session = Session(bind=op.get_bind()) 40 | session.add(Currency(code="BTT", name="BitTorrent", is_active=True, is_crypto=True)) 41 | session.flush() 42 | 43 | 44 | def downgrade(): 45 | # ### commands auto generated by Alembic - please adjust! ### 46 | pass 47 | # ### end Alembic commands ### 48 | -------------------------------------------------------------------------------- /migrations/versions/20190326083625_add_bx_in_tx_exchange.py: -------------------------------------------------------------------------------- 1 | """add_bx_in_tx_exchange 2 | 3 | Revision ID: 20190326083625 4 | Revises: 20190326060130 5 | Create Date: 2019-03-26 08:36:25.229520 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | from sqlalchemy.ext.declarative import declarative_base 11 | from sqlalchemy.orm.session import Session 12 | 13 | 14 | class BxInThExchange: 15 | name = "[bx.in.th](https://bx.in.th/ref/s9c3HU/)" 16 | 17 | 18 | # revision identifiers, used by Alembic. 19 | revision = "20190326083625" 20 | down_revision = "20190326060130" 21 | branch_labels = None 22 | depends_on = None 23 | 24 | Base = declarative_base() 25 | 26 | 27 | class Exchange(Base): 28 | __tablename__ = "exchanges" 29 | 30 | id = sa.Column(sa.Integer, primary_key=True) 31 | name = sa.Column(sa.Text, nullable=False, index=True) 32 | weight = sa.Column(sa.Integer, nullable=False) 33 | is_active = sa.Column(sa.Boolean, nullable=False) 34 | 35 | 36 | def upgrade(): 37 | session = Session(bind=op.get_bind()) 38 | session.add(Exchange(name=BxInThExchange.name, is_active=True, weight=15)) 39 | session.flush() 40 | 41 | 42 | def downgrade(): 43 | # ### commands auto generated by Alembic - please adjust! ### 44 | pass 45 | # ### end Alembic commands ### 46 | -------------------------------------------------------------------------------- /migrations/versions/20190326131313_add_syp_exchange.py: -------------------------------------------------------------------------------- 1 | """add_syp_exchange 2 | 3 | Revision ID: 20190326131313 4 | Revises: 20190326083625 5 | Create Date: 2019-03-26 13:13:13.581001 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | from sqlalchemy.ext.declarative import declarative_base 11 | from sqlalchemy.orm.session import Session 12 | 13 | from app.exchanges import SpTodayExchange 14 | 15 | # revision identifiers, used by Alembic. 16 | revision = "20190326131313" 17 | down_revision = "20190326083625" 18 | branch_labels = None 19 | depends_on = None 20 | 21 | 22 | Base = declarative_base() 23 | 24 | 25 | class Exchange(Base): 26 | __tablename__ = "exchanges" 27 | 28 | id = sa.Column(sa.Integer, primary_key=True) 29 | name = sa.Column(sa.Text, nullable=False, index=True) 30 | weight = sa.Column(sa.Integer, nullable=False) 31 | is_active = sa.Column(sa.Boolean, nullable=False) 32 | 33 | 34 | def upgrade(): 35 | session = Session(bind=op.get_bind()) 36 | session.add(Exchange(name=SpTodayExchange.name, is_active=True, weight=15)) 37 | session.flush() 38 | 39 | 40 | def downgrade(): 41 | # ### commands auto generated by Alembic - please adjust! ### 42 | pass 43 | # ### end Alembic commands ### 44 | -------------------------------------------------------------------------------- /migrations/versions/20190328125146_notifications.py: -------------------------------------------------------------------------------- 1 | """notifications 2 | 3 | Revision ID: 20190328125146 4 | Revises: 20190326131313 5 | Create Date: 2019-03-28 12:51:46.862444 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "20190328125146" 13 | down_revision = "20190326131313" 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | op.create_table( 21 | "notifications", 22 | sa.Column("id", sa.Integer(), nullable=False), 23 | sa.Column("chat_id", sa.BigInteger(), nullable=False), 24 | sa.Column("from_currency_id", sa.Integer(), nullable=False), 25 | sa.Column("to_currency_id", sa.Integer(), nullable=False), 26 | sa.Column( 27 | "trigger_clause", 28 | sa.Enum("more", "less", "diff", "percent", name="notifytriggerclauseenum"), 29 | nullable=False, 30 | ), 31 | sa.Column("trigger_value", sa.Numeric(precision=24, scale=12), nullable=False), 32 | sa.Column("last_rate", sa.Numeric(precision=24, scale=12), nullable=False), 33 | sa.Column("is_active", sa.Boolean(), nullable=False), 34 | sa.Column( 35 | "created_at", 36 | sa.TIMESTAMP(), 37 | server_default=sa.text("now()"), 38 | nullable=False, 39 | ), 40 | sa.Column( 41 | "modified_at", 42 | sa.TIMESTAMP(), 43 | server_default=sa.text("now()"), 44 | nullable=False, 45 | ), 46 | sa.ForeignKeyConstraint(["chat_id"], ["chats.id"]), 47 | sa.ForeignKeyConstraint(["from_currency_id"], ["currencies.id"]), 48 | sa.ForeignKeyConstraint(["to_currency_id"], ["currencies.id"]), 49 | sa.PrimaryKeyConstraint("id"), 50 | sa.UniqueConstraint("chat_id", "from_currency_id", "to_currency_id"), 51 | ) 52 | op.create_index( 53 | op.f("ix_notifications_is_active"), "notifications", ["is_active"], unique=False 54 | ) 55 | # ### end Alembic commands ### 56 | 57 | 58 | def downgrade(): 59 | # ### commands auto generated by Alembic - please adjust! ### 60 | op.drop_index(op.f("ix_notifications_is_active"), table_name="notifications") 61 | op.drop_table("notifications") 62 | # ### end Alembic commands ### 63 | -------------------------------------------------------------------------------- /migrations/versions/20190402024141_chat_rm_personal_data.py: -------------------------------------------------------------------------------- 1 | """chat_rm_personal_data 2 | 3 | Revision ID: 20190402024141 4 | Revises: 20190328125146 5 | Create Date: 2019-04-02 02:41:41.759954 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "20190402024141" 13 | down_revision = "20190328125146" 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | op.drop_column("chats", "username") 21 | op.drop_column("chats", "first_name") 22 | # ### end Alembic commands ### 23 | 24 | 25 | def downgrade(): 26 | # ### commands auto generated by Alembic - please adjust! ### 27 | op.add_column( 28 | "chats", sa.Column("first_name", sa.TEXT(), autoincrement=False, nullable=True) 29 | ) 30 | op.add_column( 31 | "chats", sa.Column("username", sa.TEXT(), autoincrement=False, nullable=True) 32 | ) 33 | # ### end Alembic commands ### 34 | -------------------------------------------------------------------------------- /migrations/versions/20190402045327_settings_show_keyboard.py: -------------------------------------------------------------------------------- 1 | """settings_show_keyboard 2 | 3 | Revision ID: 20190402045327 4 | Revises: 20190402024141 5 | Create Date: 2019-04-02 04:53:27.965342 6 | 7 | """ 8 | from alembic import op 9 | 10 | # revision identifiers, used by Alembic. 11 | revision = "20190402045327" 12 | down_revision = "20190402024141" 13 | branch_labels = None 14 | depends_on = None 15 | 16 | 17 | def upgrade(): 18 | op.alter_column( 19 | "chats", "is_console_mode", nullable=False, new_column_name="is_show_keyboard" 20 | ) 21 | op.execute("UPDATE chats SET is_show_keyboard=NOT is_show_keyboard") 22 | 23 | 24 | def downgrade(): 25 | pass 26 | -------------------------------------------------------------------------------- /migrations/versions/20190404054029_keyboard_size.py: -------------------------------------------------------------------------------- 1 | """keyboard_size 2 | 3 | Revision ID: 20190404054029 4 | Revises: 20190404023229 5 | Create Date: 2019-04-04 05:40:29.527255 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | from sqlalchemy.ext.declarative import declarative_base 11 | from sqlalchemy.orm.session import Session 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = "20190404054029" 15 | down_revision = "20190402045327" 16 | branch_labels = None 17 | depends_on = None 18 | 19 | Base = declarative_base() 20 | 21 | 22 | class Chat(Base): 23 | __tablename__ = "chats" 24 | 25 | id = sa.Column(sa.BigInteger, primary_key=True, autoincrement=False) 26 | keyboard_size = sa.Column(sa.Text, default="2x3", nullable=False) 27 | created_at = sa.Column(sa.TIMESTAMP, server_default=sa.func.now(), nullable=False) 28 | modified_at = sa.Column( 29 | sa.TIMESTAMP, 30 | server_default=sa.func.now(), 31 | onupdate=sa.func.now(), 32 | nullable=False, 33 | ) 34 | 35 | 36 | def upgrade(): 37 | # ### commands auto generated by Alembic - please adjust! ### 38 | op.add_column("chats", sa.Column("keyboard_size", sa.Text(), nullable=True)) 39 | # ### end Alembic commands ### 40 | 41 | session = Session(bind=op.get_bind()) 42 | session.query(Chat).update( 43 | {"keyboard_size": "3x3", "modified_at": Chat.modified_at} 44 | ) 45 | op.alter_column("chats", "keyboard_size", nullable=False) 46 | 47 | 48 | def downgrade(): 49 | # ### commands auto generated by Alembic - please adjust! ### 50 | op.drop_column("chats", "keyboard_size") 51 | # ### end Alembic commands ### 52 | -------------------------------------------------------------------------------- /migrations/versions/20190903073346_bitkub_exchanger.py: -------------------------------------------------------------------------------- 1 | """bitkub_exchanger 2 | 3 | Revision ID: 20190903073346 4 | Revises: 20190404054029 5 | Create Date: 2019-09-03 07:33:46.874414 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | from sqlalchemy.ext.declarative import declarative_base 11 | from sqlalchemy.orm.session import Session 12 | 13 | from app.exchanges import BitkubExchange 14 | 15 | # revision identifiers, used by Alembic. 16 | revision = "20190903073346" 17 | down_revision = "20190404054029" 18 | branch_labels = None 19 | depends_on = None 20 | 21 | 22 | Base = declarative_base() 23 | 24 | 25 | class Exchange(Base): 26 | __tablename__ = "exchanges" 27 | 28 | id = sa.Column(sa.Integer, primary_key=True) 29 | name = sa.Column(sa.Text, nullable=False, index=True) 30 | weight = sa.Column(sa.Integer, nullable=False) 31 | is_active = sa.Column(sa.Boolean, nullable=False) 32 | 33 | 34 | class Rate(Base): 35 | __tablename__ = "rates" 36 | 37 | id = sa.Column(sa.Integer, primary_key=True) 38 | exchange_id = sa.Column(sa.Integer, sa.ForeignKey("exchanges.id"), nullable=False) 39 | 40 | 41 | def upgrade(): 42 | session = Session(bind=op.get_bind()) 43 | session.add(Exchange(name=BitkubExchange.name, is_active=True, weight=15)) 44 | 45 | bx_in_th_name = "[bx.in.th](https://bx.in.th/ref/s9c3HU/)" 46 | bx_in_th = session.query(Exchange).filter_by(name=bx_in_th_name).one() 47 | session.query(Rate).filter_by(exchange_id=bx_in_th.id).delete() 48 | session.delete(bx_in_th) 49 | session.flush() 50 | 51 | 52 | def downgrade(): 53 | # ### commands auto generated by Alembic - please adjust! ### 54 | pass 55 | # ### end Alembic commands ### 56 | -------------------------------------------------------------------------------- /migrations/versions/20200217055212_satang_exchanger.py: -------------------------------------------------------------------------------- 1 | """satang_exchanger 2 | 3 | Revision ID: 20200217055212 4 | Revises: 20190903073346 5 | Create Date: 2020-02-17 05:52:12.259761 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | from sqlalchemy.ext.declarative import declarative_base 11 | from sqlalchemy.orm.session import Session 12 | 13 | from app.exchanges import SatangExchange 14 | 15 | # revision identifiers, used by Alembic. 16 | revision = "20200217055212" 17 | down_revision = "20190903073346" 18 | branch_labels = None 19 | depends_on = None 20 | 21 | Base = declarative_base() 22 | 23 | 24 | class Exchange(Base): 25 | __tablename__ = "exchanges" 26 | 27 | id = sa.Column(sa.Integer, primary_key=True) 28 | name = sa.Column(sa.Text, nullable=False, index=True) 29 | weight = sa.Column(sa.Integer, nullable=False) 30 | is_active = sa.Column(sa.Boolean, nullable=False) 31 | 32 | 33 | def upgrade(): 34 | session = Session(bind=op.get_bind()) 35 | session.add(Exchange(name=SatangExchange.name, is_active=True, weight=17)) 36 | session.flush() 37 | 38 | 39 | def downgrade(): 40 | # ### commands auto generated by Alembic - please adjust! ### 41 | pass 42 | # ### end Alembic commands ### 43 | -------------------------------------------------------------------------------- /migrations/versions/20200217062024_add_xzc_currency.py: -------------------------------------------------------------------------------- 1 | """add_xzc_currency 2 | 3 | Revision ID: 20200217062024 4 | Revises: 20200217055212 5 | Create Date: 2020-02-17 06:20:24.959555 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | from sqlalchemy.ext.declarative import declarative_base 11 | from sqlalchemy.orm.session import Session 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = "20200217062024" 15 | down_revision = "20200217055212" 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | Base = declarative_base() 21 | 22 | 23 | class Currency(Base): 24 | """ 25 | https://en.wikipedia.org/wiki/ISO_4217 26 | 27 | See: migrations/versions/20190306193447_currencies_chat_request_foreigns.py 28 | """ 29 | 30 | __tablename__ = "currencies" 31 | 32 | id = sa.Column(sa.Integer, primary_key=True) 33 | code = sa.Column(sa.Text, unique=True, nullable=False) 34 | name = sa.Column(sa.Text, nullable=False) 35 | is_active = sa.Column(sa.Boolean, index=True, nullable=False) 36 | is_crypto = sa.Column(sa.Boolean, index=True, nullable=False) 37 | 38 | 39 | def upgrade(): 40 | session = Session(bind=op.get_bind()) 41 | session.add(Currency(code="XZC", name="Zcoin", is_active=True, is_crypto=True)) 42 | session.flush() 43 | 44 | 45 | def downgrade(): 46 | # ### commands auto generated by Alembic - please adjust! ### 47 | pass 48 | # ### end Alembic commands ### 49 | -------------------------------------------------------------------------------- /migrations/versions/20200329134234_referral_bittrex.py: -------------------------------------------------------------------------------- 1 | """referral_bittrex 2 | 3 | Revision ID: 20200329134234 4 | Revises: 20200217062024 5 | Create Date: 2020-03-29 13:42:34.129252 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | from sqlalchemy.ext.declarative import declarative_base 11 | from sqlalchemy.orm.session import Session 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = "20200329134234" 15 | down_revision = "20200217062024" 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | Base = declarative_base() 21 | 22 | 23 | class Exchange(Base): 24 | __tablename__ = "exchanges" 25 | 26 | id = sa.Column(sa.Integer, primary_key=True) 27 | name = sa.Column(sa.Text, nullable=False, index=True) 28 | weight = sa.Column(sa.Integer, nullable=False) 29 | is_active = sa.Column(sa.Boolean, nullable=False) 30 | 31 | 32 | def upgrade(): 33 | session = Session(bind=op.get_bind()) 34 | session.query(Exchange).filter_by(name="Bittrex").update( 35 | { 36 | "name": "[bittrex.com](https://bittrex.com/Account/Register?referralCode=YIV-CNI-13Q)" 37 | } 38 | ) 39 | session.flush() 40 | 41 | 42 | def downgrade(): 43 | # ### commands auto generated by Alembic - please adjust! ### 44 | pass 45 | # ### end Alembic commands ### 46 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "telegramexchangeratesbot" 3 | version = "1.5.4" 4 | description = "Telegram bot actual exchange rates for travel, work and daily life." 5 | authors = ["Lev Lybin "] 6 | license = "GPL-3.0" 7 | homepage = "https://telegram.me/ExchangeRatesBot" 8 | repository = "https://github.com/llybin/TelegramExchangeRatesBot" 9 | 10 | [tool.poetry.dependencies] 11 | python = "^3.9" 12 | alembic = "1.6.4" 13 | beautifulsoup4 = "4.9.3" 14 | babel = "2.9.1" 15 | cached-property = "1.5.2" 16 | celery = {version = "4.4.7", extras = ["redis"]} 17 | celery-once = "3.0.1" 18 | click = "8.0.1" 19 | "dogpile.cache" = "1.1.3" 20 | jsonschema = "3.2.0" 21 | psycopg2-binary = "2.8.6" 22 | python-telegram-bot = "13.5" 23 | ratelimit = "2.2.1" 24 | requests = {version = "2.25.1", extras = ["socks"]} 25 | sentry-sdk = "1.1.0" 26 | sqlalchemy = "1.3.24" 27 | sqlalchemy-utils = "0.37.4" 28 | "zope.sqlalchemy" = "1.4" 29 | 30 | [tool.poetry.dev-dependencies] 31 | iso4217 = "1.6.20180829" 32 | vcrpy = "^4.1.0" 33 | freezegun = "^1.1.0" 34 | coverage = "^5.2.1" 35 | 36 | [build-system] 37 | requires = ["poetry>=0.12"] 38 | build-backend = "poetry.masonry.api" 39 | 40 | [tool.isort] 41 | combine_as_imports = true 42 | default_section = "THIRDPARTY" 43 | line_length = 88 44 | use_parentheses = true 45 | include_trailing_comma = true 46 | multi_line_output = 3 47 | 48 | [tool.black] 49 | line-length = 88 50 | target-version = ['py38'] 51 | include = '\.pyi?$' 52 | exclude = ''' 53 | ( 54 | /( 55 | \.eggs 56 | | \.git 57 | | \.mypy_cache 58 | | \.pytest_cache 59 | | \.venv 60 | | __pycache__ 61 | | _build 62 | | build 63 | | dist 64 | )/ 65 | ) 66 | ''' 67 | -------------------------------------------------------------------------------- /suite/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/llybin/TelegramExchangeRatesBot/7cd5e8173915dd56d2c360595cf14f8850d6e853/suite/__init__.py -------------------------------------------------------------------------------- /suite/conf/exceptions.py: -------------------------------------------------------------------------------- 1 | class ImproperlyConfigured(Exception): 2 | """Something in settings was improperly configured""" 3 | 4 | pass 5 | -------------------------------------------------------------------------------- /suite/conf/global_settings.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/llybin/TelegramExchangeRatesBot/7cd5e8173915dd56d2c360595cf14f8850d6e853/suite/conf/global_settings.py -------------------------------------------------------------------------------- /suite/conf/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/llybin/TelegramExchangeRatesBot/7cd5e8173915dd56d2c360595cf14f8850d6e853/suite/conf/tests/__init__.py -------------------------------------------------------------------------------- /suite/conf/tests/settings/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/llybin/TelegramExchangeRatesBot/7cd5e8173915dd56d2c360595cf14f8850d6e853/suite/conf/tests/settings/__init__.py -------------------------------------------------------------------------------- /suite/conf/tests/settings/base_settings.py: -------------------------------------------------------------------------------- 1 | NAME = "Lev" 2 | -------------------------------------------------------------------------------- /suite/conf/tests/settings/testing_settings.py: -------------------------------------------------------------------------------- 1 | from .base_settings import * 2 | 3 | NAME = "Lybin" 4 | -------------------------------------------------------------------------------- /suite/conf/tests/test_settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from unittest import TestCase 4 | 5 | 6 | class SettingsTest(TestCase): 7 | def setUp(self): 8 | self.tearDown() 9 | 10 | def tearDown(self): 11 | try: 12 | del sys.modules["suite"] 13 | del sys.modules["suite.conf"] 14 | del sys.modules["suite.conf.exceptions"] 15 | del sys.modules["suite.conf.global_settings"] 16 | del sys.modules["suite.conf.tests"] 17 | del sys.modules["suite.conf.tests.base_settings"] 18 | del sys.modules["suite.conf.tests.testing_settings"] 19 | 20 | except KeyError: 21 | pass 22 | 23 | def test_config_environment(self): 24 | from suite.conf.tests.settings.base_settings import NAME 25 | from suite.conf import settings 26 | 27 | os.environ["SETTINGS_MODULE"] = "suite.conf.tests.settings.base_settings" 28 | 29 | self.assertIsNotNone(settings.NAME) 30 | self.assertEqual( 31 | os.environ.get("SETTINGS_MODULE"), "suite.conf.tests.settings.base_settings" 32 | ) 33 | self.assertEqual(settings.NAME, NAME) 34 | self.assertTrue(settings.configured) 35 | 36 | def test_config_new_environment(self): 37 | from suite.conf.tests.settings.testing_settings import NAME 38 | from suite.conf import settings 39 | 40 | os.environ["SETTINGS_MODULE"] = "suite.conf.tests.settings.testing_settings" 41 | 42 | self.assertEqual( 43 | os.environ.get("SETTINGS_MODULE"), 44 | "suite.conf.tests.settings.testing_settings", 45 | ) 46 | self.assertIsNotNone(settings.NAME) 47 | self.assertEqual(settings.NAME, NAME) 48 | self.assertTrue(settings.configured) 49 | -------------------------------------------------------------------------------- /suite/database/__init__.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import event, orm, schema 2 | from sqlalchemy.ext.declarative import declarative_base 3 | 4 | from zope.sqlalchemy import register 5 | 6 | from . import models 7 | 8 | Session = orm.scoped_session(orm.sessionmaker()) 9 | register(Session, keep_session=True) 10 | 11 | metadata = schema.MetaData() 12 | 13 | BaseObject = declarative_base(metadata=metadata) 14 | 15 | 16 | def init_sqlalchemy(engine): 17 | Session.configure(bind=engine) 18 | metadata.bind = engine 19 | 20 | 21 | def enable_sql_two_phase_commit(config, enable=True): 22 | Session.configure(twophase=enable) 23 | 24 | 25 | @event.listens_for(BaseObject, "class_instrument") 26 | def register_model(cls): 27 | setattr(models, cls.__name__, cls) 28 | 29 | 30 | @event.listens_for(BaseObject, "class_uninstrument") 31 | def unregister_model(cls): 32 | if hasattr(models, cls.__name__): 33 | delattr(models, cls.__name__) 34 | 35 | 36 | __all__ = [ 37 | "BaseObject", 38 | "Session", 39 | "metadata", 40 | "init_sqlalchemy", 41 | "enable_sql_two_phase_commit", 42 | "models", 43 | ] 44 | -------------------------------------------------------------------------------- /suite/database/models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/llybin/TelegramExchangeRatesBot/7cd5e8173915dd56d2c360595cf14f8850d6e853/suite/database/models.py -------------------------------------------------------------------------------- /suite/management/__init__.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from .commands.db import db 4 | from .commands.newsletter import newsletter 5 | from .commands.start import start 6 | from .commands.test import test 7 | 8 | 9 | @click.group() 10 | def cli(): 11 | pass 12 | 13 | 14 | cli.add_command(db) 15 | cli.add_command(newsletter) 16 | cli.add_command(start) 17 | cli.add_command(test) 18 | -------------------------------------------------------------------------------- /suite/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/llybin/TelegramExchangeRatesBot/7cd5e8173915dd56d2c360595cf14f8850d6e853/suite/management/commands/__init__.py -------------------------------------------------------------------------------- /suite/management/commands/db.py: -------------------------------------------------------------------------------- 1 | import os 2 | from functools import wraps 3 | 4 | import click 5 | from alembic import command 6 | from alembic.config import Config 7 | from alembic.util.exc import CommandError 8 | 9 | from suite.conf import settings 10 | 11 | alembic_ini_path = os.path.join(settings.BASE_DIR, "..", "alembic.ini") 12 | alembic_cfg = Config(alembic_ini_path) 13 | alembic_cfg.set_main_option("sqlalchemy.url", settings.DATABASE["url"]) 14 | 15 | 16 | @click.group(help="Subcommands to work with database") 17 | def db(): 18 | pass 19 | 20 | 21 | def catch_alembic_error(func): 22 | @wraps(func) 23 | def wrapper(*args, **kwargs): 24 | try: 25 | func(*args, **kwargs) 26 | 27 | except CommandError as e: 28 | click.echo(f"Error: {e}") 29 | 30 | else: 31 | click.echo("Done.") 32 | 33 | return wrapper 34 | 35 | 36 | def command_migrate(migration_name): 37 | command.upgrade(alembic_cfg, migration_name) 38 | 39 | 40 | @click.command( 41 | help="Updates database schema. Manages both apps with migrations and those without." 42 | ) 43 | @click.option( 44 | "--migration_name", 45 | default="head", 46 | help="Database state will be brought to the state after that migration.", 47 | ) 48 | @catch_alembic_error 49 | def migrate(migration_name): 50 | """Upgrade to a later version.""" 51 | 52 | command_migrate(migration_name) 53 | 54 | 55 | @click.command(help="Creates new migration(s) for apps.") 56 | @click.option("-m", "--message", default="auto", help="Apply a message to the revision") 57 | @click.option("--empty", is_flag=True, help="Create an empty migration.") 58 | @catch_alembic_error 59 | def makemigrations(message, empty): 60 | """Create new migration.""" 61 | 62 | # TODO: don't create empty migration when autogenerate 63 | command.revision(alembic_cfg, message=message, autogenerate=not empty) 64 | 65 | 66 | @click.command(help="Creates merge revision.") 67 | @click.argument("revisions", nargs=-1, required=True) 68 | @click.option( 69 | "-m", "--message", default="merge", help="Apply a message to the revision" 70 | ) 71 | @catch_alembic_error 72 | def merge(revisions, message): 73 | """Create merge revision.""" 74 | 75 | if len(revisions) < 2: 76 | raise CommandError("Enter two or more revisions.") 77 | 78 | command.merge(alembic_cfg, revisions, message) 79 | 80 | 81 | @click.command(help="Show current revision") 82 | @click.option("-v", "--verbose", is_flag=True) 83 | @catch_alembic_error 84 | def current(verbose): 85 | """Show current revision.""" 86 | 87 | command.current(alembic_cfg, verbose) 88 | 89 | 90 | @click.command(help="Shows all available migrations for the current project.") 91 | @click.option("-v", "--verbose", is_flag=True) 92 | @catch_alembic_error 93 | def showmigrations(verbose): 94 | """List revisions in chronological order.""" 95 | 96 | command.history(alembic_cfg, verbose=verbose) 97 | 98 | 99 | @click.command(help="Shows all latest revisions for the current project.") 100 | @click.option( 101 | "--resolve-dependencies", is_flag=True, help="Treat dependencies as down revisions." 102 | ) 103 | @click.option("-v", "--verbose", is_flag=True) 104 | @catch_alembic_error 105 | def heads(resolve_dependencies, verbose): 106 | """Show latest revisions.""" 107 | 108 | command.heads(alembic_cfg, resolve_dependencies, verbose) 109 | 110 | 111 | db.add_command(current) 112 | db.add_command(heads) 113 | db.add_command(makemigrations) 114 | db.add_command(merge) 115 | db.add_command(migrate) 116 | db.add_command(showmigrations) 117 | -------------------------------------------------------------------------------- /suite/management/commands/newsletter.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import click 4 | from sqlalchemy import create_engine 5 | 6 | from app.models import Chat 7 | from app.tasks_notifications import send_notification 8 | from suite.conf import settings 9 | from suite.database import Session, init_sqlalchemy 10 | 11 | 12 | @click.command(help="Delivery newsletter.") 13 | @click.argument("file_text") 14 | @click.option( 15 | "--for-all", is_flag=True, help="Send for all, by default only for developer." 16 | ) 17 | def newsletter(file_text, for_all): 18 | try: 19 | text = open(file_text, "r").read() 20 | except FileNotFoundError: 21 | click.echo("File not found.") 22 | return 23 | 24 | if for_all: 25 | click.echo("Will start delivery for all...") 26 | time.sleep(5) 27 | 28 | db_engine = create_engine(settings.DATABASE["url"]) 29 | init_sqlalchemy(db_engine) 30 | 31 | chats = Session.query(Chat.id).filter_by(is_subscribed=True).order_by(Chat.id) 32 | 33 | if not for_all: 34 | chats = chats.filter_by(id=settings.DEVELOPER_USER_ID) 35 | 36 | for chat in chats.yield_per(100): 37 | send_notification.delay(chat.id, text) 38 | -------------------------------------------------------------------------------- /suite/management/commands/start.py: -------------------------------------------------------------------------------- 1 | import os 2 | from importlib import import_module 3 | 4 | import click 5 | 6 | from suite.conf import ENVIRONMENT_VARIABLE 7 | 8 | 9 | @click.command(help="Starts application.") 10 | def start(): 11 | app_dir = os.environ.get(ENVIRONMENT_VARIABLE).split(".")[0] 12 | app_module = f"{app_dir}.main" 13 | m = import_module(app_module) 14 | m.main() 15 | -------------------------------------------------------------------------------- /suite/management/commands/test.py: -------------------------------------------------------------------------------- 1 | from unittest import TestLoader, TextTestRunner 2 | 3 | import click 4 | from sqlalchemy import create_engine 5 | from sqlalchemy_utils import create_database, database_exists, drop_database 6 | 7 | from suite.conf import settings 8 | from suite.database import init_sqlalchemy 9 | from suite.management.commands.db import alembic_cfg, command_migrate 10 | 11 | 12 | @click.command(help="Runs tests.") 13 | @click.argument("tests_path", required=False) 14 | def test(tests_path=None): 15 | loader = TestLoader() 16 | if tests_path: 17 | try: 18 | tests = loader.loadTestsFromName(tests_path) 19 | if not tests._tests: 20 | tests = loader.discover(tests_path, top_level_dir=".") 21 | except ModuleNotFoundError: 22 | tests = loader.discover(".") 23 | else: 24 | tests = loader.discover(".") 25 | 26 | test_runner = TextTestRunner(verbosity=2) 27 | 28 | settings.SENTRY_URL = None 29 | settings.BOT_TOKEN = None 30 | settings.DEVELOPER_BOT_TOKEN = None 31 | settings.DEVELOPER_USER_ID = None 32 | 33 | db_url, db_name = settings.DATABASE["url"].rsplit("/", 1) 34 | settings.DATABASE["url"] = f"{db_url}/test_{db_name}" 35 | 36 | db_engine = create_engine(settings.DATABASE["url"]) 37 | init_sqlalchemy(db_engine) 38 | 39 | alembic_cfg.set_main_option("sqlalchemy.url", settings.DATABASE["url"]) 40 | 41 | click.echo("Creating DB...") 42 | 43 | if database_exists(db_engine.url): 44 | drop_database(db_engine.url) 45 | 46 | create_database(db_engine.url) 47 | 48 | try: 49 | click.echo("Migrating DB...") 50 | command_migrate("head") 51 | 52 | click.echo("Running tests...") 53 | result = test_runner.run(tests) 54 | except Exception: 55 | result = None 56 | finally: 57 | click.echo("Deleting DB...") 58 | drop_database(db_engine.url) 59 | 60 | if not result or result.failures or result.errors: 61 | exit(-1) 62 | -------------------------------------------------------------------------------- /suite/test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/llybin/TelegramExchangeRatesBot/7cd5e8173915dd56d2c360595cf14f8850d6e853/suite/test/__init__.py -------------------------------------------------------------------------------- /suite/test/testcases.py: -------------------------------------------------------------------------------- 1 | """ 2 | Based on Django Test: 3 | https://github.com/django/django/blob/2.1.7/django/test/testcases.py 4 | 5 | """ 6 | 7 | import sys 8 | from unittest import TestCase 9 | 10 | from suite.test.utils import modify_settings, override_settings 11 | 12 | 13 | class SimpleTestCase(TestCase): 14 | 15 | # The class we'll use for the test client self.client. 16 | # Can be overridden in derived classes. 17 | # client_class = Client # TODO: 18 | _overridden_settings = None 19 | _modified_settings = None 20 | 21 | @classmethod 22 | def setUpClass(cls): 23 | super().setUpClass() 24 | if cls._overridden_settings: 25 | cls._cls_overridden_context = override_settings(**cls._overridden_settings) 26 | cls._cls_overridden_context.enable() 27 | if cls._modified_settings: 28 | cls._cls_modified_context = modify_settings(cls._modified_settings) 29 | cls._cls_modified_context.enable() 30 | 31 | @classmethod 32 | def tearDownClass(cls): 33 | if hasattr(cls, "_cls_modified_context"): 34 | cls._cls_modified_context.disable() 35 | delattr(cls, "_cls_modified_context") 36 | if hasattr(cls, "_cls_overridden_context"): 37 | cls._cls_overridden_context.disable() 38 | delattr(cls, "_cls_overridden_context") 39 | super().tearDownClass() 40 | 41 | def __call__(self, result=None): 42 | """ 43 | Wrapper around default __call__ method to perform common Django test 44 | set up. This means that user-defined Test Cases aren't required to 45 | include a call to super().setUp(). 46 | """ 47 | testMethod = getattr(self, self._testMethodName) 48 | skipped = getattr(self.__class__, "__unittest_skip__", False) or getattr( 49 | testMethod, "__unittest_skip__", False 50 | ) 51 | 52 | if not skipped: 53 | try: 54 | self._pre_setup() 55 | except Exception: 56 | result.addError(self, sys.exc_info()) 57 | return 58 | super().__call__(result) 59 | if not skipped: 60 | try: 61 | self._post_teardown() 62 | except Exception: 63 | result.addError(self, sys.exc_info()) 64 | return 65 | 66 | def _pre_setup(self): 67 | """ 68 | Perform pre-test setup: 69 | * Create a test client. 70 | * Clear the mail test outbox. 71 | """ 72 | # self.client = self.client_class() # TODO: 73 | # mail.outbox = [] 74 | pass 75 | 76 | def _post_teardown(self): 77 | """Perform post-test things.""" 78 | pass 79 | 80 | def settings(self, **kwargs): 81 | """ 82 | A context manager that temporarily sets a setting and reverts to the 83 | original value when exiting the context. 84 | """ 85 | return override_settings(**kwargs) 86 | 87 | def modify_settings(self, **kwargs): 88 | """ 89 | A context manager that temporarily applies changes a list setting and 90 | reverts back to the original value when exiting the context. 91 | """ 92 | return modify_settings(**kwargs) 93 | -------------------------------------------------------------------------------- /suite/test/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/llybin/TelegramExchangeRatesBot/7cd5e8173915dd56d2c360595cf14f8850d6e853/suite/test/tests/__init__.py -------------------------------------------------------------------------------- /suite/test/tests/test_db.py: -------------------------------------------------------------------------------- 1 | from suite.conf import settings 2 | from suite.test.testcases import SimpleTestCase 3 | 4 | 5 | class TestDBTest(SimpleTestCase): 6 | def test_db_test(self): 7 | db_name = settings.DATABASE["url"].rsplit("/", 1)[1] 8 | self.assertTrue(db_name.startswith("test_")) 9 | -------------------------------------------------------------------------------- /suite/test/tests/test_override_settings.py: -------------------------------------------------------------------------------- 1 | from suite.conf import settings 2 | from suite.test.testcases import SimpleTestCase 3 | from suite.test.utils import override_settings 4 | 5 | 6 | @override_settings(VAR_OVERRIDE=5555) 7 | class ConfTestTest(SimpleTestCase): 8 | def test_settings(self): 9 | self.assertEqual(settings.VAR_OVERRIDE, 5555) 10 | 11 | @override_settings(VAR_OVERRIDE=1111) 12 | def test_override_settings(self): 13 | self.assertEqual(settings.VAR_OVERRIDE, 1111) 14 | --------------------------------------------------------------------------------