elemento-customers 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- elemento_customers-1.0.0/MANIFEST.in +4 -0
- elemento_customers-1.0.0/PKG-INFO +12 -0
- elemento_customers-1.0.0/README.md +52 -0
- elemento_customers-1.0.0/app/__init__.py +5 -0
- elemento_customers-1.0.0/app/client.py +181 -0
- elemento_customers-1.0.0/app/types.py +67 -0
- elemento_customers-1.0.0/elemento_customers.egg-info/SOURCES.txt +6 -0
- elemento_customers-1.0.0/pyproject.toml +73 -0
- elemento_customers-1.0.0/setup.cfg +4 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: elemento-customers
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Customer service
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: kaiju-tools<3,>=2.4.9
|
|
7
|
+
Requires-Dist: kaiju-models<2,>=1.0.4
|
|
8
|
+
Provides-Extra: server
|
|
9
|
+
Requires-Dist: kaiju-db<3,>=2.3; extra == "server"
|
|
10
|
+
Requires-Dist: kaiju-redis<3,>=2.4; extra == "server"
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: kaiju-tools[dev]; extra == "dev"
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Elemento-Customers
|
|
2
|
+
|
|
3
|
+
Используется для хранения и обработки данных клиентов: их основных атрибутов, параметров анкеты, контактной информации,
|
|
4
|
+
базовой статистики и т.д. Описание доступных методов смотри в `docs/api.http`.
|
|
5
|
+
|
|
6
|
+
Конкретный набор хранимых данных зависит от настроек анкеты.
|
|
7
|
+
|
|
8
|
+
# Запуск сервера
|
|
9
|
+
|
|
10
|
+
Стандартный как для всех Kaiju приложений.
|
|
11
|
+
|
|
12
|
+
Сервер клиентов требует подключения к `postgres` серверу бд для хранения таблицы клиентов и
|
|
13
|
+
структурированных атрибутов анкеты.
|
|
14
|
+
|
|
15
|
+
Также сервер требует `redis` с установленным `redis-search` модулем (только при `search_enabled=true`) для поисковых
|
|
16
|
+
запросов по данным анкет.
|
|
17
|
+
|
|
18
|
+
# Описание важных настроек
|
|
19
|
+
|
|
20
|
+
- `services_db_host` : str - IP или адрес postgres сервера базы данных
|
|
21
|
+
- `services_db_port`: int - порт postgres сервера базы данных
|
|
22
|
+
- `services_db_database` : str - имя базы данных
|
|
23
|
+
- `services_db_user` : str - имя пользователя postgres (должен обладать правами создавать таблицы)
|
|
24
|
+
- `services_db_password` : str - пароль для учетной записи пользователя postgres
|
|
25
|
+
- `search_enabled` : bool - включить или выключить поисковые сервисы в redis
|
|
26
|
+
- `services_redis_host` : str - IP или адрес сервера redis
|
|
27
|
+
- `services_redis_port` : int - порт сервера redis
|
|
28
|
+
- `services_redis_db` : int - номер базы данных в redis
|
|
29
|
+
- `services_redis_password` : str - пароль для сервера redis
|
|
30
|
+
- `services_redis_cluster`: bool - использовать клиент для кластера redis
|
|
31
|
+
|
|
32
|
+
# Поддержка
|
|
33
|
+
|
|
34
|
+
- Периодический вызов `Customers.search.idx.migrate` для перестройки поискового индекса и избежания расхождений.
|
|
35
|
+
- Периодический вызов `brin_summarize_new_values(idx_customers_id)` в Postgres для оптимизации BRIN индекса.
|
|
36
|
+
|
|
37
|
+
# Установка для разработки
|
|
38
|
+
|
|
39
|
+
1. Клонировать проект из git
|
|
40
|
+
2. Зайти в директорию проекта и запустить скрипт разработки: `sh setup-dev.sh`
|
|
41
|
+
3. ...
|
|
42
|
+
4. Проверить запуск с помощью `python -m app`
|
|
43
|
+
5. Для запуска в локальной среде рекомендуется установить docker-compose использовать `docker-compose.yaml`.
|
|
44
|
+
|
|
45
|
+
# Краткое описание работы
|
|
46
|
+
|
|
47
|
+
1. Сервер хранит структурированный набор анкетных атрибутов клиентов из которых создает модель для валидации входных
|
|
48
|
+
данных.
|
|
49
|
+
2. При создании клиента ему присваивается автоинкрементный `id` и создается учетная запись в таблице `customers`. Помимо
|
|
50
|
+
присвоения `id` телефон также является уникальным обязательным атрибутом анкеты и проверяется по индексу БД при создании
|
|
51
|
+
учетной записи.
|
|
52
|
+
3. При включенном redis данные по анкете также копируются в поисковый индекс. Ошибка при копировании будет проигнорирована.
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""RPC client services for customer app."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Literal
|
|
4
|
+
|
|
5
|
+
from kaiju_tools.http import RPCClientService
|
|
6
|
+
from kaiju_tools.services import SERVICE_CLASS_REGISTRY
|
|
7
|
+
|
|
8
|
+
from .types import Customer, CustomerCreate, CustomerId, CustomerUpdate, SearchFilter
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ElementoCustomersClient(RPCClientService):
|
|
12
|
+
"""Auto-generated ElementoCustomers RPC client.
|
|
13
|
+
|
|
14
|
+
Configuration example:
|
|
15
|
+
|
|
16
|
+
.. code-block:: yaml
|
|
17
|
+
|
|
18
|
+
- cls: HTTPService
|
|
19
|
+
name: elemento_customers_conn
|
|
20
|
+
settings:
|
|
21
|
+
host: http://0.0.0.0:10001
|
|
22
|
+
- cls: ElementoCustomersClient
|
|
23
|
+
settings:
|
|
24
|
+
transport: elemento_customers_conn
|
|
25
|
+
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
async def create(
|
|
29
|
+
self, customer: CustomerCreate.Fields, _max_timeout: int = None, _nowait: bool = False
|
|
30
|
+
) -> Customer.Fields:
|
|
31
|
+
"""Call Customers.create."""
|
|
32
|
+
result = await self.call(
|
|
33
|
+
method="Customers.create", params=dict(customer=customer), max_timeout=_max_timeout, nowait=_nowait
|
|
34
|
+
)
|
|
35
|
+
return Customer.get_struct(result)
|
|
36
|
+
|
|
37
|
+
async def get(
|
|
38
|
+
self, id: CustomerId | int, _max_timeout: int = None, _nowait: bool = False
|
|
39
|
+
) -> Customer.Fields | None:
|
|
40
|
+
"""Get customer data by id or return `None` if not exists."""
|
|
41
|
+
result = await self.call(method="Customers.get", params=dict(id=id), max_timeout=_max_timeout, nowait=_nowait)
|
|
42
|
+
if result:
|
|
43
|
+
result = Customer.get_struct(result)
|
|
44
|
+
return result
|
|
45
|
+
|
|
46
|
+
async def get_by_phone(self, phone: str, _max_timeout: int = None, _nowait: bool = False) -> Customer.Fields | None:
|
|
47
|
+
"""Find a customer by phone number or return `None` if not exists."""
|
|
48
|
+
result = await self.call(
|
|
49
|
+
method="Customers.get_by_phone", params=dict(phone=phone), max_timeout=_max_timeout, nowait=_nowait
|
|
50
|
+
)
|
|
51
|
+
if result:
|
|
52
|
+
result = Customer.get_struct(result)
|
|
53
|
+
return result
|
|
54
|
+
|
|
55
|
+
async def find(
|
|
56
|
+
self,
|
|
57
|
+
*,
|
|
58
|
+
query: str = "",
|
|
59
|
+
filters: SearchFilter = None,
|
|
60
|
+
offset: int = 0,
|
|
61
|
+
limit: int = 100,
|
|
62
|
+
sort_key: str = None,
|
|
63
|
+
sort_order: Literal["asc", "desc"] = None,
|
|
64
|
+
_max_timeout: int = None,
|
|
65
|
+
_nowait: bool = False,
|
|
66
|
+
) -> list[Customer.Fields]:
|
|
67
|
+
"""Find and list customers.
|
|
68
|
+
|
|
69
|
+
:param query: query string
|
|
70
|
+
:param filters: list of search conditions
|
|
71
|
+
:param offset: pagination offset
|
|
72
|
+
:param limit: pagination limit
|
|
73
|
+
:param sort_key: sorting key
|
|
74
|
+
:param sort_order: sorting order
|
|
75
|
+
:param _max_timeout: max request timeout in sec (None == server default)
|
|
76
|
+
:param _nowait: do not wait for the response (equivalent to id: null in JSONRPC)
|
|
77
|
+
"""
|
|
78
|
+
result = await self.call(
|
|
79
|
+
method="Customers.find",
|
|
80
|
+
params=dict(
|
|
81
|
+
query=query, filters=filters, offset=offset, limit=limit, sort_key=sort_key, sort_order=sort_order
|
|
82
|
+
),
|
|
83
|
+
max_timeout=_max_timeout,
|
|
84
|
+
nowait=_nowait,
|
|
85
|
+
)
|
|
86
|
+
return [Customer.get_struct(row) for row in result]
|
|
87
|
+
|
|
88
|
+
async def block(self, id: CustomerId | int, _max_timeout: int = None, _nowait: bool = False) -> None:
|
|
89
|
+
"""Set customer status to `blocked`."""
|
|
90
|
+
return await self.call(method="Customers.block", params=dict(id=id), max_timeout=_max_timeout, nowait=_nowait)
|
|
91
|
+
|
|
92
|
+
async def unblock(self, id: CustomerId | int, _max_timeout: int = None, _nowait: bool = False) -> None:
|
|
93
|
+
"""Revert customer `blocked` status."""
|
|
94
|
+
return await self.call(method="Customers.unblock", params=dict(id=id), max_timeout=_max_timeout, nowait=_nowait)
|
|
95
|
+
|
|
96
|
+
async def delete(self, id: CustomerId | int, _max_timeout: int = None, _nowait: bool = False) -> None:
|
|
97
|
+
"""Delete customer completely and all its data."""
|
|
98
|
+
return await self.call(method="Customers.delete", params=dict(id=id), max_timeout=_max_timeout, nowait=_nowait)
|
|
99
|
+
|
|
100
|
+
async def update(
|
|
101
|
+
self,
|
|
102
|
+
id: CustomerId | int,
|
|
103
|
+
customer: CustomerUpdate.Fields | dict[str, Any],
|
|
104
|
+
_max_timeout: int = None,
|
|
105
|
+
_nowait: bool = False,
|
|
106
|
+
) -> Customer.Fields:
|
|
107
|
+
"""Update certain customer profile data or other parameters."""
|
|
108
|
+
result = await self.call(
|
|
109
|
+
method="Customers.update", params=dict(id=id, customer=customer), max_timeout=_max_timeout, nowait=_nowait
|
|
110
|
+
)
|
|
111
|
+
return Customer.get_struct(result)
|
|
112
|
+
|
|
113
|
+
async def get_settings(self, _max_timeout: int = None, _nowait: bool = False) -> dict[str, Any]:
|
|
114
|
+
"""Get current service shared settings.
|
|
115
|
+
|
|
116
|
+
:param _max_timeout: max request timeout in sec (None == server default)
|
|
117
|
+
:param _nowait: do not wait for the response (equivalent to id: null in JSONRPC)
|
|
118
|
+
"""
|
|
119
|
+
return await self.call(method="Customers.settings.get", params=dict(), max_timeout=_max_timeout, nowait=_nowait)
|
|
120
|
+
|
|
121
|
+
async def set_settings(
|
|
122
|
+
self, value: dict[str, Any], _max_timeout: int = None, _nowait: bool = False
|
|
123
|
+
) -> dict[str, Any]:
|
|
124
|
+
"""Create or update service shared settings.
|
|
125
|
+
|
|
126
|
+
:param value: new settings (you can pass only updated keys here)
|
|
127
|
+
:param _max_timeout: max request timeout in sec (None == server default)
|
|
128
|
+
:param _nowait: do not wait for the response (equivalent to id: null in JSONRPC)
|
|
129
|
+
"""
|
|
130
|
+
return await self.call(
|
|
131
|
+
method="Customers.settings.set", params=dict(value=value), max_timeout=_max_timeout, nowait=_nowait
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
async def reset_settings(self, _max_timeout: int = None, _nowait: bool = False) -> dict[str, Any]:
|
|
135
|
+
"""Reset service settings to the initial values.
|
|
136
|
+
|
|
137
|
+
:param _max_timeout: max request timeout in sec (None == server default)
|
|
138
|
+
:param _nowait: do not wait for the response (equivalent to id: null in JSONRPC)
|
|
139
|
+
"""
|
|
140
|
+
return await self.call(
|
|
141
|
+
method="Customers.settings.reset", params=dict(), max_timeout=_max_timeout, nowait=_nowait
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
async def init_search_index(
|
|
145
|
+
self, load_docs: bool = True, _max_timeout: int = None, _nowait: bool = False
|
|
146
|
+
) -> int | None:
|
|
147
|
+
"""Init search index if not exists.
|
|
148
|
+
|
|
149
|
+
:returns: a number of loaded search documents or `None` if index already exists.
|
|
150
|
+
"""
|
|
151
|
+
return await self.call(
|
|
152
|
+
method="Customers.search.idx.init",
|
|
153
|
+
params=dict(load_docs=load_docs),
|
|
154
|
+
max_timeout=_max_timeout,
|
|
155
|
+
nowait=_nowait,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
async def flush_search_index(self, _max_timeout: int = None, _nowait: bool = False) -> None:
|
|
159
|
+
"""Remove the search index and all related search documents."""
|
|
160
|
+
return await self.call(
|
|
161
|
+
method="Customers.search.idx.flush", params=dict(), max_timeout=_max_timeout, nowait=_nowait
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
async def migrate_search_index(
|
|
165
|
+
self, load_docs: bool = False, _max_timeout: int = None, _nowait: bool = False
|
|
166
|
+
) -> int:
|
|
167
|
+
"""Migrate to a new index.
|
|
168
|
+
|
|
169
|
+
Use this when you have modified the customer profile schema and need to update the search according to that.
|
|
170
|
+
|
|
171
|
+
:returns: a number of loaded search documents
|
|
172
|
+
"""
|
|
173
|
+
return await self.call(
|
|
174
|
+
method="Customers.search.idx.migrate",
|
|
175
|
+
params=dict(load_docs=load_docs),
|
|
176
|
+
max_timeout=_max_timeout,
|
|
177
|
+
nowait=_nowait,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
SERVICE_CLASS_REGISTRY.register(ElementoCustomersClient)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Basic data types."""
|
|
2
|
+
|
|
3
|
+
from datetime import date
|
|
4
|
+
from typing import Any, NewType, TypedDict
|
|
5
|
+
|
|
6
|
+
from kaiju_models import BooleanField, DateField, EmailField, IntegerField, JsonMapField, Model, StringField
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
__all__ = ["CustomerId", "Customer", "CustomerUpdate", "CustomerCreate", "SearchFilter"]
|
|
10
|
+
|
|
11
|
+
CustomerId = NewType("CustomerId", int)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SearchFilter(TypedDict):
|
|
15
|
+
"""UI search filter data."""
|
|
16
|
+
|
|
17
|
+
id: str
|
|
18
|
+
kind: str
|
|
19
|
+
condition: str
|
|
20
|
+
value: Any
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CustomerUpdate(Model["CustomerUpdate.Fields"]):
|
|
24
|
+
"""Data to update customer."""
|
|
25
|
+
|
|
26
|
+
class Fields(Model.Fields):
|
|
27
|
+
blocked: bool = BooleanField()
|
|
28
|
+
email: str | None = EmailField()
|
|
29
|
+
email_confirmed: bool = BooleanField()
|
|
30
|
+
profile: dict[str, Any] = JsonMapField() # profile data is dynamic and determined by settings
|
|
31
|
+
meta: dict[str, Any] = JsonMapField() # meta is dynamic and determined by settings
|
|
32
|
+
subscriptions: dict[str, Any] = JsonMapField() # subs data is dynamic and determined by settings
|
|
33
|
+
loyalty_enabled: bool = BooleanField()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CustomerCreate(Model["CustomerCreate.Fields"]):
|
|
37
|
+
"""Data to create a new customer."""
|
|
38
|
+
|
|
39
|
+
class Fields(Model.Fields):
|
|
40
|
+
phone: str = StringField(pattern="7[0-9]{10}", required=True)
|
|
41
|
+
source_id: str | None = StringField()
|
|
42
|
+
email: str | None = EmailField()
|
|
43
|
+
email_confirmed: bool = BooleanField(default=False)
|
|
44
|
+
profile: dict[str, Any] = JsonMapField() # profile data is dynamic and determined by settings
|
|
45
|
+
meta: dict[str, Any] = JsonMapField() # meta is dynamic and determined by settings
|
|
46
|
+
subscriptions: dict[str, Any] = JsonMapField() # subs data is dynamic and determined by settings
|
|
47
|
+
loyalty_enabled: bool = BooleanField(default=False)
|
|
48
|
+
accept_tender_offer: bool = BooleanField(default=True)
|
|
49
|
+
created: date = DateField() # TODO: remove after loading the initial db
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Customer(Model["Customer.Fields"]):
|
|
53
|
+
"""Customer data"""
|
|
54
|
+
|
|
55
|
+
class Fields(Model.Fields):
|
|
56
|
+
id: CustomerId = IntegerField()
|
|
57
|
+
phone: str = StringField()
|
|
58
|
+
source_id: str | None = StringField()
|
|
59
|
+
email: str | None = EmailField()
|
|
60
|
+
email_confirmed: bool = BooleanField(default=False)
|
|
61
|
+
blocked: bool = BooleanField(default=False)
|
|
62
|
+
profile: dict[str, Any] = JsonMapField() # profile data is dynamic and determined by settings
|
|
63
|
+
meta: dict[str, Any] = JsonMapField() # meta is dynamic and determined by settings
|
|
64
|
+
subscriptions: dict[str, Any] = JsonMapField() # subs data is dynamic and determined by settings
|
|
65
|
+
loyalty_enabled: bool = BooleanField(default=False)
|
|
66
|
+
created: date = DateField()
|
|
67
|
+
accept_tender_offer: bool = BooleanField(default=True)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "elemento-customers"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Customer service"
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"kaiju-tools>=2.4.9,<3",
|
|
8
|
+
"kaiju-models>=1.0.4,<2"
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
[project.optional-dependencies]
|
|
12
|
+
server = [
|
|
13
|
+
"kaiju-db>=2.3,<3",
|
|
14
|
+
"kaiju-redis>=2.4,<3",
|
|
15
|
+
]
|
|
16
|
+
dev = [
|
|
17
|
+
"kaiju-tools [dev]"
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
# --- BUILD CONFIGURATION ---
|
|
21
|
+
|
|
22
|
+
[build-system]
|
|
23
|
+
requires = ["setuptools"]
|
|
24
|
+
build-backend = "setuptools.build_meta"
|
|
25
|
+
|
|
26
|
+
[tool.setuptools]
|
|
27
|
+
packages = ["elemento_customers"]
|
|
28
|
+
py-modules = []
|
|
29
|
+
package-dir={elemento_customers = 'app'}
|
|
30
|
+
|
|
31
|
+
# --- TOOL CONFIGURATION ---
|
|
32
|
+
|
|
33
|
+
[tool.bandit]
|
|
34
|
+
skips = ["B101"] # , "B105", "B110", "B310", "B311", "B404", "B601", "B602", "B603", "B608", "B701"
|
|
35
|
+
|
|
36
|
+
[tool.black]
|
|
37
|
+
line-length = 120
|
|
38
|
+
target-version = ['py311']
|
|
39
|
+
|
|
40
|
+
[tool.isort]
|
|
41
|
+
profile = "black"
|
|
42
|
+
multi_line_output = 3
|
|
43
|
+
lines_after_imports = 2
|
|
44
|
+
skip_gitignore = true
|
|
45
|
+
balanced_wrapping = true
|
|
46
|
+
line_length = 120
|
|
47
|
+
wrap_length = 120
|
|
48
|
+
known_first_party = ["app"]
|
|
49
|
+
src_paths = ["app"]
|
|
50
|
+
skip = ['conftest.py', '__init__.py', '__main__.py', 'setup.py']
|
|
51
|
+
skip_glob = ['docs/*', 'tests/*']
|
|
52
|
+
|
|
53
|
+
[tool.pylint]
|
|
54
|
+
load-plugins = [
|
|
55
|
+
'pylint.extensions.check_elif',
|
|
56
|
+
'pylint.extensions.docstyle',
|
|
57
|
+
'pylint.extensions.dunder',
|
|
58
|
+
'pylint.extensions.eq_without_hash',
|
|
59
|
+
'pylint.extensions.mccabe',
|
|
60
|
+
'pylint.extensions.overlapping_exceptions',
|
|
61
|
+
'pylint.extensions.private_import'
|
|
62
|
+
]
|
|
63
|
+
disable = ['C0114', 'C0116', 'C0115', 'R0902', 'R0903', 'R0913', 'R1735', 'W0622', 'W0611', 'W0707', 'R0917', 'R1260', 'W0613', 'R0912']
|
|
64
|
+
max-line-length = 120
|
|
65
|
+
|
|
66
|
+
[tool.pytest.ini_options]
|
|
67
|
+
log_cli = true
|
|
68
|
+
log_level = "DEBUG"
|
|
69
|
+
log_format = '"%(name)s %(levelname)s %(message)s"'
|
|
70
|
+
log_cli_level = "DEBUG"
|
|
71
|
+
max_args = 10
|
|
72
|
+
addopts = "--doctest-modules --doctest-continue-on-failure --ignore=docs/source/conf.py --ignore=conftest.py --ignore=setup.py"
|
|
73
|
+
markers = ['docker', 'benchmark']
|