Build 060.7: implement REST Trade Adapter
This commit is contained in:
@@ -0,0 +1,41 @@
|
|||||||
|
# src/market_data/acquisition/adapters/dzengi/rest_trade_adapter.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.market_data.acquisition.adapters.dzengi.mapper import (
|
||||||
|
map_dzengi_rest_agg_trades_to_trades,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.adapters.dzengi.parser import (
|
||||||
|
parse_rest_agg_trades,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.models.trade import Trade
|
||||||
|
from src.market_data.acquisition.validation.schema import (
|
||||||
|
ValidatedRestAggTradesDocument,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.validation.values import (
|
||||||
|
validate_rest_agg_trade_values,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def adapt_rest_agg_trades_document(
|
||||||
|
document: ValidatedRestAggTradesDocument,
|
||||||
|
*,
|
||||||
|
symbol: str,
|
||||||
|
) -> tuple[Trade, ...]:
|
||||||
|
"""
|
||||||
|
Преобразовать структурно проверенный документ Dzengi aggTrades
|
||||||
|
в канонический immutable-набор Trade.
|
||||||
|
|
||||||
|
Функция последовательно выполняет parsing, value validation
|
||||||
|
и mapping. Schema validation должна быть выполнена вызывающим слоем.
|
||||||
|
Исключения отдельных этапов не перехватываются и не оборачиваются.
|
||||||
|
"""
|
||||||
|
|
||||||
|
trades = parse_rest_agg_trades(document)
|
||||||
|
|
||||||
|
validate_rest_agg_trade_values(trades)
|
||||||
|
|
||||||
|
return map_dzengi_rest_agg_trades_to_trades(
|
||||||
|
trades,
|
||||||
|
symbol=symbol,
|
||||||
|
)
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
# tests/unit/market_data/acquisition/adapters/dzengi/test_rest_trade_adapter.py
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
from types import MappingProxyType
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.market_data.acquisition.adapters.dzengi import rest_trade_adapter
|
||||||
|
from src.market_data.acquisition.adapters.dzengi.rest_trade_adapter import (
|
||||||
|
adapt_rest_agg_trades_document,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.exceptions import (
|
||||||
|
TradeMappingError,
|
||||||
|
TradeParseError,
|
||||||
|
TradeValueError,
|
||||||
|
)
|
||||||
|
from src.market_data.acquisition.models.trade import TradeAggressorSide
|
||||||
|
from src.market_data.acquisition.validation.schema import (
|
||||||
|
ValidatedRestAggTradesDocument,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_adapt_rest_agg_trades_document_returns_canonical_trades() -> None:
|
||||||
|
document = ValidatedRestAggTradesDocument(
|
||||||
|
items=(
|
||||||
|
MappingProxyType(
|
||||||
|
{
|
||||||
|
"a": 101,
|
||||||
|
"p": "43210.50",
|
||||||
|
"q": "0.125",
|
||||||
|
"T": 1_700_000_000_000,
|
||||||
|
"m": False,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
MappingProxyType(
|
||||||
|
{
|
||||||
|
"a": 102,
|
||||||
|
"p": "43211.75",
|
||||||
|
"q": "0.250",
|
||||||
|
"T": 1_700_000_001_000,
|
||||||
|
"m": True,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = adapt_rest_agg_trades_document(
|
||||||
|
document,
|
||||||
|
symbol=" BTCUSDT ",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result, tuple)
|
||||||
|
assert len(result) == 2
|
||||||
|
|
||||||
|
first_trade = result[0]
|
||||||
|
|
||||||
|
assert first_trade.symbol == "BTCUSDT"
|
||||||
|
assert first_trade.trade_id == 101
|
||||||
|
assert first_trade.price == Decimal("43210.50")
|
||||||
|
assert first_trade.quantity == Decimal("0.125")
|
||||||
|
assert first_trade.executed_at == datetime(
|
||||||
|
2023,
|
||||||
|
11,
|
||||||
|
14,
|
||||||
|
22,
|
||||||
|
13,
|
||||||
|
20,
|
||||||
|
tzinfo=timezone.utc,
|
||||||
|
)
|
||||||
|
assert first_trade.aggressor_side is TradeAggressorSide.BUY
|
||||||
|
assert first_trade.source == "dzengi"
|
||||||
|
|
||||||
|
second_trade = result[1]
|
||||||
|
|
||||||
|
assert second_trade.symbol == "BTCUSDT"
|
||||||
|
assert second_trade.trade_id == 102
|
||||||
|
assert second_trade.price == Decimal("43211.75")
|
||||||
|
assert second_trade.quantity == Decimal("0.250")
|
||||||
|
assert second_trade.executed_at == datetime(
|
||||||
|
2023,
|
||||||
|
11,
|
||||||
|
14,
|
||||||
|
22,
|
||||||
|
13,
|
||||||
|
21,
|
||||||
|
tzinfo=timezone.utc,
|
||||||
|
)
|
||||||
|
assert second_trade.aggressor_side is TradeAggressorSide.SELL
|
||||||
|
assert second_trade.source == "dzengi"
|
||||||
|
|
||||||
|
|
||||||
|
def test_adapt_rest_agg_trades_document_propagates_parse_error(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
document = ValidatedRestAggTradesDocument(items=())
|
||||||
|
expected_error = TradeParseError("test parse error")
|
||||||
|
|
||||||
|
def raise_parse_error(
|
||||||
|
_: ValidatedRestAggTradesDocument,
|
||||||
|
) -> tuple[()]:
|
||||||
|
raise expected_error
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
rest_trade_adapter,
|
||||||
|
"parse_rest_agg_trades",
|
||||||
|
raise_parse_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(TradeParseError) as exc_info:
|
||||||
|
adapt_rest_agg_trades_document(
|
||||||
|
document,
|
||||||
|
symbol="BTCUSDT",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc_info.value is expected_error
|
||||||
|
|
||||||
|
|
||||||
|
def test_adapt_rest_agg_trades_document_propagates_value_error(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
document = ValidatedRestAggTradesDocument(items=())
|
||||||
|
expected_error = TradeValueError("test value error")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
rest_trade_adapter,
|
||||||
|
"parse_rest_agg_trades",
|
||||||
|
lambda _: (),
|
||||||
|
)
|
||||||
|
|
||||||
|
def raise_value_error(_: tuple[object, ...]) -> None:
|
||||||
|
raise expected_error
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
rest_trade_adapter,
|
||||||
|
"validate_rest_agg_trade_values",
|
||||||
|
raise_value_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(TradeValueError) as exc_info:
|
||||||
|
adapt_rest_agg_trades_document(
|
||||||
|
document,
|
||||||
|
symbol="BTCUSDT",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc_info.value is expected_error
|
||||||
|
|
||||||
|
|
||||||
|
def test_adapt_rest_agg_trades_document_propagates_mapping_error(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
document = ValidatedRestAggTradesDocument(items=())
|
||||||
|
expected_error = TradeMappingError("test mapping error")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
rest_trade_adapter,
|
||||||
|
"parse_rest_agg_trades",
|
||||||
|
lambda _: (),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
rest_trade_adapter,
|
||||||
|
"validate_rest_agg_trade_values",
|
||||||
|
lambda _: None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def raise_mapping_error(
|
||||||
|
_: tuple[object, ...],
|
||||||
|
*,
|
||||||
|
symbol: str,
|
||||||
|
) -> tuple[()]:
|
||||||
|
del symbol
|
||||||
|
raise expected_error
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
rest_trade_adapter,
|
||||||
|
"map_dzengi_rest_agg_trades_to_trades",
|
||||||
|
raise_mapping_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(TradeMappingError) as exc_info:
|
||||||
|
adapt_rest_agg_trades_document(
|
||||||
|
document,
|
||||||
|
symbol="BTCUSDT",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc_info.value is expected_error
|
||||||
328
docs/migrations/build_060_5.md
Normal file
328
docs/migrations/build_060_5.md
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
# Build 060.5 — REST Trade Value Validation
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
|
||||||
|
Реализовать слой проверки допустимости значений (`Value Validation`) для REST aggTrades после завершения этапов структурной проверки (`Schema Validation`) и преобразования в transport-модели (`REST Parser`).
|
||||||
|
|
||||||
|
Build завершает третий этап конвейера обработки REST Trades и обеспечивает, что все transport-модели содержат только корректные значения перед передачей в Mapper.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Место Build в общей архитектуре
|
||||||
|
|
||||||
|
До Build 060.5 конвейер выглядел следующим образом:
|
||||||
|
|
||||||
|
```text
|
||||||
|
REST JSON
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Schema Validation
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
ValidatedRestAggTradesDocument
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
REST Parser
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
tuple[DzengiRestAggTrade]
|
||||||
|
```
|
||||||
|
|
||||||
|
После Build 060.5:
|
||||||
|
|
||||||
|
```text
|
||||||
|
REST JSON
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Schema Validation
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
ValidatedRestAggTradesDocument
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
REST Parser
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
tuple[DzengiRestAggTrade]
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
REST Trade Value Validation
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
tuple[DzengiRestAggTrade]
|
||||||
|
```
|
||||||
|
|
||||||
|
Следующим этапом станет:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Mapper
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Canonical Trade
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Архитектурная ответственность
|
||||||
|
|
||||||
|
Value Validation отвечает исключительно за проверку допустимости значений transport-моделей.
|
||||||
|
|
||||||
|
Validator:
|
||||||
|
|
||||||
|
- не изменяет transport-модель;
|
||||||
|
- не выполняет mapping;
|
||||||
|
- не преобразует данные в canonical-модель;
|
||||||
|
- не нормализует значения;
|
||||||
|
- не возвращает преобразованные объекты.
|
||||||
|
|
||||||
|
При успешной проверке функция завершается без результата.
|
||||||
|
|
||||||
|
При обнаружении ошибки выбрасывается специализированное исключение.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Добавлено новое исключение
|
||||||
|
|
||||||
|
Файл:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/market_data/acquisition/exceptions.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Добавлено:
|
||||||
|
|
||||||
|
```python
|
||||||
|
TradeValueError
|
||||||
|
```
|
||||||
|
|
||||||
|
Назначение:
|
||||||
|
|
||||||
|
- ошибки проверки допустимости значений REST Trade transport-моделей.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Новый публичный API
|
||||||
|
|
||||||
|
Файл:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/market_data/acquisition/validation/values.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Добавлена функция:
|
||||||
|
|
||||||
|
```python
|
||||||
|
validate_rest_agg_trade_values(
|
||||||
|
trades: tuple[DzengiRestAggTrade, ...],
|
||||||
|
) -> None
|
||||||
|
```
|
||||||
|
|
||||||
|
Ответственность функции:
|
||||||
|
|
||||||
|
- проверить все transport-модели;
|
||||||
|
- при первой ошибке выбросить `TradeValueError`;
|
||||||
|
- при отсутствии ошибок успешно завершиться.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Внутренняя архитектура
|
||||||
|
|
||||||
|
Реализация построена по тому же принципу, что уже используется для Instrument, Quote и Candle.
|
||||||
|
|
||||||
|
```text
|
||||||
|
validate_rest_agg_trade_values()
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
_validate_rest_agg_trade()
|
||||||
|
│
|
||||||
|
├────────────► _trade_positive_int()
|
||||||
|
├────────────► _trade_positive_decimal()
|
||||||
|
└────────────► _trade_decimal()
|
||||||
|
```
|
||||||
|
|
||||||
|
Каждая helper-функция отвечает только за одну проверку.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Проверяемые поля
|
||||||
|
|
||||||
|
## aggregate_trade_id
|
||||||
|
|
||||||
|
Проверяется:
|
||||||
|
|
||||||
|
- тип int (bool исключается);
|
||||||
|
- значение больше нуля.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## price
|
||||||
|
|
||||||
|
Проверяется:
|
||||||
|
|
||||||
|
- успешное преобразование в Decimal;
|
||||||
|
- конечность числа;
|
||||||
|
- значение больше нуля.
|
||||||
|
|
||||||
|
Поддерживаются значения:
|
||||||
|
|
||||||
|
- str
|
||||||
|
- int
|
||||||
|
- float
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## quantity
|
||||||
|
|
||||||
|
Проверяется аналогично полю price.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## timestamp
|
||||||
|
|
||||||
|
Проверяется:
|
||||||
|
|
||||||
|
- тип int (bool исключается);
|
||||||
|
- значение больше нуля.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## buyer_is_maker
|
||||||
|
|
||||||
|
Дополнительная проверка отсутствует.
|
||||||
|
|
||||||
|
Корректность типа bool уже гарантируется предыдущим этапом — REST Parser.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Что НЕ входит в Value Validation
|
||||||
|
|
||||||
|
Build сознательно не выполняет:
|
||||||
|
|
||||||
|
- преобразование transport-моделей;
|
||||||
|
- mapping;
|
||||||
|
- создание Canonical Trade;
|
||||||
|
- преобразование чисел в Decimal для дальнейшей обработки;
|
||||||
|
- проверку возраста сделки;
|
||||||
|
- проверку порядка timestamp;
|
||||||
|
- проверку последовательности aggregateTradeId;
|
||||||
|
- проверку дубликатов.
|
||||||
|
|
||||||
|
Все перечисленные задачи относятся к последующим слоям Acquisition Pipeline.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Диагностика ошибок
|
||||||
|
|
||||||
|
Все сообщения содержат полный путь до ошибочного поля.
|
||||||
|
|
||||||
|
Примеры:
|
||||||
|
|
||||||
|
```text
|
||||||
|
$[0].price должно быть больше нуля.
|
||||||
|
|
||||||
|
$[1].quantity должно быть корректным числом.
|
||||||
|
|
||||||
|
$[2].timestamp должно быть целым числом больше нуля.
|
||||||
|
```
|
||||||
|
|
||||||
|
Такой формат полностью соответствует существующей архитектуре Validation Layer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Unit Tests
|
||||||
|
|
||||||
|
Добавлены тесты для:
|
||||||
|
|
||||||
|
## Позитивных сценариев
|
||||||
|
|
||||||
|
- одна корректная сделка;
|
||||||
|
- несколько корректных сделок;
|
||||||
|
- пустой tuple;
|
||||||
|
- строковые числовые значения;
|
||||||
|
- int;
|
||||||
|
- float;
|
||||||
|
- оба значения buyer_is_maker.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Негативных сценариев
|
||||||
|
|
||||||
|
Проверяются:
|
||||||
|
|
||||||
|
- aggregate_trade_id ≤ 0;
|
||||||
|
- timestamp ≤ 0;
|
||||||
|
- price ≤ 0;
|
||||||
|
- quantity ≤ 0;
|
||||||
|
- NaN;
|
||||||
|
- Infinity;
|
||||||
|
- -Infinity;
|
||||||
|
- некорректные числовые строки;
|
||||||
|
- корректное формирование пути ошибки.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Результаты проверки
|
||||||
|
|
||||||
|
Target tests:
|
||||||
|
|
||||||
|
```text
|
||||||
|
68 passed
|
||||||
|
```
|
||||||
|
|
||||||
|
Полная регрессия проекта:
|
||||||
|
|
||||||
|
```text
|
||||||
|
1072 passed
|
||||||
|
```
|
||||||
|
|
||||||
|
Дополнительно выполнено:
|
||||||
|
|
||||||
|
```text
|
||||||
|
python -m compileall src
|
||||||
|
```
|
||||||
|
|
||||||
|
Результат:
|
||||||
|
|
||||||
|
```text
|
||||||
|
OK
|
||||||
|
```
|
||||||
|
|
||||||
|
Также выполнено:
|
||||||
|
|
||||||
|
```text
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
Ошибок форматирования не обнаружено.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Архитектурный результат Build
|
||||||
|
|
||||||
|
После завершения Build 060.5 REST Trades получили полностью независимый трехуровневый pipeline проверки данных.
|
||||||
|
|
||||||
|
```text
|
||||||
|
REST JSON
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Schema Validation
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
ValidatedRestAggTradesDocument
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
REST Parser
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
tuple[DzengiRestAggTrade]
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
REST Trade Value Validation
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
tuple[DzengiRestAggTrade]
|
||||||
|
```
|
||||||
|
|
||||||
|
Каждый этап отвечает исключительно за собственную область ответственности.
|
||||||
|
|
||||||
|
Следующий Build (060.6) впервые переведет transport-модели в внутреннюю каноническую модель `Trade`, независимую от конкретной биржи.
|
||||||
@@ -2185,42 +2185,42 @@ Production Integration — намеренно не выполнялась
|
|||||||
|
|
||||||
# Следующий этап
|
# Следующий этап
|
||||||
|
|
||||||
Следующим этапом развития серии Build 060 становится интеграция построенного Pipeline в рабочую подсистему получения данных.
|
Следующим этапом развития серии Build 060 становится:
|
||||||
|
|
||||||
Наиболее логичным продолжением является:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Build 060.7 — REST Trades Client
|
Build 060.7 — REST Trade Adapter
|
||||||
```
|
```
|
||||||
|
|
||||||
На этом этапе будет реализован специализированный клиент получения агрегированных сделок через REST API Dzengi, использующий полностью сформированный Pipeline:
|
На этом этапе будет реализован адаптер REST-сделок, который объединит уже созданные компоненты обработки:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
REST Request
|
Validated REST document
|
||||||
|
|
||||||
↓
|
↓
|
||||||
|
|
||||||
REST Response
|
|
||||||
|
|
||||||
↓
|
|
||||||
|
|
||||||
Schema Validation
|
|
||||||
|
|
||||||
↓
|
|
||||||
|
|
||||||
Parser
|
Parser
|
||||||
|
|
||||||
↓
|
↓
|
||||||
|
|
||||||
Value Validation
|
Value Validation
|
||||||
|
|
||||||
↓
|
↓
|
||||||
|
|
||||||
Mapper
|
Mapper
|
||||||
|
|
||||||
↓
|
↓
|
||||||
|
|
||||||
tuple[Trade]
|
tuple[Trade]
|
||||||
```
|
```
|
||||||
|
|
||||||
Build 060.7 станет первым этапом, на котором сформированный Pipeline начнёт использоваться в реальном процессе получения исторических сделок.
|
Build 060.7 не должен самостоятельно выполнять HTTP-запросы и получать документ из внешнего источника.
|
||||||
|
|
||||||
|
Получение исходного REST-документа будет выделено в следующий отдельный этап:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Build 060.8 — REST Trades Document Source
|
||||||
|
```
|
||||||
|
|
||||||
|
Такое разделение сохраняет независимость:
|
||||||
|
|
||||||
|
* источника документа;
|
||||||
|
* адаптера обработки документа;
|
||||||
|
* transport-моделей;
|
||||||
|
* validation;
|
||||||
|
* parser;
|
||||||
|
* mapper;
|
||||||
|
* Canonical Trade.
|
||||||
|
|
||||||
|
После завершения Build 060.7 система получит единый REST Trade Adapter, способный преобразовывать уже полученный REST-документ в канонические сделки, но ещё не связанный с конкретным механизмом HTTP-доступа.
|
||||||
1734
docs/migrations/build_060_7.md
Normal file
1734
docs/migrations/build_060_7.md
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user