diff --git a/ansible/roles/deploy_bot_container/defaults/main/docker.yml b/ansible/roles/deploy_bot_container/defaults/main/docker.yml index 7c93d55..0bf5c47 100644 --- a/ansible/roles/deploy_bot_container/defaults/main/docker.yml +++ b/ansible/roles/deploy_bot_container/defaults/main/docker.yml @@ -1,7 +1,9 @@ --- docker_lib_folder: "/var/lib/docker" docker_volumes: - - "volumes/meow-bot/sources" - - "volumes/meow-bot/postgres" + sources: + path: "volumes/meow-bot/sources" + postgres: + path: "volumes/meow-bot/postgres" docker_network: "active" ... diff --git a/ansible/roles/deploy_bot_container/tasks/docker.yml b/ansible/roles/deploy_bot_container/tasks/docker.yml index 61414b4..e82afe5 100644 --- a/ansible/roles/deploy_bot_container/tasks/docker.yml +++ b/ansible/roles/deploy_bot_container/tasks/docker.yml @@ -4,6 +4,16 @@ register: timezone_output changed_when: false +- name: Get existing of a docker network + docker_network_info: + name: "{{ docker_network }}" + register: docker_network_result + +- name: Add a separate docker network + docker_network: + name: "{{ docker_network }}" + when: not docker_network_result.exists + - name: Create temporary build directory tempfile: state: directory @@ -19,13 +29,13 @@ - name: Create volume directory for a container file: - path: "{{ docker_lib_folder }}/{{ item }}" + path: "{{ docker_lib_folder }}/{{ item.value.path }}" recurse: yes state: directory owner: root group: root become: yes - with_items: "{{ docker_volumes }}" + with_dict: "{{ docker_volumes }}" - name: Copy req.txt for building a docker image copy: @@ -34,7 +44,7 @@ - name: Copy sources synchronize: - dest: "{{ docker_lib_folder }}/volumes/meow-bot/sources" + dest: "{{ docker_lib_folder }}/{{ docker_volumes['sources']['path'] }}" src: "../../../../sources/" owner: false group: false @@ -59,7 +69,7 @@ restart_policy: always restart: yes mounts: - - source: "{{ docker_lib_folder }}/volumes/meow-bot/postgres" + - source: "{{ docker_lib_folder }}/{{ docker_volumes['postgres']['path'] }}" target: "/var/lib/postgresql/data" type: bind networks: @@ -77,8 +87,13 @@ state: started restart_policy: always restart: yes + entrypoint: | + bash -c + "venv/bin/python3 sources/scripts/create_db.py + venv/bin/alembic -c sources/alembic.ini upgrade head && + venv/bin/python3 sources/scripts/main.py" mounts: - - source: "{{ docker_lib_folder }}/volumes/meow-bot/sources" + - source: "{{ docker_lib_folder }}/{{ docker_volumes['sources']['path'] }}" target: "/code/sources" read_only: yes type: bind diff --git a/ansible/roles/deploy_bot_container/templates/Dockerfile.j2 b/ansible/roles/deploy_bot_container/templates/Dockerfile.j2 index 1939233..62e43aa 100644 --- a/ansible/roles/deploy_bot_container/templates/Dockerfile.j2 +++ b/ansible/roles/deploy_bot_container/templates/Dockerfile.j2 @@ -9,4 +9,3 @@ WORKDIR /code COPY prod.txt sources/prod.txt RUN python3.9 -m venv --symlinks venv RUN source venv/bin/activate && python3.9 -m pip install pip -U && pip install -r sources/prod.txt -ENTRYPOINT ["venv/bin/python3", "sources/main.py"] diff --git a/requirements/prod.txt b/requirements/prod.txt index be2c44e..ad71e7c 100644 --- a/requirements/prod.txt +++ b/requirements/prod.txt @@ -1,3 +1,4 @@ +alembic==1.13.1 asyncpg==0.29.0 discord==2.3.2 git+https://github.com/soksanichenko/dateparser.git@master diff --git a/sources/alembic.ini b/sources/alembic.ini new file mode 100644 index 0000000..c1773fb --- /dev/null +++ b/sources/alembic.ini @@ -0,0 +1,116 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = sources/lib/db/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to sources/lib/db/alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:sources/lib/db/alembic/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# It's not used, because env.py is modified +# for using internal config & db_engine objects +# sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/sources/lib/commands/get_timestamp.py b/sources/lib/commands/get_timestamp.py index d68e023..c303aa6 100644 --- a/sources/lib/commands/get_timestamp.py +++ b/sources/lib/commands/get_timestamp.py @@ -43,39 +43,6 @@ def parse_and_validate( ) -class TimezoneView(discord.ui.View): - """ - View class for a user's timezone - """ - - def __init__(self, date: str = '', time: str = ''): - super().__init__() - self.time = time - self.date = date - - @discord.ui.select( - placeholder='Select timezone', - min_values=1, - max_values=1, - options=[ - discord.SelectOption(label=timezone, description='') - for timezone in pytz.all_timezones - ], - ) - async def select_callback( - self, - interaction: discord.Interaction, # pylint: disable=W0613 - select: discord.ui.Select, - ): - """ - Callback for selecting a current user's timezone - :param interaction: an object of interaction with a user - :param select: a select option - :return: None - """ - return select.values[0] - - class TimestampFormatView(discord.ui.View): """ View class for timestamp formatting diff --git a/sources/lib/commands/utils.py b/sources/lib/commands/utils.py new file mode 100644 index 0000000..4ef0e53 --- /dev/null +++ b/sources/lib/commands/utils.py @@ -0,0 +1,19 @@ +""" +Utils for the bot commands +""" + +import discord + + +async def get_command( + commands_tree: discord.app_commands.CommandTree, + command_name: str, +) -> discord.app_commands.Command: + """ + Get a command of the bot + :param commands_tree: A tree of commands + :param command_name: A name of command + :return: object of Command + """ + commands = await commands_tree.fetch_commands() + return next(iter(filter(lambda c: c.name == command_name, commands))) diff --git a/sources/lib/db/__init__.py b/sources/lib/db/__init__.py index d7c9eb5..2c92815 100644 --- a/sources/lib/db/__init__.py +++ b/sources/lib/db/__init__.py @@ -6,13 +6,8 @@ async_sessionmaker, async_scoped_session, ) -from sqlalchemy_utils import ( - database_exists, - create_database, -) from sources.config import config -from sources.lib.db.models import Base async_engine = create_async_engine(url=config.async_db_url) @@ -24,12 +19,3 @@ session_factory=async_session_factory, scopefunc=current_task, ) - - -async def create_db_if_not_exists(): - """Creates database if it doesn't exist""" - async with AsyncSession() as db_session: - if not database_exists(config.sync_db_url): - create_database(config.sync_db_url) - async with db_session.bind.begin() as conn: - await conn.run_sync(Base.metadata.create_all) diff --git a/sources/lib/db/alembic/README b/sources/lib/db/alembic/README new file mode 100644 index 0000000..a23d4fb --- /dev/null +++ b/sources/lib/db/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration with an async dbapi. diff --git a/sources/lib/db/alembic/env.py b/sources/lib/db/alembic/env.py new file mode 100644 index 0000000..64769f0 --- /dev/null +++ b/sources/lib/db/alembic/env.py @@ -0,0 +1,97 @@ +""" +Alembic env file +""" + +import asyncio +from logging.config import fileConfig + +from sqlalchemy.engine import Connection + +from alembic import context + +from sources.config import config as general_config +from sources.lib.db import async_engine +from sources.lib.db.models import Base + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config # pylint: disable=E1101 + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = general_config.async_db_url + context.configure( # pylint: disable=E1101 + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): # pylint: disable=E1101 + context.run_migrations() # pylint: disable=E1101 + + +def do_run_migrations(connection: Connection) -> None: + """ + Run migrations + :param connection: Connection object + :return: None + """ + context.configure( # pylint: disable=E1101 + connection=connection, + target_metadata=target_metadata, + ) + + with context.begin_transaction(): # pylint: disable=E1101 + context.run_migrations() # pylint: disable=E1101 + + +async def run_async_migrations() -> None: + """In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + async with async_engine.connect() as connection: + await connection.run_sync(do_run_migrations) + + await async_engine.dispose() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode.""" + + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): # pylint: disable=E1101 + run_migrations_offline() +else: + run_migrations_online() diff --git a/sources/lib/db/alembic/script.py.mako b/sources/lib/db/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/sources/lib/db/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/sources/lib/db/models.py b/sources/lib/db/models.py index a25a6c9..14d13de 100644 --- a/sources/lib/db/models.py +++ b/sources/lib/db/models.py @@ -3,6 +3,7 @@ from sqlalchemy import Text, BigInteger from sqlalchemy.orm import declarative_base, Mapped, mapped_column + Base = declarative_base() @@ -16,3 +17,14 @@ class Guild(Base): id: Mapped[int] = mapped_column(BigInteger, primary_key=True) name: Mapped[str] = mapped_column(Text) + + +class User(Base): + """ + A tables describes of the discord users + """ + + __tablename__ = "users" + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + name: Mapped[str] = mapped_column(Text) + timezone: Mapped[str] = mapped_column(Text) diff --git a/sources/lib/db/operations/__init__.py b/sources/lib/db/operations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sources/lib/db/operations/guilds.py b/sources/lib/db/operations/guilds.py new file mode 100644 index 0000000..8aa5e31 --- /dev/null +++ b/sources/lib/db/operations/guilds.py @@ -0,0 +1,28 @@ +""" +Operations with DB table `guilds` +""" + +import discord +from sqlalchemy import select + +from sources.lib.db import AsyncSession +from sources.lib.db.models import Guild + + +async def add_guild(discord_guild: discord.Guild): + """Add a guild to a database""" + async with AsyncSession() as db_session: + async with db_session.begin(): + guild = select(Guild).where( + Guild.id == discord_guild.id, + ) + guild = (await db_session.scalars(guild)).one_or_none() + if guild is None: + guild = Guild( + name=discord_guild.name, + id=discord_guild.id, + ) + db_session.add(guild) + elif guild.name != discord_guild.name: + guild.name = discord_guild.name + db_session.add(guild) diff --git a/sources/lib/db/operations/users.py b/sources/lib/db/operations/users.py new file mode 100644 index 0000000..1246ab5 --- /dev/null +++ b/sources/lib/db/operations/users.py @@ -0,0 +1,57 @@ +""" +Operations with DB table `users` +""" + +import typing + +import discord +from sqlalchemy import select + +from sources.lib.db import AsyncSession +from sources.lib.db.models import User + + +async def get_user( + db_session: AsyncSession, + user_id: int, +) -> typing.Optional[User]: + """Get user by ID""" + user = select(User).where(User.id == user_id) + return (await db_session.scalars(user)).one_or_none() + + +async def add_user( + discord_user: discord.User, + user_timezone: str, +): + """Add a user to a database""" + async with AsyncSession() as db_session: + async with db_session.begin(): + user = await get_user( + db_session=db_session, + user_id=discord_user.id, + ) + if user is None: + user = User( + id=discord_user.id, + name=discord_user.name, + timezone=user_timezone, + ) + db_session.add(user) + elif user.name != discord_user.name: + user.name = discord_user.name + user.timezone = user_timezone + db_session.add(user) + + +async def get_user_timezone( + discord_user: discord.User, +) -> typing.Optional[str]: + """Get a user timezone""" + async with AsyncSession() as db_session: + user = await get_user( + db_session=db_session, + user_id=discord_user.id, + ) + if user is not None: + return user.timezone diff --git a/sources/lib/db/utils.py b/sources/lib/db/utils.py index 3d7c6ac..c23d85c 100644 --- a/sources/lib/db/utils.py +++ b/sources/lib/db/utils.py @@ -1,23 +1,23 @@ """DB utilities""" -import discord -from sqlalchemy import select - +from sqlalchemy_utils import ( + database_exists, + create_database, +) +from sources.config import config from sources.lib.db import AsyncSession -from sources.lib.db.models import Guild +from sources.lib.db.models import Base +from sources.lib.utils import Logger -async def add_guild(discord_guild: discord.Guild): - """Add a guild to a database""" +async def create_db_if_not_exists(): + """ + Create DB and its initial objects if they don't exist + :return: + """ + Logger().info('Create DB if not exists') async with AsyncSession() as db_session: - async with db_session.begin(): - guild = select(Guild).where( - Guild.id == discord_guild.id, - ) - guild = (await db_session.scalars(guild)).one_or_none() - if guild is None: - guild = Guild( - name=discord_guild.name, - id=discord_guild.id, - ) - db_session.add(guild) + if not database_exists(config.sync_db_url): + create_database(config.sync_db_url) + async with db_session.bind.begin() as conn: + await conn.run_sync(Base.metadata.create_all) diff --git a/sources/scripts/__init__.py b/sources/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sources/scripts/create_db.py b/sources/scripts/create_db.py new file mode 100644 index 0000000..70f8983 --- /dev/null +++ b/sources/scripts/create_db.py @@ -0,0 +1,18 @@ +""" +Pre-run script for creating a DB and its initial objects +""" + +import asyncio +from discord import utils + +from sources.lib.db.utils import create_db_if_not_exists + + +async def main(): + """Main run function""" + utils.setup_logging() + await create_db_if_not_exists() + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/sources/main.py b/sources/scripts/main.py similarity index 73% rename from sources/main.py rename to sources/scripts/main.py index 7537cac..2ffd260 100644 --- a/sources/main.py +++ b/sources/scripts/main.py @@ -7,7 +7,6 @@ import discord from discord import utils from discord.ext.commands import Bot -from discord.utils import MISSING from sources.config import config from sources.lib.commands.get_timestamp import ( @@ -15,9 +14,10 @@ autocomplete_timezone, ) from sources.lib.commands.get_timestamp import TimestampFormatView +from sources.lib.commands.utils import get_command from sources.lib.core import BotAvatar -from sources.lib.db import create_db_if_not_exists -from sources.lib.db.utils import add_guild +from sources.lib.db.operations.guilds import add_guild +from sources.lib.db.operations.users import add_user, get_user_timezone from sources.lib.on_message.domains_fixer import fix_urls from sources.lib.utils import Logger @@ -40,31 +40,66 @@ async def ping(interaction: discord.Interaction): await interaction.response.send_message('pong', ephemeral=True) +@bot.tree.command( + name='set-timezone', + description='Set a current timezone of user', +) +@discord.app_commands.autocomplete(timezone=autocomplete_timezone) +async def set_timezone( + interaction: discord.Interaction, + timezone: str, +): + """ + Set a current timezone of user + :param interaction: the command's interaction + :param timezone: a current timezone of user + :return: None + """ + user = interaction.user + await add_user( + discord_user=user, + user_timezone=timezone, + ) + await interaction.response.send_message( + f'Timezone for user **{user.display_name}** is set to **{timezone}**', + ephemeral=True, + ) + + @discord.app_commands.describe( time='Please input a time in any suitable format in your region' ) @discord.app_commands.describe( date='Please input a date in any suitable format in your region' ) -@discord.app_commands.autocomplete(timezone=autocomplete_timezone) @bot.tree.command( name='get-timestamp', description='Get formatted timestamp for any date and/or time', ) async def get_timestamp( interaction: discord.Interaction, - timezone: str, time: str = '', date: str = '', ): """ Send any text by the bot - :param timezone: a current user's timezone :param time: an input time for converting :param date: an input date for converting :param interaction: the command's interaction :return: None """ + user = interaction.user + timezone = await get_user_timezone( + discord_user=user, + ) + command_name = 'set-timezone' + command = await get_command(commands_tree=bot.tree, command_name=command_name) + if timezone is None: + await interaction.response.send_message( + f'User **{user.display_name}** does not have a timezone.\n' + f'Please, use command to set it' + ) + return time_date = parse_and_validate( timezone=timezone, date=date, @@ -124,14 +159,7 @@ async def start_bot_staff(): async def main(): """Main run function""" - utils.setup_logging( - handler=MISSING, - formatter=MISSING, - level=MISSING, - root=False, - ) - Logger().info('Create DB if not exists') - await create_db_if_not_exists() + utils.setup_logging() await bot.start( token=config.discord_token, reconnect=True,