lesscode-flask 0.0.34__tar.gz → 0.0.38__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.
Files changed (53) hide show
  1. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/PKG-INFO +2 -7
  2. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/README.md +1 -6
  3. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/lesscode_flask/__init__.py +3 -3
  4. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/lesscode_flask/app.py +6 -4
  5. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/lesscode_flask/model/auth_client.py +3 -3
  6. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/lesscode_flask/model/base_model.py +23 -1
  7. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/lesscode_flask/model/user.py +5 -3
  8. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/lesscode_flask/service/authentication_service.py +9 -2
  9. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/lesscode_flask/service/base_service.py +22 -19
  10. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/lesscode_flask/utils/helpers.py +17 -16
  11. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/lesscode_flask/utils/request/request.py +2 -0
  12. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/lesscode_flask.egg-info/PKG-INFO +2 -7
  13. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/lesscode_flask/db/__init__.py +0 -0
  14. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/lesscode_flask/db/datasource.py +0 -0
  15. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/lesscode_flask/db/executor.py +0 -0
  16. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/lesscode_flask/log/access_log_handler.py +0 -0
  17. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/lesscode_flask/model/access_log.py +0 -0
  18. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/lesscode_flask/model/auth_permission.py +0 -0
  19. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/lesscode_flask/model/parameterized_query.py +0 -0
  20. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/lesscode_flask/model/response_result.py +0 -0
  21. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/lesscode_flask/service/access_log_service.py +0 -0
  22. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/lesscode_flask/service/auth_client_service.py +0 -0
  23. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/lesscode_flask/service/auth_permission_service.py +0 -0
  24. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/lesscode_flask/setting/__init__.py +0 -0
  25. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/lesscode_flask/setup/__init__.py +0 -0
  26. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/lesscode_flask/utils/__init__.py +0 -0
  27. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/lesscode_flask/utils/decorator/__init__.py +0 -0
  28. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/lesscode_flask/utils/decorator/cache.py +0 -0
  29. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/lesscode_flask/utils/decorator/swagger.py +0 -0
  30. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/lesscode_flask/utils/file/file_exporter.py +0 -0
  31. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/lesscode_flask/utils/json/NotSortJSONProvider.py +0 -0
  32. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/lesscode_flask/utils/oss/__init__.py +0 -0
  33. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/lesscode_flask/utils/oss/ks3_oss.py +0 -0
  34. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/lesscode_flask/utils/redis/redis_helper.py +0 -0
  35. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/lesscode_flask/utils/swagger/swagger_template.py +0 -0
  36. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/lesscode_flask/utils/swagger/swagger_util.py +0 -0
  37. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/lesscode_flask/wsgi.py +0 -0
  38. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/lesscode_flask.egg-info/SOURCES.txt +0 -0
  39. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/lesscode_flask.egg-info/dependency_links.txt +0 -0
  40. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/lesscode_flask.egg-info/requires.txt +0 -0
  41. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/lesscode_flask.egg-info/top_level.txt +0 -0
  42. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/redash/query_runner/__init__.py +0 -0
  43. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/redash/query_runner/clickhouse.py +0 -0
  44. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/redash/query_runner/elasticsearch.py +0 -0
  45. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/redash/query_runner/kingbase.py +0 -0
  46. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/redash/query_runner/mysql.py +0 -0
  47. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/redash/query_runner/pg.py +0 -0
  48. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/redash/settings/__init__.py +0 -0
  49. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/redash/settings/helpers.py +0 -0
  50. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/redash/utils/__init__.py +0 -0
  51. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/redash/utils/requests_session.py +0 -0
  52. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/setup.cfg +0 -0
  53. {lesscode_flask-0.0.34 → lesscode_flask-0.0.38}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: lesscode-flask
3
- Version: 0.0.34
3
+ Version: 0.0.38
4
4
  Summary: lesscode-flask 是基于flask的web开发脚手架项目,该项目初衷为简化开发过程,让研发人员更加关注业务。
5
5
  Home-page: https://lesscode-flask
6
6
  Author: Chao.yy
@@ -39,12 +39,7 @@ Already a pro? Just edit this README.md and make it your own. Want to make it ea
39
39
  - [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files
40
40
  - [ ] [Add files using the command line](https://docs.gitlab.com/ee/gitlab-basics/add-file.html#add-a-file-using-the-command-line) or push an existing Git repository with the following command:
41
41
 
42
- ```
43
- cd existing_repo
44
- git remote add origin http://gitlab.chanyeos.com/backend/lesscode-flask.git
45
- git branch -M main
46
- git push -uf origin main
47
- ```
42
+
48
43
 
49
44
  ## Integrate with your tools
50
45
 
@@ -13,12 +13,7 @@ Already a pro? Just edit this README.md and make it your own. Want to make it ea
13
13
  - [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files
14
14
  - [ ] [Add files using the command line](https://docs.gitlab.com/ee/gitlab-basics/add-file.html#add-a-file-using-the-command-line) or push an existing Git repository with the following command:
15
15
 
16
- ```
17
- cd existing_repo
18
- git remote add origin http://gitlab.chanyeos.com/backend/lesscode-flask.git
19
- git branch -M main
20
- git push -uf origin main
21
- ```
16
+
22
17
 
23
18
  ## Integrate with your tools
24
19
 
@@ -1,4 +1,4 @@
1
- __version__ = '0.0.34'
1
+ __version__ = '0.0.38'
2
2
 
3
3
  import functools
4
4
  import logging
@@ -36,12 +36,12 @@ class SQ_Blueprint(Blueprint):
36
36
  data = func(*args, **kwargs)
37
37
  return data
38
38
 
39
+ wrapper._title = title
40
+ wrapper._request_type = content_type
39
41
  # 添加 URL 规则到 Flask 路由
40
42
  self.add_url_rule(path, None, wrapper, **options)
41
43
  return wrapper
42
44
 
43
- decorator._title = title
44
- decorator._request_type = content_type
45
45
  return decorator
46
46
 
47
47
  def post_route(self, title: str, url: str = None, cache_enalbe: bool = False, cache_ex: int = 3600 * 10,
@@ -10,6 +10,7 @@ from flask import Flask, typing as ft, abort, Response
10
10
  import typing as t
11
11
  from flask.globals import request_ctx, request
12
12
  from flask_login import current_user
13
+ from lesscode_utils.json_utils import JSONEncoder
13
14
  from werkzeug.middleware.proxy_fix import ProxyFix
14
15
  from lesscode_flask.utils.helpers import inject_args, generate_uuid, app_config
15
16
  from lesscode_flask.model.response_result import ResponseResult
@@ -68,7 +69,6 @@ class Lesscoder(Flask):
68
69
  return super(Lesscoder, self).preprocess_request()
69
70
 
70
71
  def full_dispatch_request(self) -> Response:
71
- logging.info("full_dispatch_request")
72
72
  # 生成请求标识
73
73
  request_id = request.headers.get('Request-Id')
74
74
  if request_id is None:
@@ -86,7 +86,8 @@ class Lesscoder(Flask):
86
86
  return response
87
87
 
88
88
  def dispatch_request(self) -> ft.ResponseReturnValue:
89
- """ 实现参数自动注入功能,对父级代码进行重写
89
+ """
90
+ 实现参数自动注入功能,对父级代码进行重写
90
91
  """
91
92
  # 此处开始 均为原代码直接拷贝
92
93
  req = request_ctx.request
@@ -118,9 +119,10 @@ class Lesscoder(Flask):
118
119
  return result
119
120
  try:
120
121
  # 判断返回结构是否是json,不是json则不包装
121
- json.dumps(result)
122
+ json.dumps(result, cls=JSONEncoder)
122
123
  return ResponseResult(data=result)
123
- except:
124
+ except Exception as e:
125
+ print(e)
124
126
  return result
125
127
 
126
128
  def setup(self):
@@ -1,5 +1,5 @@
1
1
  # coding: utf-8
2
- from lesscode_flask.model.base_model import BaseModel,JSONEncodedDict
2
+ from lesscode_flask.model.base_model import BaseModel, JSONEncodedDict, DatetimeEncodedString
3
3
  from lesscode_flask.utils.helpers import generate_uuid
4
4
  from sqlalchemy import Column, DateTime, Integer, JSON, text, Text, String, Float, BigInteger
5
5
 
@@ -35,8 +35,8 @@ class AuthClient(BaseModel):
35
35
  create_user_name = Column(String(36), nullable=False, comment='创建人用户名')
36
36
  modify_user_id = Column(String(36), comment='修改人id')
37
37
  modify_user_name = Column(String(36), comment='修改人用户名')
38
- create_time = Column(DateTime, server_default=text("CURRENT_TIMESTAMP"), comment='创建时间')
39
- modify_time = Column(DateTime, comment='修改时间')
38
+ create_time = Column(DatetimeEncodedString(), server_default=text("CURRENT_TIMESTAMP"), comment='创建时间')
39
+ modify_time = Column(DatetimeEncodedString(), comment='修改时间')
40
40
  service_export = Column(String(255), comment='服务出口,例如:https://chanyeos.com')
41
41
  is_only_one = Column(Integer, default=1, comment='1:互踢,0:不互踢')
42
42
  metadata_ = Column('metadata', JSONEncodedDict())
@@ -1,4 +1,6 @@
1
1
  import json
2
+ import logging
3
+ from datetime import datetime
2
4
  from typing import Optional
3
5
 
4
6
  from sqlalchemy.sql.type_api import _T
@@ -10,7 +12,6 @@ class BaseModel(db.Model):
10
12
  __abstract__ = True
11
13
  __bind_key__ = 'default'
12
14
 
13
-
14
15
  # def to_dict(self):
15
16
  # return {c.name: getattr(self, c.name) for c in self.__table__.columns}
16
17
 
@@ -36,3 +37,24 @@ class JSONEncodedDict(TypeDecorator):
36
37
  value = json.loads(value)
37
38
  return value
38
39
 
40
+
41
+ class DatetimeEncodedString(TypeDecorator):
42
+ """数据字段日期与字符串进行互转"""
43
+
44
+ impl = VARCHAR
45
+
46
+ def __init__(self, date_format="%Y-%m-%d %H:%M:%S"):
47
+ self.date_format = date_format
48
+ super().__init__()
49
+ # date_format = "%Y-%m-%d %H:%M:%S"
50
+ def process_bind_param(self, value, dialect):
51
+ # 将实体属性转化为数据库值
52
+ if value is not None:
53
+ value = datetime.strptime(value, self.date_format)
54
+ return value
55
+
56
+ def process_result_value(self, value, dialect):
57
+ # 数据库值转化为实体属性。
58
+ if value is not None:
59
+ value = value.strftime(self.date_format)
60
+ return value
@@ -23,8 +23,7 @@ class User(UserMixin, PermissionsCheckMixin):
23
23
  """
24
24
 
25
25
  def __init__(self, id, username: str = None, display_name: str = None, phone_no: str = None, email: str = None,
26
- org_id: str = None,
27
- account_status: str = None, permissions=None):
26
+ org_id: str = None, account_status: str = None, permissions=None, client_id: str = None):
28
27
  # '账号id',
29
28
  self.id = id
30
29
  # 用户名
@@ -39,6 +38,8 @@ class User(UserMixin, PermissionsCheckMixin):
39
38
  self.org_id = org_id
40
39
  # '1正常(激活);2未激活(管理员新增,首次登录需要改密码); 3锁定(登录错误次数超限,锁定时长可配置); 4休眠(长期未登录(字段,时长可配置),定时) 5禁用-账号失效;
41
40
  self.account_status = account_status
41
+ # client_id
42
+ self.client_id = client_id
42
43
  # 权限集合
43
44
  self.permissions = permissions
44
45
 
@@ -79,7 +80,8 @@ class AnonymousUser(User):
79
80
  """
80
81
 
81
82
  def __init__(self, permissions=None):
82
- super(AnonymousUser, self).__init__("AnonymousUserId", "AnonymousUser", "匿名用户", "-", "-", None, 1, permissions)
83
+ super(AnonymousUser, self).__init__("AnonymousUserId", "AnonymousUser", "匿名用户", "-", "-", None, 1,
84
+ permissions)
83
85
 
84
86
  @staticmethod
85
87
  def is_api_user():
@@ -32,11 +32,18 @@ def get_token_user(token):
32
32
  # }
33
33
  if access_token:
34
34
  user_id = access_token.get("user_id")
35
- clientId = access_token.get("client_id")
35
+ clientId = access_token.get("clientId")
36
36
  user_cache_key = f"oauth2:client_user_info:client_id:{clientId}:user_id:{user_id}"
37
37
  user_dict = RedisHelper(app_config.get("REDIS_OAUTH_KEY", "redis")).sync_hgetall(user_cache_key)
38
38
  if user_dict:
39
- user = User(user_id, user_dict["username"], user_dict["display_name"], user_dict["permissions"])
39
+ user = User(
40
+ id=user_id,
41
+ username=user_dict["username"],
42
+ display_name=user_dict["display_name"],
43
+ phone_no=user_dict["phone_no"],
44
+ permissions=user_dict["roleIds"],
45
+ client_id=user_dict["client_id"]
46
+ )
40
47
  return user
41
48
  return None
42
49
 
@@ -80,26 +80,30 @@ class BaseService:
80
80
  db.session.commit()
81
81
  return id
82
82
 
83
- def get_item(self, id: str):
83
+ def get_item(self, id: str, select_columns: list = None):
84
84
  """
85
85
  获取单条信息
86
86
  :param id:
87
+ :param select_columns:
87
88
  :return:
88
89
  """
89
- item = self.model.query.get(id)
90
+ query = self.model.query
91
+ if select_columns:
92
+ query = query.with_entities(*select_columns)
93
+ item = alchemy_result_to_dict(query.get(id))
90
94
  return item
91
95
 
92
- def get_one(self, filters: list):
96
+ # def get_one(self, filters: list, select_columns: list = None, ):
97
+ def get_one(self, select_columns: list = None, order_columns: list = None, filters: list = None):
93
98
  """
94
99
  获取单条信息
95
100
  :param filters:
96
101
  :return:
97
102
  """
98
- query = self.model.query
99
- if filters:
100
- query = query.filter(*filters)
101
- item = query.one()
102
- return item
103
+ data = self.get_items(select_columns, order_columns, filters, offset=0, size=1)
104
+ if data:
105
+ return data[0]
106
+ return None
103
107
 
104
108
  def get_items(self, select_columns: list = None, order_columns: list = None, filters: list = None, offset: int = 0,
105
109
  size: int = 10):
@@ -121,26 +125,27 @@ class BaseService:
121
125
  query = query.offset(offset).limit(size)
122
126
  if select_columns:
123
127
  query = query.with_entities(*select_columns)
124
- data = alchemy_result_to_dict(query.all())
125
- else:
126
- data = serialize_result_to_dict(query.all())
128
+ data = alchemy_result_to_dict(query.all())
127
129
  return data
128
130
 
129
131
  def delete_item(self, id: str):
130
- return self.model.query.filter_by(id=id).delete()
132
+ data = self.model.query.filter_by(id=id).delete()
133
+ db.session.commit()
134
+ return data
131
135
 
132
136
  def delete_items(self, filters: list):
133
137
  if filters and len(filters) > 0:
134
- return self.model.query.filter(*filters).delete()
138
+ data = self.model.query.filter(*filters).delete()
139
+ db.session.commit()
140
+ return data
135
141
  return 0
136
142
 
137
- def page(self, select_columns: list = None, columns: list = None, order_columns: list = None, filters: list = None,
143
+ def page(self, select_columns: list = None, order_columns: list = None, filters: list = None,
138
144
  page_num: int = 1,
139
145
  page_size: int = 10):
140
146
  """
141
147
  分页查询
142
148
  :param select_columns:
143
- :param columns:
144
149
  :param order_columns:
145
150
  :param filters:
146
151
  :param page_num:
@@ -155,7 +160,7 @@ class BaseService:
155
160
  if select_columns:
156
161
  query = query.with_entities(*select_columns)
157
162
 
158
- pagination = query.paginate(page=page_num, per_page=page_size)
163
+ pagination = query.paginate(page=page_num, per_page=page_size, error_out=False)
159
164
  # 获取当前页的数据
160
165
  items = pagination.items
161
166
  # 获取分页信息
@@ -166,9 +171,7 @@ class BaseService:
166
171
  data = alchemy_result_to_dict(items)
167
172
  else:
168
173
  data = serialize_result_to_dict(items)
169
- result = {"columns": columns, "dataSource": data, "total": total,
174
+ result = {"dataSource": data, "total": total,
170
175
  "has_prev": has_prev,
171
176
  "has_next": has_next}
172
- if columns:
173
- result["columns"] = columns
174
177
  return result
@@ -75,7 +75,7 @@ def parameter_validation(obj: dict):
75
75
  验证参数对象中非None的键值
76
76
  :return:
77
77
  """
78
- return {k: v for k, v in obj.items() if v}
78
+ return {k: v for k, v in obj.items() if v is not None}
79
79
 
80
80
 
81
81
  def parse_boolean(s):
@@ -84,6 +84,7 @@ def parse_boolean(s):
84
84
  :param s: 待转换的字符串
85
85
  :return:
86
86
  """
87
+ s = str(s)
87
88
  s = s.strip().lower()
88
89
  if s in ("yes", "true", "on", "1"):
89
90
  return True
@@ -129,21 +130,21 @@ def inject_args(req, func, view_args={}):
129
130
  # 兼容**kwargs 参数
130
131
  if parameter.kind == inspect.Parameter.VAR_KEYWORD:
131
132
  argument_value = kwargs
132
- # if argument_value:
133
- # 获取形参类型
134
- parameter_type = parameter.annotation
135
- # 形参类型为空,尝试获取形参默认值类型
136
- if parameter_type is inspect.Parameter.empty:
137
- parameter_type = type(parameter.default)
138
- if parameter_type == int:
139
- params_dict[parameter_name] = int(argument_value)
140
- elif parameter_type == float:
141
- params_dict[parameter_name] = float(argument_value)
142
- elif parameter_type == bool:
143
- params_dict[parameter_name] = parse_boolean(argument_value)
144
- else:
145
- # 其余都按str处理
146
- params_dict[parameter_name] = argument_value
133
+ if argument_value is not None:
134
+ # 获取形参类型
135
+ parameter_type = parameter.annotation
136
+ # 形参类型为空,尝试获取形参默认值类型
137
+ if parameter_type is inspect.Parameter.empty:
138
+ parameter_type = type(parameter.default)
139
+ if parameter_type == int:
140
+ params_dict[parameter_name] = int(argument_value)
141
+ elif parameter_type == float:
142
+ params_dict[parameter_name] = float(argument_value)
143
+ elif parameter_type == bool:
144
+ params_dict[parameter_name] = parse_boolean(argument_value)
145
+ else:
146
+ # 其余都按str处理
147
+ params_dict[parameter_name] = argument_value
147
148
  return params_dict
148
149
 
149
150
 
@@ -1,4 +1,5 @@
1
1
  import importlib
2
+ import logging
2
3
  import random
3
4
  import uuid
4
5
 
@@ -26,6 +27,7 @@ def sync_common_request(method, path, params=None, data=None, json=None, base_ur
26
27
  with httpx.Client(**connect_config) as session:
27
28
  try:
28
29
  res = session.request(method.upper(), url=base_url + path, params=params, data=data, json=json, **kwargs)
30
+ logging.error("res返回值是{}".format(res))
29
31
  if result_type == "json":
30
32
  res = res.json()
31
33
  if not pack:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: lesscode-flask
3
- Version: 0.0.34
3
+ Version: 0.0.38
4
4
  Summary: lesscode-flask 是基于flask的web开发脚手架项目,该项目初衷为简化开发过程,让研发人员更加关注业务。
5
5
  Home-page: https://lesscode-flask
6
6
  Author: Chao.yy
@@ -39,12 +39,7 @@ Already a pro? Just edit this README.md and make it your own. Want to make it ea
39
39
  - [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files
40
40
  - [ ] [Add files using the command line](https://docs.gitlab.com/ee/gitlab-basics/add-file.html#add-a-file-using-the-command-line) or push an existing Git repository with the following command:
41
41
 
42
- ```
43
- cd existing_repo
44
- git remote add origin http://gitlab.chanyeos.com/backend/lesscode-flask.git
45
- git branch -M main
46
- git push -uf origin main
47
- ```
42
+
48
43
 
49
44
  ## Integrate with your tools
50
45