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.
@@ -0,0 +1,4 @@
1
+ prune *
2
+ include app/__init__.py
3
+ include app/client.py
4
+ include app/types.py
@@ -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,5 @@
1
+ """Project information."""
2
+
3
+ __author__ = "Anton Taraskin"
4
+ __email__ = "hel.nidhoggr@gmail.com"
5
+ __version__ = "1.0.0"
@@ -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,6 @@
1
+ MANIFEST.in
2
+ README.md
3
+ pyproject.toml
4
+ app/__init__.py
5
+ app/client.py
6
+ app/types.py
@@ -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']
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+