Разработан чат-бот

This commit is contained in:
2024-07-26 03:30:01 +03:00
commit dcf7f2b8f4
44 changed files with 2035 additions and 0 deletions

1
ai/__init__.py Normal file
View File

@ -0,0 +1 @@
from .ai import update_statistics

17
ai/ai.py Normal file
View File

@ -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)

1
api/__init__.py Normal file
View File

@ -0,0 +1 @@
from .api import create_issue, read_issue

52
api/api.py Normal file
View File

@ -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)

1
bot/__init__.py Normal file
View File

@ -0,0 +1 @@
from .bot import dispatcher, storage

115
bot/bot.py Normal file
View File

@ -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,
)

1
config/__init__.py Normal file
View File

@ -0,0 +1 @@
from .config import Bot, Database, AI, Jira

78
config/config.py Normal file
View File

@ -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',
)

382
database.sql Normal file
View File

@ -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;

1
database/__init__.py Normal file
View File

@ -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

468
database/database.py Normal file
View File

@ -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,
},
)

1
errors/__init__.py Normal file
View File

@ -0,0 +1 @@
from .errors import Error

20
errors/errors.py Normal file
View File

@ -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 '<b>Ошибка 0x%.8X!</b>\n%s' % (
self.code,
self.errors[self.code],
)

1
fsm/__init__.py Normal file
View File

@ -0,0 +1 @@
from .fsm import FSM

22
fsm/fsm.py Normal file
View File

@ -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()

1
handlers/__init__.py Normal file
View File

@ -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

374
handlers/handlers.py Normal file
View File

@ -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',
)

1
keyboards/__init__.py Normal file
View File

@ -0,0 +1 @@
from .keyboards import start, categories, subcategories, dates, time_ranges, back, order_confirmation, main_menu, orders, operations, cancel_order, my_orders, issues

172
keyboards/keyboards.py Normal file
View File

@ -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

26
main.py Normal file
View File

@ -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,
)

1
messages/__init__.py Normal file
View File

@ -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

210
messages/messages.py Normal file
View File

@ -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='<b>%(name)s</b>\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='<b>%(name)s</b>\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='<b>Категория:</b> %s - %s.\n<b>Дата:</b> %s.\n<b>Время:</b> %s.\n<b>Адрес электронной почты:</b> %s.\n<b>Номер телефона:</b> %s.\n<b>Описание проблемы:</b>\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='<b>Обращение %s успешно оформлено!</b>\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='<b>Категория:</b> %s.\n<b>Дата:</b> %s.\n<b>Время:</b> %s.\n<b>Адрес электронной почты:</b> %s.\n<b>Номер телефона:</b> %s.\n<b>Описание проблемы:</b>\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='<b>Обращение %s успешно изменено!</b>\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()

6
models/__init__.py Normal file
View File

@ -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

79
models/models.py Normal file
View File

@ -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

4
requirements.txt Normal file
View File

@ -0,0 +1,4 @@
aiogram
jira
psycopg[binary]
pydantic

BIN
static/images/1.1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

BIN
static/images/1.2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

BIN
static/images/1.3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

BIN
static/images/2.1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

BIN
static/images/2.2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

BIN
static/images/2.3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

BIN
static/images/2.4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

BIN
static/images/3.1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

BIN
static/images/3.2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

BIN
static/images/3.3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

BIN
static/images/3.4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
static/images/3.5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

BIN
static/images/4.1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
static/images/4.2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
static/images/4.3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

BIN
static/images/4.4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

BIN
static/images/4.5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

BIN
static/images/4.6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

BIN
static/images/4.7.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB