commit dcf7f2b8f4cc6f5b908229f19fc81f93ddb1a945 Author: Gleb O. Ivaniczkij Date: Fri Jul 26 03:30:01 2024 +0300 Разработан чат-бот diff --git a/ai/__init__.py b/ai/__init__.py new file mode 100644 index 0000000..80dae48 --- /dev/null +++ b/ai/__init__.py @@ -0,0 +1 @@ +from .ai import update_statistics diff --git a/ai/ai.py b/ai/ai.py new file mode 100644 index 0000000..90d4be8 --- /dev/null +++ b/ai/ai.py @@ -0,0 +1,17 @@ +import database +import logging +import traceback +import time + +import config + + +def update_statistics(): + if not config.AI.enabled: + return + while True: + try: + database.update_statistics(config.AI.k) + except Exception: + logging.error('\n\t%s' % '\n\t'.join(traceback.format_exc().split('\n')).rstrip()) + time.sleep(config.AI.update_frequency) diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..34ac65b --- /dev/null +++ b/api/__init__.py @@ -0,0 +1 @@ +from .api import create_issue, read_issue diff --git a/api/api.py b/api/api.py new file mode 100644 index 0000000..6a1bb6c --- /dev/null +++ b/api/api.py @@ -0,0 +1,52 @@ +import config +from jira import JIRA, Issue +from models import date, Subcategory, TimeRange + + +jira = JIRA( + basic_auth=( + config.Jira.username, + config.Jira.token, + ), + server=config.Jira.server, +) + + +time_ranges = { + 1: ('T09:00:00.000+0300', 'T12:00:00.000+0300'), + 2: ('T12:00:00.000+0300', 'T15:00:00.000+0300'), + 3: ('T15:00:00.000+0300', 'T18:00:00.000+0300'), +} + + +async def create_issue( + subcategory: Subcategory, + _date: date, + time_range: TimeRange, + email_address: str, + phone_number: str, + comment: str, + firstname: str, +) -> Issue: + return jira.create_issue( + project='SFX', + summary='Задача от %s' % firstname, + issuetype={ + 'name': 'Telegram API', + }, + customfield_10033={ + 'value': subcategory.category.name, + 'child': { + 'value': subcategory.name, + }, + }, + customfield_10035='%s%s' % (str(_date), time_ranges[time_range.id][0]), + customfield_10036='%s%s' % (str(_date), time_ranges[time_range.id][1]), + customfield_10037=email_address, + customfield_10038=phone_number, + customfield_10039=comment, + ) + + +async def read_issue(issue_id: str): + return jira.issue(issue_id) diff --git a/bot/__init__.py b/bot/__init__.py new file mode 100644 index 0000000..e81fe17 --- /dev/null +++ b/bot/__init__.py @@ -0,0 +1 @@ +from .bot import dispatcher, storage diff --git a/bot/bot.py b/bot/bot.py new file mode 100644 index 0000000..ccdd6b3 --- /dev/null +++ b/bot/bot.py @@ -0,0 +1,115 @@ +from aiogram import Bot, Dispatcher +from aiogram.contrib.fsm_storage.memory import MemoryStorage + +import config +from fsm import FSM +import handlers +import messages + + +bot = Bot( + token=config.Bot.token, +) + +storage = MemoryStorage() + +dispatcher = Dispatcher( + bot=bot, + storage=storage, +) + +dispatcher.register_message_handler( + callback=messages.start, + commands=['start'], + state='*', +) + +dispatcher.register_message_handler( + callback=handlers.start, + state=FSM.start, +) + +dispatcher.register_message_handler( + callback=handlers.select_category, + state=FSM.select_category, +) + +dispatcher.register_message_handler( + callback=handlers.select_subcategory, + state=FSM.select_subcategory, +) + +dispatcher.register_message_handler( + callback=handlers.select_date, + state=FSM.select_date, +) + +dispatcher.register_message_handler( + callback=handlers.select_time_range, + state=FSM.select_time_range, +) + +dispatcher.register_message_handler( + callback=handlers.input_email_address, + state=FSM.input_email_address, +) + +dispatcher.register_message_handler( + callback=handlers.input_phone_number, + state=FSM.input_phone_number, +) + +dispatcher.register_message_handler( + callback=handlers.input_comment, + state=FSM.input_comment, +) + +dispatcher.register_message_handler( + callback=handlers.confirm_order, + state=FSM.confirm_order, +) + +dispatcher.register_message_handler( + callback=handlers.success_sign_up, + state=FSM.success_sign_up, +) + +dispatcher.register_message_handler( + callback=handlers.select_order, + state=FSM.select_order, +) + +dispatcher.register_message_handler( + callback=handlers.no_orders, + state=FSM.no_orders, +) + +dispatcher.register_message_handler( + callback=handlers.select_operation, + state=FSM.select_operation, +) + +dispatcher.register_message_handler( + callback=handlers.reschedule_order_select_date, + state=FSM.reschedule_order_select_date, +) + +dispatcher.register_message_handler( + callback=handlers.reschedule_order_select_time_range, + state=FSM.reschedule_order_select_time_range, +) + +dispatcher.register_message_handler( + callback=handlers.success_reschedule_order, + state=FSM.success_reschedule_order, +) + +dispatcher.register_message_handler( + callback=handlers.cancel_order, + state=FSM.cancel_order, +) + +dispatcher.register_message_handler( + callback=handlers.success_cancel_order, + state=FSM.success_cancel_order, +) diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..0a56c31 --- /dev/null +++ b/config/__init__.py @@ -0,0 +1 @@ +from .config import Bot, Database, AI, Jira diff --git a/config/config.py b/config/config.py new file mode 100644 index 0000000..2afab59 --- /dev/null +++ b/config/config.py @@ -0,0 +1,78 @@ +from configparser import RawConfigParser + + +config = RawConfigParser() +config.read( + filenames='./config.ini', +) + + +class Bot: + token = config.get( + section='Bot', + option='token', + ) + + +class Database: + dbname = config.get( + section='Database', + option='dbname', + ) + + user = config.get( + section='Database', + option='user', + ) + + password = config.get( + section='Database', + option='password', + ) + + host = config.get( + section='Database', + option='host', + fallback='localhost', + ) + + port = config.getint( + section='Database', + option='port', + fallback=5432, + ) + + +class AI: + enabled = config.getboolean( + section='AI', + option='enabled', + fallback=True, + ) + + k = config.getint( + section='AI', + option='k', + fallback=3, + ) + + update_frequency = config.getint( + section='AI', + option='update_frequency', + fallback=86400, + ) + + +class Jira: + username = config.get( + section='Jira', + option='username', + ) + token = config.get( + section='Jira', + option='token', + ) + server = config.get( + section='Jira', + option='server', + ) diff --git a/database.sql b/database.sql new file mode 100644 index 0000000..b32d8c8 --- /dev/null +++ b/database.sql @@ -0,0 +1,382 @@ +create table categories ( + id smallserial not null, + name character varying (64) not null, + description text not null, + primary key (id), + unique (name) +); + +insert into categories (name, description) values +('Прикладное программное обеспечение', '[Описание категории]'), +('Системное программное обеспечение', '[Описание категории]'), +('Системы обеспечения IT-безопасности', '[Описание категории]'), +('Специализированное программное обеспечение', '[Описание категории]'); + +create table subcategories ( + category_id smallint not null, + id smallint not null, + name character varying (64) not null, + description text not null, + foreign key (category_id) references categories on delete cascade, + primary key (category_id, id), + unique (category_id, name) +); + +create function sfx_subcategories_id_seq() returns trigger as $$ + begin + if new.id is null then + new.id := (select + coalesce(max(subcategories.id) + 1, 1) + from + subcategories + where + subcategories.category_id = new.category_id); + end if; + return new; + end; +$$ language plpgsql; + +create trigger subcategories_id_seq before insert on subcategories + for each row execute procedure sfx_subcategories_id_seq(); + +insert into subcategories (category_id, name, description) values +(1, 'Программы 1С', '[Описание подкатегории]'), +(1, 'Программы ЭОС', '[Описание подкатегории]'), +(1, 'Собственные разработки на платформе 1С', '[Описание подкатегории]'), +(2, 'Продукция Astra Linux', '[Описание подкатегории]'), +(2, 'Продукция Alt Linux (Базальт-СПО)', '[Описание подкатегории]'), +(2, 'ПО для работы с текстом', '[Описание подкатегории]'), +(2, 'МойОфис', '[Описание подкатегории]'), +(3, 'Системы обеспечения сохранности данных', '[Описание подкатегории]'), +(3, 'Антивирусное ПО', '[Описание подкатегории]'), +(3, 'Системы защиты корпоративной информации', '[Описание подкатегории]'), +(3, 'Программный комплекс «Стахановец»', '[Описание подкатегории]'), +(3, 'Решение StaffCop Enterprise', '[Описание подкатегории]'), +(4, 'Графические редакторы Movavi', '[Описание подкатегории]'), +(4, 'Сметные программы', '[Описание подкатегории]'), +(4, 'Библиотеки нормативов и стандартов', '[Описание подкатегории]'), +(4, 'САПР', '[Описание подкатегории]'), +(4, 'Решения для совместной работы TrueConf', '[Описание подкатегории]'), +(4, 'Polys - система безопасных онлайн-голосований', '[Описание подкатегории]'), +(4, 'VISOCALL IP Телекоммуникация в медицине', '[Описание подкатегории]'); + +create table executors ( + id bigserial not null, + name character varying (64) not null, + primary key (id), + unique (name) +); + +insert into executors (name) values +('Богатырёва Ю. И.'), +('Ванькова В. С.'), +('Даниленко С. В.'), +('Екатериничев А. Л.'), +('Клепиков А. К.'), +('Мартынюк Ю. М.'), +('Надеждин Е. Н.'), +('Привалов А. Н.'), +('Родионова О. В.'); + +create table executor_specialties ( + executor_id bigint not null, + category_id smallint not null, + subcategory_id smallint not null, + foreign key (executor_id) references executors on delete cascade, + foreign key (category_id, subcategory_id) references subcategories on delete cascade, + primary key (executor_id, category_id, subcategory_id) +); + +insert into executor_specialties (executor_id, category_id, subcategory_id) values +(1, 1, 1), +(2, 1, 1), +(4, 1, 1), +(5, 1, 1), +(6, 1, 1), +(7, 1, 1), +(8, 1, 1), +(9, 1, 1), +(4, 1, 2), +(5, 1, 2), +(7, 1, 2), +(8, 1, 2), +(1, 1, 3), +(2, 1, 3), +(3, 1, 3), +(5, 1, 3), +(6, 1, 3), +(7, 1, 3), +(8, 1, 3), +(4, 2, 1), +(5, 2, 1), +(6, 2, 1), +(7, 2, 1), +(8, 2, 1), +(9, 2, 1), +(2, 2, 2), +(5, 2, 2), +(6, 2, 2), +(7, 2, 2), +(8, 2, 2), +(9, 2, 2), +(2, 2, 3), +(4, 2, 3), +(8, 2, 3), +(4, 2, 4), +(5, 2, 4), +(8, 2, 4), +(9, 2, 4), +(2, 3, 1), +(3, 3, 1), +(6, 3, 1), +(7, 3, 1), +(1, 3, 2), +(4, 3, 2), +(5, 3, 2), +(6, 3, 2), +(9, 3, 2), +(1, 3, 3), +(6, 3, 3), +(9, 3, 3), +(3, 3, 4), +(8, 3, 4), +(1, 3, 5), +(3, 3, 5), +(5, 3, 5), +(6, 3, 5), +(7, 3, 5), +(9, 3, 5), +(5, 4, 1), +(6, 4, 1), +(7, 4, 2), +(9, 4, 4), +(3, 4, 6), +(4, 4, 6); + +create table time_ranges ( + id smallserial not null, + start_time time not null, + end_time time not null, + primary key (id) +); + +insert into time_ranges (start_time, end_time) values +('9:00'::time, '12:00'::time), +('12:00'::time, '15:00'::time), +('15:00'::time, '18:00'::time); + +create table orders ( + id bigserial not null, + category_id smallint not null, + subcategory_id smallint not null, + date date not null, + time_range_id smallint not null, + executor_id bigint not null, + telegram_id bigint not null, + email_address character varying (256) not null, + phone_number character varying (16) not null, + comment character varying (1024) not null, + start_time timestamp, + end_time timestamp, + foreign key (category_id, subcategory_id) references subcategories on delete cascade, + foreign key (time_range_id) references time_ranges on delete cascade, + foreign key (executor_id) references executors on delete cascade, + primary key (id) +); + +create table issues ( + id bigint not null, + key character varying (16) not null, + status character varying (16) not null, + telegram_id bigint not null, + primary key (id), + unique (key) +); + +create table statistics ( + category_id smallint not null, + subcategory_id smallint not null, + execution_time interval not null, + foreign key (category_id, subcategory_id) references subcategories on delete cascade, + primary key (category_id, subcategory_id) +); + +insert into statistics (category_id, subcategory_id, execution_time) values +(1, 1, interval '15 minutes'), +(1, 2, interval '30 minutes'), +(1, 3, interval '15 minutes'), +(2, 1, interval '1 hour'), +(2, 2, interval '2 hours'), +(2, 3, interval '1 hour'), +(2, 4, interval '2 hours'), +(3, 1, interval '15 minutes'), +(3, 2, interval '30 minutes'), +(3, 3, interval '15 minutes'), +(3, 4, interval '30 minutes'), +(3, 5, interval '15 minutes'), +(4, 1, interval '1 hour'), +(4, 2, interval '2 hours'), +(4, 3, interval '1 hour'), +(4, 4, interval '2 hours'), +(4, 5, interval '1 hour'), +(4, 6, interval '2 hours'), +(4, 7, interval '1 hour'); + +create or replace function sfx_update_statistics( + k integer +) returns void as $$ + declare subcategory subcategories; + declare _order record; + begin + for subcategory in ( + select + subcategories.category_id, + subcategories.id + from + subcategories + ) loop + for _order in ( + select + count(t1) as count, + avg(t1.end_time - t1.start_time) as avg + from + ( + select + orders.start_time, + orders.end_time + from + orders + where + orders.category_id = subcategory.category_id and + orders.subcategory_id = subcategory.id and + orders.start_time is not null and + orders.end_time is not null + order by + orders.start_time desc + limit + k + ) t1 + ) loop + if _order.count <> k then + continue; + end if; + update + statistics + set + execution_time = _order.avg + where + statistics.category_id = subcategory.category_id and + statistics.subcategory_id = subcategory.id; + end loop; + end loop; + end; +$$ language plpgsql; + +create or replace function sfx_read_free_to_order( + _category_id smallint, + _subcategory_id smallint +) returns table ( + date date, + time_range_id smallint, + executor_id bigint, + busy_interval interval +) as $$ + declare avg_interval interval := ( + select + statistics.execution_time + from + statistics + where + statistics.category_id = _category_id and + statistics.subcategory_id = _subcategory_id + ); + declare max_interval interval := ( + select + max(time_ranges.end_time - time_ranges.start_time) + from + time_ranges + ); + declare executor executors; + declare time_range time_ranges; + declare _date date := now()::date; + declare _busy_interval interval; + begin + if avg_interval > max_interval then + raise '0x00000005'; + end if; + create temporary table tmp ( + date date not null, + time_range_id smallint not null, + executor_id bigint not null, + busy_interval interval not null + ) on commit drop; + while ( + select + coalesce(count(t1.*), 0) < 6 + from + (select distinct + tmp.date + from + tmp) t1 + ) loop + for executor in ( + select + executors.id + from + executor_specialties + left join executors on + executor_specialties.executor_id = executors.id + where + executor_specialties.category_id = _category_id and + executor_specialties.subcategory_id = _subcategory_id + ) loop + for time_range in ( + select + time_ranges.id, + time_ranges.start_time, + time_ranges.end_time + from + time_ranges + ) loop + if _date = now()::date and time_range.start_time < now()::time then + continue; + end if; + _busy_interval := ( + select + coalesce(sum(statistics.execution_time), '0s'::interval) + from + orders + left join statistics on + orders.category_id = statistics.category_id and + orders.subcategory_id = statistics.subcategory_id + where + orders.date = _date and + orders.time_range_id = time_range.id and + orders.executor_id = executor.id + ); + if time_range.end_time - time_range.start_time - _busy_interval > avg_interval then + insert into tmp ( + date, + time_range_id, + executor_id, + busy_interval + ) + values ( + _date, + time_range.id, + executor.id, + _busy_interval + ); + end if; + end loop; + end loop; + _date = _date + '1 day'::interval; + end loop; + return query ( + select + tmp.* + from + tmp + ); + end; +$$ language plpgsql; diff --git a/database/__init__.py b/database/__init__.py new file mode 100644 index 0000000..b0d5efc --- /dev/null +++ b/database/__init__.py @@ -0,0 +1 @@ +from .database import read_categories, read_subcategories, read_free_to_order, create_order, read_orders, update_order, delete_order, update_statistics, create_issue, read_issues, delete_issue diff --git a/database/database.py b/database/database.py new file mode 100644 index 0000000..dd5c156 --- /dev/null +++ b/database/database.py @@ -0,0 +1,468 @@ +import config +from models import date, Category, Subcategory, Executor, TimeRange, FreeToOrder, Order, Issue +from psycopg import AsyncConnection, Connection + + +conninfo = 'dbname=%(dbname)s user=%(user)s password=%(password)s host=%(host)s port=%(port)d' % { + 'dbname': config.Database.dbname, + 'user': config.Database.user, + 'password': config.Database.password, + 'host': config.Database.host, + 'port': config.Database.port, +} + + +async def read_categories() -> tuple[Category]: + async with await AsyncConnection.connect(conninfo) as connection: + async with connection.cursor() as cursor: + sql = ''' + select distinct + categories.id, + categories.name, + categories.description + from + categories + right join executor_specialties on + categories.id = executor_specialties.category_id + order by + categories.id; + ''' + await cursor.execute(sql) + records = await cursor.fetchall() + return tuple( + Category( + id=category_id, + name=name, + description=description, + ) + for + category_id, + name, + description, + in records + ) + + +async def read_subcategories(category: Category) -> tuple[Subcategory]: + async with await AsyncConnection.connect(conninfo) as connection: + async with connection.cursor() as cursor: + sql = ''' + select distinct + categories.id, + categories.name, + categories.description, + subcategories.id, + subcategories.name, + subcategories.description + from + subcategories + left join categories on + subcategories.category_id = categories.id + right join executor_specialties on + categories.id = executor_specialties.category_id and + subcategories.id = executor_specialties.subcategory_id + where + categories.id = %(category_id)s + order by + subcategories.id; + ''' + await cursor.execute( + sql, + { + 'category_id': category.id, + }, + ) + records = await cursor.fetchall() + return tuple( + Subcategory( + category=Category( + id=category_id, + name=category_name, + description=category_description, + ), + id=subcategory_id, + name=subcategory_name, + description=subcategory_description, + ) + for + category_id, + category_name, + category_description, + subcategory_id, + subcategory_name, + subcategory_description, + in records + ) + + +async def read_free_to_order(subcategory: Subcategory) -> tuple[FreeToOrder]: + async with await AsyncConnection.connect(conninfo) as connection: + async with connection.cursor() as cursor: + sql = ''' + select + sfx_read_free_to_order.date, + time_ranges.id, + time_ranges.start_time, + time_ranges.end_time, + executors.id, + executors.name, + sfx_read_free_to_order.busy_interval + from + sfx_read_free_to_order( + %(category_id)s::smallint, + %(subcategory_id)s::smallint + ) + left join time_ranges on + sfx_read_free_to_order.time_range_id = time_ranges.id + left join executors on + sfx_read_free_to_order.executor_id = executors.id; + ''' + await cursor.execute( + sql, + { + 'category_id': subcategory.category.id, + 'subcategory_id': subcategory.id + }, + ) + records = await cursor.fetchall() + return tuple( + FreeToOrder( + date=free_to_order_date, + time_range=TimeRange( + id=time_range_id, + start_time=time_range_start_time, + end_time=time_range_end_time, + ), + executor=Executor( + id=executor_id, + name=executor_name, + ), + busy_interval=free_to_order_busy_interval, + ) + for + free_to_order_date, + time_range_id, + time_range_start_time, + time_range_end_time, + executor_id, + executor_name, + free_to_order_busy_interval, + in records + ) + + +async def create_order( + subcategory: Subcategory, + date: date, + time_range: TimeRange, + executor: Executor, + telegram_id: int, + email_address: str, + phone_number: str, + comment: str, +) -> int: + async with await AsyncConnection.connect(conninfo) as connection: + async with connection.cursor() as cursor: + sql = ''' + insert into orders ( + category_id, + subcategory_id, + date, + time_range_id, + executor_id, + telegram_id, + email_address, + phone_number, + comment + ) values ( + %(category_id)s, + %(subcategory_id)s, + %(date)s, + %(time_range_id)s, + %(executor_id)s, + %(telegram_id)s, + %(email_address)s, + %(phone_number)s, + %(comment)s + ) returning + orders.id; + ''' + await cursor.execute( + sql, + { + 'category_id': subcategory.category.id, + 'subcategory_id': subcategory.id, + 'date': date, + 'time_range_id': time_range.id, + 'executor_id': executor.id, + 'telegram_id': telegram_id, + 'email_address': email_address, + 'phone_number': phone_number, + 'comment': comment, + }, + ) + record = await cursor.fetchone() + return record[0] + + +async def read_orders( + telegram_id: int, +) -> tuple[Order]: + async with await AsyncConnection.connect(conninfo) as connection: + async with connection.cursor() as cursor: + sql = ''' + select + orders.id, + categories.id, + categories.name, + categories.description, + subcategories.id, + subcategories.name, + subcategories.description, + orders.date, + time_ranges.id, + time_ranges.start_time, + time_ranges.end_time, + executors.id, + executors.name, + orders.telegram_id, + orders.email_address, + orders.phone_number, + orders.comment, + orders.start_time, + orders.end_time + from + orders + left join categories on + orders.category_id = categories.id + left join subcategories on + orders.category_id = subcategories.category_id and + orders.subcategory_id = subcategories.id + left join time_ranges on + orders.time_range_id = time_ranges.id + left join executors on + orders.executor_id = executors.id + where + orders.telegram_id = %(telegram_id)s and + orders.start_time is null and + orders.end_time is null + order by + orders.id; + ''' + await cursor.execute( + sql, + { + 'telegram_id': telegram_id, + }, + ) + records = await cursor.fetchall() + return tuple( + Order( + id=order_id, + subcategory=Subcategory( + category=Category( + id=category_id, + name=category_name, + description=category_description, + ), + id=subcategory_id, + name=subcategory_name, + description=subcategory_description, + ), + date=order_date, + time_range=TimeRange( + id=time_range_id, + start_time=time_range_start_time, + end_time=time_range_end_time, + ), + executor=Executor( + id=executor_id, + name=executor_name, + ), + telegram_id=order_telegram_id, + email_address=order_email_address, + phone_number=order_phone_number, + comment=order_comment, + start_time=order_start_time, + end_time=order_end_time, + ) + for + order_id, + category_id, + category_name, + category_description, + subcategory_id, + subcategory_name, + subcategory_description, + order_date, + time_range_id, + time_range_start_time, + time_range_end_time, + executor_id, + executor_name, + order_telegram_id, + order_email_address, + order_phone_number, + order_comment, + order_start_time, + order_end_time, + in records + ) + + +async def update_order( + order_id: int, + date: date, + time_range: TimeRange, + executor: Executor, +): + async with await AsyncConnection.connect(conninfo) as connection: + async with connection.cursor() as cursor: + sql = ''' + update + orders + set + date = %(date)s, + time_range_id = %(time_range_id)s, + executor_id = %(executor_id)s + where + orders.id = %(order_id)s; + ''' + await cursor.execute( + sql, + { + 'order_id': order_id, + 'date': date, + 'time_range_id': time_range.id, + 'executor_id': executor.id, + }, + ) + + +async def delete_order( + order_id: int, +): + async with await AsyncConnection.connect(conninfo) as connection: + async with connection.cursor() as cursor: + sql = ''' + delete from + orders + where + orders.id = %(order_id)s; + ''' + await cursor.execute( + sql, + { + 'order_id': order_id, + }, + ) + + +def update_statistics( + k: int, +): + with Connection.connect(conninfo) as connection: + with connection.cursor() as cursor: + sql = ''' + select + sfx_update_statistics( + %(k)s + ); + ''' + cursor.execute( + sql, + { + 'k': k, + }, + ) + + +async def create_issue( + issue: Issue, +): + async with await AsyncConnection.connect(conninfo) as connection: + async with connection.cursor() as cursor: + sql = ''' + insert into issues ( + id, + key, + status, + telegram_id + ) values ( + %(issue_id)s, + %(key)s, + %(status)s, + %(telegram_id)s + ); + ''' + await cursor.execute( + sql, + { + 'issue_id': issue.id, + 'key': issue.key, + 'status': issue.status, + 'telegram_id': issue.telegram_id, + }, + ) + + +async def read_issues( + telegram_id: int, +) -> tuple[Issue]: + async with await AsyncConnection.connect(conninfo) as connection: + async with connection.cursor() as cursor: + sql = ''' + select + issues.id, + issues.key, + issues.status, + issues.telegram_id + from + issues + where + issues.telegram_id = %(telegram_id)s and + issues.status != 'done' + order by + issues.id; + ''' + await cursor.execute( + sql, + { + 'telegram_id': telegram_id, + }, + ) + records = await cursor.fetchall() + return tuple( + Issue( + id=issue_id, + key=key, + status=status, + telegram_id=telegram_id, + ) + for + issue_id, + key, + status, + telegram_id, + in + records + ) + + +async def delete_issue( + issue: Issue, +): + async with await AsyncConnection.connect(conninfo) as connection: + async with connection.cursor() as cursor: + sql = ''' + delete from + issues + where + issues.id = %(issue_id)s + ''' + await cursor.execute( + sql, + { + 'issue_id': issue.id, + }, + ) diff --git a/errors/__init__.py b/errors/__init__.py new file mode 100644 index 0000000..b159600 --- /dev/null +++ b/errors/__init__.py @@ -0,0 +1 @@ +from .errors import Error diff --git a/errors/errors.py b/errors/errors.py new file mode 100644 index 0000000..579ea37 --- /dev/null +++ b/errors/errors.py @@ -0,0 +1,20 @@ +class Error(str): + errors = { + 0x00000000: 'Неизвестная ошибка.', + 0x00000001: 'Выберете из предложенного.', + 0x00000002: 'Текст слишком длинный.', + 0x00000003: 'Введите корректный адрес электронной почты.', + 0x00000004: 'Введите корректный номер телефона.', + 0x00000005: 'К сожалению, бот не способен обрабатывать заказы этой подкатегории.\n\nСвяжитесь с нами любым удобным для вас способом:\n+7 (4872) 70-02-70 — телефон в Туле;\n8-800-775-15-40 — по России бесплатно;\nsfx@sfx-tula.ru — электронная почта.', + } + + def __new__(cls, code): + self = str.__new__(Error) + self.code = code + return self + + def __str__(self): + return 'Ошибка 0x%.8X!\n%s' % ( + self.code, + self.errors[self.code], + ) diff --git a/fsm/__init__.py b/fsm/__init__.py new file mode 100644 index 0000000..cf25615 --- /dev/null +++ b/fsm/__init__.py @@ -0,0 +1 @@ +from .fsm import FSM diff --git a/fsm/fsm.py b/fsm/fsm.py new file mode 100644 index 0000000..f417d62 --- /dev/null +++ b/fsm/fsm.py @@ -0,0 +1,22 @@ +from aiogram.dispatcher.filters.state import StatesGroup, State + + +class FSM(StatesGroup): + start = State() + select_category = State() + select_subcategory = State() + select_date = State() + select_time_range = State() + input_email_address = State() + input_phone_number = State() + input_comment = State() + confirm_order = State() + success_sign_up = State() + select_order = State() + no_orders = State() + select_operation = State() + reschedule_order_select_date = State() + reschedule_order_select_time_range = State() + success_reschedule_order = State() + cancel_order = State() + success_cancel_order = State() diff --git a/handlers/__init__.py b/handlers/__init__.py new file mode 100644 index 0000000..0e0b0cf --- /dev/null +++ b/handlers/__init__.py @@ -0,0 +1 @@ +from .handlers import start, select_category, select_subcategory, select_date, select_time_range, input_email_address, input_phone_number, input_comment, confirm_order, success_sign_up, select_order, no_orders, select_operation, reschedule_order_select_date, reschedule_order_select_time_range, success_reschedule_order, cancel_order, success_cancel_order diff --git a/handlers/handlers.py b/handlers/handlers.py new file mode 100644 index 0000000..f731821 --- /dev/null +++ b/handlers/handlers.py @@ -0,0 +1,374 @@ +import api +import database +import models +from errors import Error +import logging +import messages +from models import Message, FSMContext +import psycopg +import re + + +async def start(message: Message, state: FSMContext): + if message.text == 'Записаться': + return await messages.select_category(message, state) + if message.text == 'Мои обращения': + return await messages.select_order(message, state) + await message.answer( + text=Error(0x00000001), + parse_mode='HTML', + ) + + +async def select_category(message: Message, state: FSMContext): + if message.text == 'Назад': + return await messages.start(message, state) + try: + category = tuple( + category + for category in await database.read_categories() + if category.name == message.text + )[0] + await state.update_data({ + 'category': category, + }) + await messages.select_subcategory(message, state) + except IndexError: + await message.answer( + text=Error(0x00000001), + parse_mode='HTML', + ) + + +async def select_subcategory(message: Message, state: FSMContext): + if message.text == 'Назад': + return await messages.select_category(message, state) + try: + data = await state.get_data() + subcategory = tuple( + subcategory + for subcategory in await database.read_subcategories(data['category']) + if subcategory.name == message.text + )[0] + try: + free_to_order = await database.read_free_to_order(subcategory) + await state.update_data({ + 'subcategory': subcategory, + 'free_to_order': free_to_order, + }) + await messages.select_date(message, state) + except psycopg.errors.RaiseException as error: + if error.diag.message_primary == '0x00000005': + logging.error('\n\tКатегория: %s\n\tПодкатегория: %s\n\n\tСредняя длительность выполнения заказа указанной подкатегории (таблица statistics) дольше, чем все временные промежутки (таблица time_ranges).' % ( + subcategory.category.name, + subcategory.name, + )) + return await message.answer( + text=Error(0x00000005), + parse_mode='HTML', + ) + raise + except IndexError: + await message.answer( + text=Error(0x00000001), + parse_mode='HTML', + ) + + +async def select_date(message: Message, state: FSMContext): + if message.text == 'Назад': + return await messages.select_subcategory(message, state) + try: + data = await state.get_data() + free_to_order = tuple( + item + for item in data['free_to_order'] + if item.date.strftime('%d.%m') == message.text + )[0] + await state.update_data({ + 'date': free_to_order.date, + }) + await messages.select_time_range(message, state) + except IndexError: + await message.answer( + text=Error(0x00000001), + parse_mode='HTML', + ) + + +async def select_time_range(message: Message, state: FSMContext): + if message.text == 'Назад': + return await messages.select_date(message, state) + try: + data = await state.get_data() + free_to_order = tuple( + item + for item in data['free_to_order'] + if '%s - %s' % ( + item.time_range.start_time.strftime('%H:%M'), + item.time_range.end_time.strftime('%H:%M'), + ) == message.text + )[0] + await state.update_data({ + 'time_range': free_to_order.time_range, + }) + await messages.input_email_address(message, state) + except IndexError: + await message.answer( + text=Error(0x00000001), + parse_mode='HTML', + ) + + +async def input_email_address(message: Message, state: FSMContext): + if message.text == 'Назад': + return await messages.select_time_range(message, state) + match = re.match(models.email_address, message.text.strip()) + if match is None: + return await message.answer( + text=Error(0x00000003), + parse_mode='HTML', + ) + await state.update_data({ + 'email_address': match.string, + }) + await messages.input_phone_number(message, state) + + +async def input_phone_number(message: Message, state: FSMContext): + if message.text == 'Назад': + return await messages.input_email_address(message, state) + phone_number = re.sub(models.phone_number, '', message.text) + if len(phone_number) not in range(8, 16): + return await message.answer( + text=Error(0x00000004), + parse_mode='HTML', + ) + await state.update_data({ + 'phone_number': '+%s' % phone_number, + }) + await messages.input_comment(message, state) + + +async def input_comment(message: Message, state: FSMContext): + if message.text == 'Назад': + return await messages.input_phone_number(message, state) + comment = message.text + if len(comment) > models.max_comment_length: + return await message.answer( + text=Error(0x00000002), + parse_mode='HTML', + ) + await state.update_data({ + 'comment': comment, + }) + await messages.confirm_order(message, state) + + +async def confirm_order(message: Message, state: FSMContext): + if message.text == 'Назад': + return await messages.input_comment(message, state) + if message.text == 'Подтвердить': + data = await state.get_data() + # executor = sorted( + # tuple( + # item + # for item in data['free_to_order'] + # if item.date == data['date'] and item.time_range == data['time_range'] + # ), + # key=lambda x: x.busy_interval, + # )[0].executor + # order_id = await database.create_order( + # subcategory=data['subcategory'], + # date=data['date'], + # time_range=data['time_range'], + # executor=executor, + # telegram_id=message.from_user.id, + # email_address=data['email_address'], + # phone_number=data['phone_number'], + # comment=data['comment'], + # ) + # orders = await database.read_orders(message.from_user.id) + issue = await api.create_issue( + subcategory=data['subcategory'], + _date=data['date'], + time_range=data['time_range'], + email_address=data['email_address'], + phone_number=data['phone_number'], + comment=data['comment'], + firstname=message.from_user.first_name, + ) + await state.update_data({ + # 'executor': executor, + 'issue_key': issue.key, + }) + await database.create_issue( + issue=models.Issue( + id=issue.id, + key=issue.key, + status='new', + telegram_id=message.from_user.id, + ), + ) + return await messages.success_sign_up(message, state) + await message.answer( + text=Error(0x00000001), + parse_mode='HTML', + ) + + +async def success_sign_up(message: Message, state: FSMContext): + if message.text == 'Главное меню': + return await messages.start(message, state) + await message.answer( + text=Error(0x00000001), + parse_mode='HTML', + ) + + +async def select_order(message: Message, state: FSMContext): + if message.text == 'Назад': + return await messages.start(message, state) + try: + issue = tuple( + issue + for issue in await database.read_issues(message.from_user.id) + if issue.key == message.text + )[0] + await state.update_data({ + 'issue': issue, + }) + await messages.select_operation(message, state) + except IndexError: + await message.answer( + text=Error(0x00000001), + parse_mode='HTML', + ) + + +async def no_orders(message: Message, state: FSMContext): + if message.text == 'Главное меню': + return await messages.start(message, state) + await message.answer( + text=Error(0x00000001), + parse_mode='HTML', + ) + + +async def select_operation(message: Message, state: FSMContext): + if message.text == 'Назад': + return await messages.select_order(message, state) + if message.text == 'Перенести': + data = await state.get_data() + free_to_order = await database.read_free_to_order(data['order'].subcategory) + await state.update_data({ + 'free_to_order': free_to_order, + }) + return await messages.reschedule_order_select_date(message, state) + if message.text == 'Отменить': + return await messages.cancel_order(message, state) + await message.answer( + text=Error(0x00000001), + parse_mode='HTML', + ) + + +async def reschedule_order_select_date(message: Message, state: FSMContext): + if message.text == 'Назад': + return await messages.select_operation(message, state) + try: + data = await state.get_data() + free_to_order = tuple( + item + for item in data['free_to_order'] + if item.date.strftime('%d.%m') == message.text + )[0] + await state.update_data({ + 'date': free_to_order.date, + }) + await messages.reschedule_order_select_time_range(message, state) + except IndexError: + await message.answer( + text=Error(0x00000001), + parse_mode='HTML', + ) + + +async def reschedule_order_select_time_range(message: Message, state: FSMContext): + if message.text == 'Назад': + return await messages.reschedule_order_select_date(message, state) + try: + data = await state.get_data() + free_to_order = tuple( + item + for item in data['free_to_order'] + if '%s - %s' % ( + item.time_range.start_time.strftime('%H:%M'), + item.time_range.end_time.strftime('%H:%M'), + ) == message.text + )[0] + executor = sorted( + tuple( + item + for item in data['free_to_order'] + if item.date == data['date'] and item.time_range == free_to_order.time_range + ), + key=lambda x: x.busy_interval, + )[0].executor + await state.update_data({ + 'executor': executor, + }) + await database.update_order( + order_id=data['order'].id, + date=data['date'], + time_range=free_to_order.time_range, + executor=executor, + ) + await messages.success_reschedule_order(message, state) + except IndexError: + await message.answer( + text=Error(0x00000001), + parse_mode='HTML', + ) + + +async def success_reschedule_order(message: Message, state: FSMContext): + if message.text == 'Мои обращения': + await state.reset_data() + return await messages.select_order(message, state) + await message.answer( + text=Error(0x00000001), + parse_mode='HTML', + ) + + +async def cancel_order(message: Message, state: FSMContext): + if message.text == 'Назад': + return await messages.select_operation(message, state) + if message.text == 'Отменить заказ': + data = await state.get_data() + issue = await api.read_issue( + issue_id=data['issue'].id, + ) + issue.delete() + await database.delete_issue( + issue=issue, + ) + # await database.delete_order( + # order_id=data['issue'].id, + # ) + return await messages.success_cancel_order(message, state) + await message.answer( + text=Error(0x00000001), + parse_mode='HTML', + ) + + +async def success_cancel_order(message: Message, state: FSMContext): + if message.text == 'Мои обращения': + await state.reset_data() + return await messages.select_order(message, state) + await message.answer( + text=Error(0x00000001), + parse_mode='HTML', + ) diff --git a/keyboards/__init__.py b/keyboards/__init__.py new file mode 100644 index 0000000..f749bc9 --- /dev/null +++ b/keyboards/__init__.py @@ -0,0 +1 @@ +from .keyboards import start, categories, subcategories, dates, time_ranges, back, order_confirmation, main_menu, orders, operations, cancel_order, my_orders, issues diff --git a/keyboards/keyboards.py b/keyboards/keyboards.py new file mode 100644 index 0000000..cb75c9e --- /dev/null +++ b/keyboards/keyboards.py @@ -0,0 +1,172 @@ +import database +from models import date, ReplyKeyboardMarkup, KeyboardButton, Category, FreeToOrder, Order, Issue + + +def start(): + return ReplyKeyboardMarkup([ + [ + KeyboardButton('Записаться'), + ], + [ + KeyboardButton('Мои обращения'), + ], + ]) + + +async def categories(): + keyboard = ReplyKeyboardMarkup() + for category in await database.read_categories(): + keyboard.add( + KeyboardButton(category.name), + ) + keyboard.add( + KeyboardButton('Назад'), + ) + return keyboard + + +async def subcategories(category: Category): + keyboard = ReplyKeyboardMarkup() + for subcategory in await database.read_subcategories(category): + keyboard.add( + KeyboardButton(subcategory.name), + ) + keyboard.add( + KeyboardButton('Назад'), + ) + return keyboard + + +def dates(free_to_order: tuple[FreeToOrder]): + _dates = tuple( + sorted( + set( + item.date + for item in free_to_order + ) + ) + ) + keyboard = ReplyKeyboardMarkup() + for _date in _dates: + keyboard.insert(_date.strftime('%d.%m')) + keyboard.add( + KeyboardButton('Назад'), + ) + return keyboard + + +def time_ranges(free_to_order: tuple[FreeToOrder], _date: date): + _time_ranges = tuple( + sorted( + set( + item.time_range + for item in free_to_order + if item.date == _date + ), + key=lambda x: x.id, + ) + ) + keyboard = ReplyKeyboardMarkup() + for time_range in _time_ranges: + keyboard.insert('%s - %s' % ( + time_range.start_time.strftime('%H:%M'), + time_range.end_time.strftime('%H:%M'), + )) + keyboard.add( + KeyboardButton('Назад'), + ) + return keyboard + + +def back(): + return ReplyKeyboardMarkup( + keyboard=[ + [ + KeyboardButton('Назад'), + ], + ], + ) + + +def order_confirmation(): + return ReplyKeyboardMarkup( + keyboard=[ + [ + KeyboardButton('Подтвердить'), + ], + [ + KeyboardButton('Назад'), + ], + ], + ) + + +def main_menu(): + return ReplyKeyboardMarkup( + keyboard=[ + [ + KeyboardButton('Главное меню'), + ], + ], + ) + + +def orders(_orders: tuple[Order]): + keyboard = ReplyKeyboardMarkup() + for order in _orders: + keyboard.insert( + KeyboardButton('№%d' % order.id), + ) + keyboard.add( + KeyboardButton('Назад'), + ) + return keyboard + + +def operations(): + return ReplyKeyboardMarkup( + keyboard=[ + [ + # KeyboardButton('Перенести'), + KeyboardButton('Отменить'), + ], + [ + KeyboardButton('Назад'), + ], + ], + ) + + +def cancel_order(): + return ReplyKeyboardMarkup( + keyboard=[ + [ + KeyboardButton('Отменить заказ'), + ], + [ + KeyboardButton('Назад'), + ], + ], + ) + + +def my_orders(): + return ReplyKeyboardMarkup( + keyboard=[ + [ + KeyboardButton('Мои обращения'), + ], + ], + ) + + +def issues(_issues: tuple[Issue]): + keyboard = ReplyKeyboardMarkup() + for issue in _issues: + keyboard.insert( + KeyboardButton('%s' % issue.key), + ) + keyboard.add( + KeyboardButton('Назад'), + ) + return keyboard diff --git a/main.py b/main.py new file mode 100644 index 0000000..d7f3f3b --- /dev/null +++ b/main.py @@ -0,0 +1,26 @@ +import asyncio +from aiogram import executor +import platform +from threading import Thread + +from bot import dispatcher +import ai + + +if platform.system() == 'Windows': + asyncio.set_event_loop_policy( + asyncio.WindowsSelectorEventLoopPolicy(), + ) + +update_statistics_thread = Thread( + target=ai.update_statistics, + daemon=True, +) + + +if __name__ == '__main__': + update_statistics_thread.start() + executor.start_polling( + dispatcher, + skip_updates=True, + ) diff --git a/messages/__init__.py b/messages/__init__.py new file mode 100644 index 0000000..6f5e7dd --- /dev/null +++ b/messages/__init__.py @@ -0,0 +1 @@ +from .messages import start, select_category, select_subcategory, select_date, select_time_range, input_email_address, input_phone_number, input_comment, confirm_order, success_sign_up, select_order, select_operation, reschedule_order_select_date, reschedule_order_select_time_range, reschedule_order_select_time_range, success_reschedule_order, cancel_order, success_cancel_order diff --git a/messages/messages.py b/messages/messages.py new file mode 100644 index 0000000..ef733c8 --- /dev/null +++ b/messages/messages.py @@ -0,0 +1,210 @@ +import api +import database +from fsm import FSM +from models import iso_datetime, Message, FSMContext, InputFile +import keyboards +import re + + +async def start(message: Message, state: FSMContext): + await state.reset_state() + await state.reset_data() + await message.answer( + text='Здравствуйте, %s!\nЧем могу помочь?' % message.from_user.first_name, + reply_markup=keyboards.start(), + ) + await FSM.start.set() + + +async def select_category(message: Message, state: FSMContext): + await message.answer( + text='Выберете категорию.', + reply_markup=await keyboards.categories(), + ) + await FSM.select_category.set() + + +async def select_subcategory(message: Message, state: FSMContext): + data = await state.get_data() + await message.answer( + text='%(name)s\n\n%(description)s\n\nВыберете подкатегорию.' % dict(data['category']), + parse_mode='HTML', + reply_markup=await keyboards.subcategories(data['category']), + ) + await FSM.select_subcategory.set() + + +async def select_date(message: Message, state: FSMContext): + data = await state.get_data() + await message.answer_photo( + photo=InputFile('./static/images/%d.%d.png' % ( + data['subcategory'].category.id, + data['subcategory'].id + )), + caption='%(name)s\n\n%(description)s\n\nВыберете дату.' % dict(data['subcategory']), + parse_mode='HTML', + reply_markup=keyboards.dates(data['free_to_order']), + ) + await FSM.select_date.set() + + +async def select_time_range(message: Message, state: FSMContext): + data = await state.get_data() + await message.answer( + text='Выберете время.', + parse_mode='HTML', + reply_markup=keyboards.time_ranges(data['free_to_order'], data['date']), + ) + await FSM.select_time_range.set() + + +async def input_email_address(message: Message, state: FSMContext): + await message.answer( + text='Введите адрес электронной почты для связи.', + parse_mode='HTML', + reply_markup=keyboards.back(), + ) + await FSM.input_email_address.set() + + +async def input_phone_number(message: Message, state: FSMContext): + await message.answer( + text='Введите номер телефона для связи.', + parse_mode='HTML', + reply_markup=keyboards.back(), + ) + await FSM.input_phone_number.set() + + +async def input_comment(message: Message, state: FSMContext): + await message.answer( + text='Опишите вашу проблему.', + parse_mode='HTML', + reply_markup=keyboards.back(), + ) + await FSM.input_comment.set() + + +async def confirm_order(message: Message, state: FSMContext): + data = await state.get_data() + await message.answer( + text='Категория: %s - %s.\nДата: %s.\nВремя: %s.\nАдрес электронной почты: %s.\nНомер телефона: %s.\nОписание проблемы:\n%s' % ( + data['category'].name, + data['subcategory'].name, + data['date'].strftime('%d.%m.%Y'), + '%s - %s' % ( + data['time_range'].start_time.strftime('%H:%M'), + data['time_range'].end_time.strftime('%H:%M'), + ), + data['email_address'], + data['phone_number'], + data['comment'], + ), + parse_mode='HTML', + reply_markup=keyboards.order_confirmation(), + ) + await FSM.confirm_order.set() + + +async def success_sign_up(message: Message, state: FSMContext): + data = await state.get_data() + await message.answer( + text='Обращение %s успешно оформлено!\nВ указанное время с вами свяжется наш специалист.\nСпасибо за обращение!' % data['issue_key'], + parse_mode='HTML', + reply_markup=keyboards.main_menu(), + ) + await FSM.success_sign_up.set() + + +async def select_order(message: Message, state: FSMContext): + issues = await database.read_issues(message.from_user.id) + if len(issues) > 0: + await message.answer( + text='Выберете обращение.', + reply_markup=keyboards.issues(issues), + ) + return await FSM.select_order.set() + await message.answer( + text='У вас нет активных обращений.', + reply_markup=keyboards.main_menu(), + ) + await FSM.no_orders.set() + + +async def select_operation(message: Message, state: FSMContext): + data = await state.get_data() + issue = await api.read_issue(data['issue'].id) + datetime_start = re.match(iso_datetime, issue.fields.customfield_10035) + datetime_end = re.match(iso_datetime, issue.fields.customfield_10036) + await message.answer( + text='Категория: %s.\nДата: %s.\nВремя: %s.\nАдрес электронной почты: %s.\nНомер телефона: %s.\nОписание проблемы:\n%s' % ( + issue.fields.customfield_10033, + '%s.%s.%s' % ( + datetime_start[3], + datetime_start[2], + datetime_start[1], + ), + '%s:%s - %s:%s' % ( + datetime_start[4], + datetime_start[5], + datetime_end[4], + datetime_end[5], + ), + issue.fields.customfield_10037, + issue.fields.customfield_10038, + issue.fields.customfield_10039, + ), + parse_mode='HTML', + reply_markup=keyboards.operations(), + ) + await FSM.select_operation.set() + + +async def reschedule_order_select_date(message: Message, state: FSMContext): + data = await state.get_data() + await message.answer( + text='Выберете дату.', + parse_mode='HTML', + reply_markup=keyboards.dates(data['free_to_order']), + ) + await FSM.reschedule_order_select_date.set() + + +async def reschedule_order_select_time_range(message: Message, state: FSMContext): + data = await state.get_data() + await message.answer( + text='Выберете время.', + parse_mode='HTML', + reply_markup=keyboards.time_ranges(data['free_to_order'], data['date']), + ) + await FSM.reschedule_order_select_time_range.set() + + +async def success_reschedule_order(message: Message, state: FSMContext): + data = await state.get_data() + await message.answer( + text='Обращение %s успешно изменено!\nВ указанное время с вами свяжется наш специалист.\nСпасибо за обращение!' % data['order'].id, + parse_mode='HTML', + reply_markup=keyboards.my_orders(), + ) + await FSM.success_reschedule_order.set() + + +async def cancel_order(message: Message, state: FSMContext): + data = await state.get_data() + await message.answer( + text='Отменить обращение %s?' % data['issue'].key, + parse_mode='HTML', + reply_markup=keyboards.cancel_order(), + ) + await FSM.cancel_order.set() + + +async def success_cancel_order(message: Message, state: FSMContext): + data = await state.get_data() + await message.answer( + text='Обращение %s успешно отменено!' % data['issue'].key, + parse_mode='HTML', + reply_markup=keyboards.my_orders(), + ) + await FSM.success_cancel_order.set() diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..eb312b8 --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,6 @@ +from aiogram.types import Message +from aiogram.types.reply_keyboard import KeyboardButton +from aiogram.dispatcher import FSMContext +from aiogram.types.input_file import InputFile + +from .models import date, email_address, phone_number, iso_datetime, max_comment_length, ReplyKeyboardMarkup, Category, Subcategory, Executor, TimeRange, FreeToOrder, Order, Issue diff --git a/models/models.py b/models/models.py new file mode 100644 index 0000000..1c9817e --- /dev/null +++ b/models/models.py @@ -0,0 +1,79 @@ +from aiogram.types.reply_keyboard import ReplyKeyboardMarkup as _ReplyKeyboardMarkup +from datetime import date, time, datetime, timedelta +from pydantic import BaseModel +import re +from typing import Literal + + +email_address = re.compile(r'(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)') +phone_number = re.compile(r'\D*') +iso_datetime = re.compile(r'(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2}).(\d{0,6})([+-]?\d{0,2}:?\d{0,2})') +max_comment_length = 1024 + + +class ReplyKeyboardMarkup(_ReplyKeyboardMarkup): + def __init__(self, keyboard=None): + super().__init__( + keyboard=keyboard, + resize_keyboard=True, + ) + + +class Category(BaseModel): + id: int + name: str + description: str + + +class Subcategory(BaseModel): + category: Category + id: int + name: str + description: str + + +class Executor(BaseModel): + id: int + name: str + + +class TimeRange(BaseModel): + id: int + start_time: time + end_time: time + + def __hash__(self): + return self.id + + +class FreeToOrder(BaseModel): + date: date + time_range: TimeRange + executor: Executor + busy_interval: timedelta + + +class Order(BaseModel): + id: int + subcategory: Subcategory + date: date + time_range: TimeRange + executor: Executor + telegram_id: int + email_address: str + phone_number: str + comment: str + start_time: datetime = None + end_time: datetime = None + + +class Issue(BaseModel): + id: int + key: str + status: Literal[ + 'undefined', + 'new', + 'indeterminate', + 'done', + ] + telegram_id: int diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8169339 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +aiogram +jira +psycopg[binary] +pydantic diff --git a/static/images/1.1.png b/static/images/1.1.png new file mode 100644 index 0000000..1dad89a Binary files /dev/null and b/static/images/1.1.png differ diff --git a/static/images/1.2.png b/static/images/1.2.png new file mode 100644 index 0000000..e117b2d Binary files /dev/null and b/static/images/1.2.png differ diff --git a/static/images/1.3.png b/static/images/1.3.png new file mode 100644 index 0000000..2832d92 Binary files /dev/null and b/static/images/1.3.png differ diff --git a/static/images/2.1.png b/static/images/2.1.png new file mode 100644 index 0000000..ba52099 Binary files /dev/null and b/static/images/2.1.png differ diff --git a/static/images/2.2.png b/static/images/2.2.png new file mode 100644 index 0000000..eeb9129 Binary files /dev/null and b/static/images/2.2.png differ diff --git a/static/images/2.3.png b/static/images/2.3.png new file mode 100644 index 0000000..e6ef405 Binary files /dev/null and b/static/images/2.3.png differ diff --git a/static/images/2.4.png b/static/images/2.4.png new file mode 100644 index 0000000..50e70e4 Binary files /dev/null and b/static/images/2.4.png differ diff --git a/static/images/3.1.png b/static/images/3.1.png new file mode 100644 index 0000000..92a713f Binary files /dev/null and b/static/images/3.1.png differ diff --git a/static/images/3.2.png b/static/images/3.2.png new file mode 100644 index 0000000..8d10cf2 Binary files /dev/null and b/static/images/3.2.png differ diff --git a/static/images/3.3.png b/static/images/3.3.png new file mode 100644 index 0000000..0d2fa55 Binary files /dev/null and b/static/images/3.3.png differ diff --git a/static/images/3.4.png b/static/images/3.4.png new file mode 100644 index 0000000..8e5689c Binary files /dev/null and b/static/images/3.4.png differ diff --git a/static/images/3.5.png b/static/images/3.5.png new file mode 100644 index 0000000..84d6232 Binary files /dev/null and b/static/images/3.5.png differ diff --git a/static/images/4.1.png b/static/images/4.1.png new file mode 100644 index 0000000..ad09288 Binary files /dev/null and b/static/images/4.1.png differ diff --git a/static/images/4.2.png b/static/images/4.2.png new file mode 100644 index 0000000..d96e2e1 Binary files /dev/null and b/static/images/4.2.png differ diff --git a/static/images/4.3.png b/static/images/4.3.png new file mode 100644 index 0000000..0413351 Binary files /dev/null and b/static/images/4.3.png differ diff --git a/static/images/4.4.png b/static/images/4.4.png new file mode 100644 index 0000000..3a2fc35 Binary files /dev/null and b/static/images/4.4.png differ diff --git a/static/images/4.5.png b/static/images/4.5.png new file mode 100644 index 0000000..cd0305a Binary files /dev/null and b/static/images/4.5.png differ diff --git a/static/images/4.6.png b/static/images/4.6.png new file mode 100644 index 0000000..d43f6fb Binary files /dev/null and b/static/images/4.6.png differ diff --git a/static/images/4.7.png b/static/images/4.7.png new file mode 100644 index 0000000..c12a9f0 Binary files /dev/null and b/static/images/4.7.png differ