PyPtt 2.1.2__tar.gz → 2.1.4__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.
- {pyptt-2.1.2 → pyptt-2.1.4}/PKG-INFO +1 -1
- {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/PTT.py +4 -2
- {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/__init__.py +1 -1
- {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_del_post.py +41 -12
- {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/connect_core.py +12 -4
- {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/screens.py +3 -1
- pyptt-2.1.4/PyPtt/ssl_config.py +24 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt.egg-info/PKG-INFO +1 -1
- {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_bucket.py +2 -1
- pyptt-2.1.4/tests/test_del_post.py +172 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_get_newest_index.py +12 -1
- {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_get_waterball.py +7 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_give_money.py +5 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_mark_post.py +3 -2
- {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_service.py +7 -1
- {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_set_board_title.py +2 -1
- pyptt-2.1.2/PyPtt/ssl_config.py +0 -24
- pyptt-2.1.2/tests/test_del_post.py +0 -88
- {pyptt-2.1.2 → pyptt-2.1.4}/LICENSE +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_bucket.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_call_status.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_change_pw.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_comment.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_get_board_info.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_get_board_list.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_get_bottom_post_list.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_get_favourite_board.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_get_newest_index.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_get_post.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_get_post_index.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_get_post_list.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_get_time.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_get_user.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_get_waterball.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_give_money.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_has_new_mail.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_loginout.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_mail.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_mark_post.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_post.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_reply_post.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_search_user.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_set_board_title.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/_api_util.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/api_server.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/check_value.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/command.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/config.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/data_type.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/exceptions.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/i18n.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/lang_en_US.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/lang_zh_TW.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/lib_util.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/log.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt/service.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt.egg-info/SOURCES.txt +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt.egg-info/dependency_links.txt +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt.egg-info/requires.txt +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/PyPtt.egg-info/top_level.txt +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/README.md +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/pyproject.toml +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/setup.cfg +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/setup.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_change_pw.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_comment.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_exceptions.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_fast_post.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_get_board_info.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_get_board_list.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_get_bottom_post_list.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_get_content_formula.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_get_favourite_boards.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_get_mail.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_get_post.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_get_post_list.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_get_post_list_parser.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_get_post_parser.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_get_time.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_get_user.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_i18n.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_init.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_logger.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_mail_send_and_del.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_parse_query_post.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_post.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_pyte_parity.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_reply_post.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_search_user.py +0 -0
- {pyptt-2.1.2 → pyptt-2.1.4}/tests/test_vt100_parser.py +0 -0
|
@@ -1071,7 +1071,7 @@ class API:
|
|
|
1071
1071
|
|
|
1072
1072
|
return _api_get_bottom_post_list.get_bottom_post_list(self, board)
|
|
1073
1073
|
|
|
1074
|
-
def del_post(self, board: str, aid: Optional[str] = None, index: int = 0) -> None:
|
|
1074
|
+
def del_post(self, board: str, aid: Optional[str] = None, index: int = 0, reason: Optional[str] = None) -> None:
|
|
1075
1075
|
"""
|
|
1076
1076
|
刪除文章。
|
|
1077
1077
|
|
|
@@ -1079,6 +1079,7 @@ class API:
|
|
|
1079
1079
|
board (str): 看板名稱。
|
|
1080
1080
|
aid (str): 文章編號。
|
|
1081
1081
|
index (int): 文章編號。
|
|
1082
|
+
reason (str): 板主刪除他人文章時,加註於刪除後標題的理由(僅板主刪除他板友文章時有效)。
|
|
1082
1083
|
|
|
1083
1084
|
Returns:
|
|
1084
1085
|
None
|
|
@@ -1089,6 +1090,7 @@ class API:
|
|
|
1089
1090
|
NoSuchBoard: 看板不存在。
|
|
1090
1091
|
NoSuchPost: 文章不存在。
|
|
1091
1092
|
NoPermission: 沒有權限。
|
|
1093
|
+
ParameterError: 對自己的文章傳入 reason(加註理由僅適用於版主刪除他人文章)。
|
|
1092
1094
|
|
|
1093
1095
|
範例::
|
|
1094
1096
|
|
|
@@ -1103,7 +1105,7 @@ class API:
|
|
|
1103
1105
|
ptt_bot.logout()
|
|
1104
1106
|
"""
|
|
1105
1107
|
|
|
1106
|
-
_api_del_post.del_post(self, board, aid, index)
|
|
1108
|
+
_api_del_post.del_post(self, board, aid, index, reason)
|
|
1107
1109
|
|
|
1108
1110
|
def get_post_list(self, board: str, limit: int = 20, offset: int = 0) -> list[dict]:
|
|
1109
1111
|
"""
|
|
@@ -14,7 +14,7 @@ from . import log
|
|
|
14
14
|
from . import screens
|
|
15
15
|
|
|
16
16
|
|
|
17
|
-
def del_post(api, board: str, post_aid: Optional[str] = None, post_index: int = 0) -> None:
|
|
17
|
+
def del_post(api, board: str, post_aid: Optional[str] = None, post_index: int = 0, reason: Optional[str] = None) -> None:
|
|
18
18
|
_api_util.one_thread(api)
|
|
19
19
|
|
|
20
20
|
if not api.is_registered_user:
|
|
@@ -27,6 +27,8 @@ def del_post(api, board: str, post_aid: Optional[str] = None, post_index: int =
|
|
|
27
27
|
if post_aid is not None:
|
|
28
28
|
check_value.check_type(post_aid, str, 'PostAID')
|
|
29
29
|
check_value.check_type(post_index, int, 'PostIndex')
|
|
30
|
+
if reason is not None:
|
|
31
|
+
check_value.check_type(reason, str, 'reason')
|
|
30
32
|
|
|
31
33
|
if len(board) == 0:
|
|
32
34
|
raise exceptions.ParameterError(f'board error parameter: {board}')
|
|
@@ -64,6 +66,13 @@ def del_post(api, board: str, post_aid: Optional[str] = None, post_index: int =
|
|
|
64
66
|
log.logger.info(i18n.delete_post, '...', i18n.success)
|
|
65
67
|
return
|
|
66
68
|
|
|
69
|
+
# PTT only offers the "R加註理由" annotation when a moderator deletes
|
|
70
|
+
# another user's post. On your own post the reason can never be applied,
|
|
71
|
+
# so reject it rather than silently dropping it.
|
|
72
|
+
if reason and api.ptt_id.lower() == post_info[data_type.PostField.author].lower():
|
|
73
|
+
raise exceptions.ParameterError(
|
|
74
|
+
'reason is only valid when a moderator deletes another user\'s post')
|
|
75
|
+
|
|
67
76
|
if check_author:
|
|
68
77
|
if api.ptt_id.lower() != post_info[data_type.PostField.author].lower():
|
|
69
78
|
log.logger.info(i18n.delete_post, '...', i18n.fail)
|
|
@@ -90,20 +99,40 @@ def del_post(api, board: str, post_aid: Optional[str] = None, post_index: int =
|
|
|
90
99
|
def confirm_delete_handler(screen):
|
|
91
100
|
api.confirm = True
|
|
92
101
|
|
|
102
|
+
# A board moderator deleting another user's post gets an extra
|
|
103
|
+
# "R加註理由" option that lets us annotate the title shown after deletion.
|
|
104
|
+
use_reason = bool(reason) and not check_author
|
|
105
|
+
|
|
106
|
+
def confirm_delete_response(screen):
|
|
107
|
+
if use_reason and '加註理由' in screen:
|
|
108
|
+
return 'r' + command.enter
|
|
109
|
+
return 'y' + command.enter
|
|
110
|
+
|
|
93
111
|
target_list = [
|
|
94
112
|
connect_core.TargetUnit('請按任意鍵繼續', response=' '),
|
|
95
|
-
connect_core.TargetUnit('請確定刪除(Y/N
|
|
96
|
-
max_match=1),
|
|
97
|
-
connect_core.TargetUnit(screens.Target.InBoard, break_detect=True),
|
|
113
|
+
connect_core.TargetUnit('請確定刪除(Y/N', response=confirm_delete_response,
|
|
114
|
+
handler=confirm_delete_handler, max_match=1),
|
|
98
115
|
]
|
|
99
116
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
117
|
+
if use_reason:
|
|
118
|
+
target_list.append(
|
|
119
|
+
connect_core.TargetUnit(
|
|
120
|
+
'請輸入刪除後要顯示的標題',
|
|
121
|
+
response=command.ctrl_e + ' ' + reason + command.enter,
|
|
122
|
+
max_match=1))
|
|
123
|
+
target_list.append(
|
|
124
|
+
connect_core.TargetUnit(
|
|
125
|
+
'請再次確定是否要用上述理由刪除(Y/N',
|
|
126
|
+
response='y' + command.enter,
|
|
127
|
+
max_match=1))
|
|
128
|
+
|
|
129
|
+
target_list.append(
|
|
130
|
+
connect_core.TargetUnit(screens.Target.InBoard, break_detect=True))
|
|
131
|
+
|
|
132
|
+
api.connect_core.send(cmd, target_list)
|
|
133
|
+
|
|
134
|
+
if not api.confirm:
|
|
135
|
+
log.logger.info(i18n.delete_post, '...', i18n.fail)
|
|
136
|
+
raise exceptions.NoPermission(i18n.no_permission)
|
|
108
137
|
|
|
109
138
|
log.logger.info(i18n.delete_post, '...', i18n.success)
|
|
@@ -196,8 +196,12 @@ class API(object):
|
|
|
196
196
|
websocket_host = 'wss://ws.ptt2.cc/bbs/'
|
|
197
197
|
websocket_origin = 'https://term.ptt2.cc'
|
|
198
198
|
elif self.config.host == data_type.HOST.LOCALHOST:
|
|
199
|
-
|
|
200
|
-
|
|
199
|
+
# ponytail: local pttbbs docker image (bbsdocker/imageptt) serves plain ws
|
|
200
|
+
# (no TLS) on 48763; config.port defaults to 23 (telnet default) so treat
|
|
201
|
+
# that as "not set" and fall back to imageptt's default port
|
|
202
|
+
port = self.config.port if self.config.port != 23 else 48763
|
|
203
|
+
websocket_host = f'ws://localhost:{port}/bbs'
|
|
204
|
+
websocket_origin = 'http://localhost'
|
|
201
205
|
else:
|
|
202
206
|
websocket_host = f'wss://{self.config.host}'
|
|
203
207
|
websocket_origin = 'https://term.ptt.cc'
|
|
@@ -209,11 +213,15 @@ class API(object):
|
|
|
209
213
|
try:
|
|
210
214
|
log.logger.debug('USER_AGENT',
|
|
211
215
|
websockets.http11.USER_AGENT if use_http11 else websockets.http.USER_AGENT)
|
|
216
|
+
# ponytail: ws:// (local docker target) can't take an ssl context —
|
|
217
|
+
# websockets errors out if you pass one on a non-wss URI
|
|
218
|
+
connect_kwargs = {'origin': websocket_origin}
|
|
219
|
+
if websocket_host.startswith('wss://'):
|
|
220
|
+
connect_kwargs['ssl'] = self._ssl_context
|
|
212
221
|
self._core = loop.run_until_complete(
|
|
213
222
|
websockets.connect(
|
|
214
223
|
websocket_host,
|
|
215
|
-
|
|
216
|
-
ssl=self._ssl_context))
|
|
224
|
+
**connect_kwargs))
|
|
217
225
|
# Respond to PTT's IAC DO NAWS (ff fd 1f) and announce terminal size.
|
|
218
226
|
# PTT honours any height >= 24; larger values reduce get_content iterations.
|
|
219
227
|
# PTT's hard cap is 100 rows regardless of what we send.
|
|
@@ -199,7 +199,9 @@ class Target:
|
|
|
199
199
|
content_end_list = [
|
|
200
200
|
'--\n※ 發信站: 批踢踢實業坊',
|
|
201
201
|
'--\n※ 發信站: 批踢踢兔(ptt2.cc)',
|
|
202
|
-
'--\n※ 發信站: 新批踢踢(ptt2.twbbs.org.tw)'
|
|
202
|
+
'--\n※ 發信站: 新批踢踢(ptt2.twbbs.org.tw)',
|
|
203
|
+
# bbsdocker/imageptt (HOST.LOCALHOST test container) site name.
|
|
204
|
+
'--\n※ 發信站: 批踢踢 docker(pttdocker.test)'
|
|
203
205
|
]
|
|
204
206
|
|
|
205
207
|
OnlineUser = [
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
key = """-----BEGIN EC PARAMETERS-----
|
|
2
|
+
BggqhkjOPQMBBw==
|
|
3
|
+
-----END EC PARAMETERS-----
|
|
4
|
+
-----BEGIN EC PRIVATE KEY-----
|
|
5
|
+
MHcCAQEEIPSUMd0VM3Lz9oHqzRjDOoA7mMSwkff1CvCmOhswqdeToAoGCCqGSM49
|
|
6
|
+
AwEHoUQDQgAEFL8NgpDyAYc1AfvrY4QeOOF5bEaj3fQyrfF+SQhRzgqnKFPxCVVs
|
|
7
|
+
qPE3yCC7L/2VweGjoPMg1YPOsSTsiliqlg==
|
|
8
|
+
-----END EC PRIVATE KEY-----
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
cert = """-----BEGIN CERTIFICATE-----
|
|
12
|
+
MIIB6jCCAY8CFFnMpZwQZ949yt925TXsbCqq9/+SMAoGCCqGSM49BAMCMHcxCzAJ
|
|
13
|
+
BgNVBAYTAlRXMQ4wDAYDVQQIDAVTdGF0ZTENMAsGA1UEBwwEQ2l0eTEVMBMGA1UE
|
|
14
|
+
CgwMT3JnYW5pemF0aW9uMRwwGgYDVQQLDBNPcmdhbml6YXRpb25hbCBVbml0MRQw
|
|
15
|
+
EgYDVQQDDAtDb21tb24gTmFtZTAeFw0yNjA3MTMxNTE4MDhaFw0zNjA3MTAxNTE4
|
|
16
|
+
MDhaMHcxCzAJBgNVBAYTAlRXMQ4wDAYDVQQIDAVTdGF0ZTENMAsGA1UEBwwEQ2l0
|
|
17
|
+
eTEVMBMGA1UECgwMT3JnYW5pemF0aW9uMRwwGgYDVQQLDBNPcmdhbml6YXRpb25h
|
|
18
|
+
bCBVbml0MRQwEgYDVQQDDAtDb21tb24gTmFtZTBZMBMGByqGSM49AgEGCCqGSM49
|
|
19
|
+
AwEHA0IABBS/DYKQ8gGHNQH762OEHjjheWxGo930Mq3xfkkIUc4KpyhT8QlVbKjx
|
|
20
|
+
N8gguy/9lcHho6DzINWDzrEk7IpYqpYwCgYIKoZIzj0EAwIDSQAwRgIhAJaKKQ+t
|
|
21
|
+
oEPH2j3xs3Rg3F3IR/8EdlzdD+mFPoXtjl9dAiEAhvHMefijy34Ya268zOGArdNz
|
|
22
|
+
5msfSDH5QT27scqencw=
|
|
23
|
+
-----END CERTIFICATE-----
|
|
24
|
+
"""
|
|
@@ -17,6 +17,7 @@ import pytest
|
|
|
17
17
|
|
|
18
18
|
import PyPtt
|
|
19
19
|
from tests import config
|
|
20
|
+
from tests import util
|
|
20
21
|
|
|
21
22
|
|
|
22
23
|
def test_bucket_nonexistent_user_raises(ptt_bots):
|
|
@@ -38,7 +39,7 @@ def test_bucket_on_moderated_board(ptt_bots):
|
|
|
38
39
|
if not config.MOD_BOARD:
|
|
39
40
|
pytest.skip('MOD_BOARD env var not set')
|
|
40
41
|
for ptt_bot in ptt_bots:
|
|
41
|
-
if ptt_bot
|
|
42
|
+
if not util.is_primary_host(ptt_bot, ptt_bots):
|
|
42
43
|
continue
|
|
43
44
|
try:
|
|
44
45
|
ptt_bot.bucket(
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
import PyPtt
|
|
7
|
+
from tests import config
|
|
8
|
+
from tests import util
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_del_own_post(ptt_bots):
|
|
12
|
+
"""Tests that a user can delete their own post."""
|
|
13
|
+
for ptt_bot in ptt_bots:
|
|
14
|
+
# 1. Post an article to delete
|
|
15
|
+
post_title = f"PyPtt Delete Test Post {int(time.time())}"
|
|
16
|
+
post_content = "This is a test post for deletion."
|
|
17
|
+
ptt_bot.post(
|
|
18
|
+
board='Test',
|
|
19
|
+
title_index=1,
|
|
20
|
+
title=post_title,
|
|
21
|
+
content=post_content
|
|
22
|
+
)
|
|
23
|
+
time.sleep(1) # Allow time for post to be indexed
|
|
24
|
+
|
|
25
|
+
newest_index = ptt_bot.get_newest_index(PyPtt.NewIndex.BOARD, board='Test')
|
|
26
|
+
|
|
27
|
+
# 2. Verify we found our post
|
|
28
|
+
for i in range(5):
|
|
29
|
+
if newest_index - i <= 0:
|
|
30
|
+
continue
|
|
31
|
+
post_data = ptt_bot.get_post('Test', index=newest_index - i)
|
|
32
|
+
|
|
33
|
+
if post_data['author'].startswith(ptt_bot.ptt_id):
|
|
34
|
+
break
|
|
35
|
+
|
|
36
|
+
assert post_data['author'].startswith(ptt_bot.ptt_id)
|
|
37
|
+
assert post_data['title'] == f"[測試] {post_title}"
|
|
38
|
+
|
|
39
|
+
# 3. Delete the post
|
|
40
|
+
|
|
41
|
+
for i in range(5): # Retry up to 3 times in case of transient issues
|
|
42
|
+
if newest_index - i <= 0:
|
|
43
|
+
continue
|
|
44
|
+
try:
|
|
45
|
+
ptt_bot.del_post(board='Test', index=newest_index - i)
|
|
46
|
+
except PyPtt.exceptions.NoPermission:
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
for i in range(5):
|
|
50
|
+
if newest_index - i <= 0:
|
|
51
|
+
continue
|
|
52
|
+
try:
|
|
53
|
+
deleted_post_data = ptt_bot.get_post('Test', index=newest_index - i)
|
|
54
|
+
except PyPtt.exceptions.ParameterError:
|
|
55
|
+
# On a small local board, deleting every remaining post can
|
|
56
|
+
# shrink the board's live index range out from under this
|
|
57
|
+
# stale `newest_index`; an out-of-range index unambiguously
|
|
58
|
+
# means the post is gone, which is what this loop checks for.
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
print(json.dumps(deleted_post_data, ensure_ascii=False, indent=2))
|
|
62
|
+
|
|
63
|
+
if deleted_post_data[PyPtt.PostField.post_status] in [
|
|
64
|
+
PyPtt.PostStatus.DELETED_BY_AUTHOR,
|
|
65
|
+
PyPtt.PostStatus.DELETED_BY_MODERATOR,
|
|
66
|
+
PyPtt.PostStatus.DELETED_BY_UNKNOWN
|
|
67
|
+
]:
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
if deleted_post_data[PyPtt.PostField.author].split(' ')[0] != post_data[PyPtt.PostField.author].split(' ')[
|
|
71
|
+
0]:
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
# author is myself but not deleted
|
|
75
|
+
assert False
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_del_other_post_permission_error(ptt_bots):
|
|
80
|
+
"""Tests that deleting another user's post raises NoPermission."""
|
|
81
|
+
for ptt_bot in ptt_bots:
|
|
82
|
+
# Find a post on a public board not authored by the bot
|
|
83
|
+
board = 'SYSOP'
|
|
84
|
+
newest_index = ptt_bot.get_newest_index(PyPtt.NewIndex.BOARD, board=board)
|
|
85
|
+
|
|
86
|
+
target_index = -1
|
|
87
|
+
for i in range(10): # Check the last 10 posts
|
|
88
|
+
index_to_check = newest_index - i
|
|
89
|
+
if index_to_check <= 0:
|
|
90
|
+
continue
|
|
91
|
+
try:
|
|
92
|
+
post = ptt_bot.get_post(board, index=index_to_check, query=True)
|
|
93
|
+
if post[PyPtt.PostField.post_status] == PyPtt.PostStatus.EXISTS and not post['author'].startswith(
|
|
94
|
+
ptt_bot.ptt_id):
|
|
95
|
+
target_index = index_to_check
|
|
96
|
+
break
|
|
97
|
+
except PyPtt.NoSuchPost:
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
if target_index == -1:
|
|
101
|
+
pytest.skip(f"Could not find a recent post by another user on {board} for host {ptt_bot.host}")
|
|
102
|
+
|
|
103
|
+
# 5. Try to delete it and expect a permission error
|
|
104
|
+
with pytest.raises(PyPtt.NoPermission):
|
|
105
|
+
ptt_bot.del_post(board=board, index=target_index)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_del_own_post_with_reason_raises(ptt_bots):
|
|
109
|
+
"""Passing a reason for your own post is rejected: the annotation only
|
|
110
|
+
applies when a moderator deletes another user's post."""
|
|
111
|
+
for ptt_bot in ptt_bots:
|
|
112
|
+
post_title = f"PyPtt Reason Reject Test {int(time.time())}"
|
|
113
|
+
ptt_bot.post(board='Test', title_index=1, title=post_title,
|
|
114
|
+
content="This is a test post for deletion.")
|
|
115
|
+
time.sleep(1)
|
|
116
|
+
|
|
117
|
+
newest_index = ptt_bot.get_newest_index(PyPtt.NewIndex.BOARD, board='Test')
|
|
118
|
+
index = -1
|
|
119
|
+
for i in range(5):
|
|
120
|
+
if newest_index - i <= 0:
|
|
121
|
+
continue
|
|
122
|
+
post_data = ptt_bot.get_post('Test', index=newest_index - i)
|
|
123
|
+
if post_data['author'].startswith(ptt_bot.ptt_id) and \
|
|
124
|
+
post_data['title'] == f"[測試] {post_title}":
|
|
125
|
+
index = newest_index - i
|
|
126
|
+
break
|
|
127
|
+
assert index != -1
|
|
128
|
+
|
|
129
|
+
# reason on your own post must raise, and must NOT delete it
|
|
130
|
+
with pytest.raises(PyPtt.exceptions.ParameterError):
|
|
131
|
+
ptt_bot.del_post(board='Test', index=index, reason='測試理由')
|
|
132
|
+
|
|
133
|
+
# clean up: a plain delete still works
|
|
134
|
+
ptt_bot.del_post(board='Test', index=index)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_del_other_post_with_reason_as_moderator(ptt_bots):
|
|
138
|
+
"""Tests that a board moderator can delete another user's post and
|
|
139
|
+
annotate a reason on the resulting title."""
|
|
140
|
+
if not config.MOD_BOARD:
|
|
141
|
+
pytest.skip('MOD_BOARD env var not set')
|
|
142
|
+
for ptt_bot in ptt_bots:
|
|
143
|
+
if not util.is_primary_host(ptt_bot, ptt_bots):
|
|
144
|
+
continue # MOD_BOARD is moderated by the primary bot
|
|
145
|
+
|
|
146
|
+
if ptt_bot.host == PyPtt.HOST.LOCALHOST:
|
|
147
|
+
pytest.skip('local imageptt hard-deletes and reindexes moderator '
|
|
148
|
+
'posts, leaving no DELETED_BY_MODERATOR tombstone to read back')
|
|
149
|
+
|
|
150
|
+
board = config.MOD_BOARD
|
|
151
|
+
newest_index = ptt_bot.get_newest_index(PyPtt.NewIndex.BOARD, board=board)
|
|
152
|
+
|
|
153
|
+
# Find a recent existing post on MOD_BOARD not authored by the bot.
|
|
154
|
+
target_index = -1
|
|
155
|
+
for i in range(10):
|
|
156
|
+
index_to_check = newest_index - i
|
|
157
|
+
try:
|
|
158
|
+
post = ptt_bot.get_post(board, index=index_to_check, query=True)
|
|
159
|
+
if post[PyPtt.PostField.post_status] == PyPtt.PostStatus.EXISTS and not post['author'].startswith(
|
|
160
|
+
ptt_bot.ptt_id):
|
|
161
|
+
target_index = index_to_check
|
|
162
|
+
break
|
|
163
|
+
except PyPtt.NoSuchPost:
|
|
164
|
+
continue
|
|
165
|
+
|
|
166
|
+
if target_index == -1:
|
|
167
|
+
pytest.skip(f"Could not find a recent post by another user on {board} for host {ptt_bot.host}")
|
|
168
|
+
|
|
169
|
+
ptt_bot.del_post(board=board, index=target_index, reason='PyPtt 自動測試刪文理由')
|
|
170
|
+
|
|
171
|
+
deleted_post = ptt_bot.get_post(board, index=target_index, query=True)
|
|
172
|
+
assert deleted_post[PyPtt.PostField.post_status] == PyPtt.PostStatus.DELETED_BY_MODERATOR
|
|
@@ -20,6 +20,15 @@ PTT2_BOARD_CASES = [
|
|
|
20
20
|
('PttSuggest', PyPtt.SearchType.KEYWORD, '[問題]'),
|
|
21
21
|
]
|
|
22
22
|
|
|
23
|
+
# The PTT1/PTT2 cases above search real, host-specific historical posts and
|
|
24
|
+
# boards that don't exist on a local imageptt container. `Python` is seeded
|
|
25
|
+
# with at least one post by scripts/bootstrap_local_pttbbs.py and — unlike
|
|
26
|
+
# `Test` — no other test in the suite deletes from it, so its post count
|
|
27
|
+
# only ever grows across a full-suite run.
|
|
28
|
+
LOCALHOST_BOARD_CASES = [
|
|
29
|
+
('Python', None, None),
|
|
30
|
+
]
|
|
31
|
+
|
|
23
32
|
def test_get_mail_index(ptt_bots):
|
|
24
33
|
"""Tests getting the newest mail index."""
|
|
25
34
|
for ptt_bot in ptt_bots:
|
|
@@ -30,7 +39,9 @@ def test_get_mail_index(ptt_bots):
|
|
|
30
39
|
def test_get_board_index(ptt_bots):
|
|
31
40
|
"""Tests getting the newest board index on PTT2 with various search parameters."""
|
|
32
41
|
for ptt_bot in ptt_bots:
|
|
33
|
-
if ptt_bot.config.host == PyPtt.HOST.
|
|
42
|
+
if ptt_bot.config.host == PyPtt.HOST.LOCALHOST:
|
|
43
|
+
test_params = LOCALHOST_BOARD_CASES
|
|
44
|
+
elif ptt_bot.config.host == PyPtt.HOST.PTT1:
|
|
34
45
|
test_params = PTT1_BOARD_CASES
|
|
35
46
|
else:
|
|
36
47
|
test_params = PTT2_BOARD_CASES
|
|
@@ -7,9 +7,16 @@ try:
|
|
|
7
7
|
except KeyError:
|
|
8
8
|
import config
|
|
9
9
|
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
# ponytail: PTT_HOST=LOCALHOST points at a local imageptt docker container
|
|
13
|
+
# instead of the real PTT1 host; unset keeps the status quo.
|
|
14
|
+
_use_localhost = os.environ.get('PTT_HOST') == 'LOCALHOST'
|
|
15
|
+
|
|
10
16
|
|
|
11
17
|
def test_get_waterball():
|
|
12
18
|
ptt1_bot = PyPtt.API(
|
|
19
|
+
host=PyPtt.HOST.LOCALHOST if _use_localhost else PyPtt.HOST.PTT1,
|
|
13
20
|
)
|
|
14
21
|
ptt1_bot.login(ptt_id=config.PTT1_ID, ptt_pw=config.PTT1_PW, kick_other_session=True)
|
|
15
22
|
|
|
@@ -12,6 +12,11 @@ def test_give_money(ptt_bots):
|
|
|
12
12
|
# PTT2 test host does not support giving money.
|
|
13
13
|
if ptt_bot.host == PyPtt.HOST.PTT2:
|
|
14
14
|
continue
|
|
15
|
+
# Can't give money to yourself — under LOCALHOST both bots share the
|
|
16
|
+
# same .host value, so the PTT2 skip above doesn't disambiguate them,
|
|
17
|
+
# and TEST_USER may legitimately be the PTT2 account's own ptt_id.
|
|
18
|
+
if ptt_bot.ptt_id.lower() == recipient_id.lower():
|
|
19
|
+
continue
|
|
15
20
|
|
|
16
21
|
try:
|
|
17
22
|
# This call should not raise an exception on success
|
|
@@ -13,6 +13,7 @@ import pytest
|
|
|
13
13
|
import PyPtt
|
|
14
14
|
from PyPtt import PostField, NewIndex
|
|
15
15
|
from tests import config
|
|
16
|
+
from tests import util
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
def _post_and_get_aid(ptt_bot, board: str) -> str:
|
|
@@ -39,8 +40,8 @@ def test_mark_post_on_moderated_board(ptt_bots):
|
|
|
39
40
|
if not config.MOD_BOARD:
|
|
40
41
|
pytest.skip('MOD_BOARD env var not set')
|
|
41
42
|
for ptt_bot in ptt_bots:
|
|
42
|
-
if ptt_bot
|
|
43
|
-
continue # MOD_BOARD is
|
|
43
|
+
if not util.is_primary_host(ptt_bot, ptt_bots):
|
|
44
|
+
continue # MOD_BOARD is moderated by the primary bot
|
|
44
45
|
aid = _post_and_get_aid(ptt_bot, board=config.MOD_BOARD)
|
|
45
46
|
try:
|
|
46
47
|
ptt_bot.mark_post(
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
1
3
|
import pytest
|
|
2
4
|
import PyPtt
|
|
3
5
|
import threading
|
|
@@ -5,6 +7,10 @@ import time
|
|
|
5
7
|
from PyPtt import Service, PostField
|
|
6
8
|
from tests import config # Assuming config.py has PTT1_ID and PTT1_PW
|
|
7
9
|
|
|
10
|
+
# ponytail: PTT_HOST=LOCALHOST points at a local imageptt docker container
|
|
11
|
+
# instead of the real PTT1 host; unset keeps the status quo.
|
|
12
|
+
_use_localhost = os.environ.get('PTT_HOST') == 'LOCALHOST'
|
|
13
|
+
|
|
8
14
|
class TestCallInterval:
|
|
9
15
|
"""Tests for call_interval feature (no network required)."""
|
|
10
16
|
|
|
@@ -56,7 +62,7 @@ class TestCallInterval:
|
|
|
56
62
|
def service_instance():
|
|
57
63
|
import time
|
|
58
64
|
|
|
59
|
-
pyptt_init_config = {}
|
|
65
|
+
pyptt_init_config = {'host': PyPtt.HOST.LOCALHOST} if _use_localhost else {}
|
|
60
66
|
service = Service(pyptt_init_config)
|
|
61
67
|
|
|
62
68
|
max_retries = 3
|
|
@@ -16,6 +16,7 @@ import pytest
|
|
|
16
16
|
|
|
17
17
|
import PyPtt
|
|
18
18
|
from tests import config
|
|
19
|
+
from tests import util
|
|
19
20
|
|
|
20
21
|
|
|
21
22
|
def test_a_set_board_title_without_moderator_perm_raises(ptt_bots):
|
|
@@ -36,7 +37,7 @@ def test_b_set_board_title_on_moderated_board(ptt_bots):
|
|
|
36
37
|
if not config.MOD_BOARD:
|
|
37
38
|
pytest.skip('MOD_BOARD env var not set')
|
|
38
39
|
for ptt_bot in ptt_bots:
|
|
39
|
-
if ptt_bot
|
|
40
|
+
if not util.is_primary_host(ptt_bot, ptt_bots):
|
|
40
41
|
continue
|
|
41
42
|
info = ptt_bot.get_board_info(board=config.MOD_BOARD)
|
|
42
43
|
original = info[PyPtt.BoardField.mandarin_des]
|
pyptt-2.1.2/PyPtt/ssl_config.py
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
key = """-----BEGIN EC PARAMETERS-----
|
|
2
|
-
BggqhkjOPQMBBw==
|
|
3
|
-
-----END EC PARAMETERS-----
|
|
4
|
-
-----BEGIN EC PRIVATE KEY-----
|
|
5
|
-
MHcCAQEEIFxFRO1jNtGmuhLNAdh+bbaPqEl8jXy2OnIDJ7xcYpKQoAoGCCqGSM49
|
|
6
|
-
AwEHoUQDQgAEWh3ZP/+oUkac+bXn6m1DpFOQ8ZxJO6R1AurOgK8Q+Dwr0mS/0+nO
|
|
7
|
-
wwE63McobMsFUiGM3t3YdzfKnlTAECNlbw==
|
|
8
|
-
-----END EC PRIVATE KEY-----
|
|
9
|
-
"""
|
|
10
|
-
|
|
11
|
-
cert = """-----BEGIN CERTIFICATE-----
|
|
12
|
-
MIIB6TCCAY8CFB0+cdUrN+Ovk+BQN8KWG1vDyrW5MAoGCCqGSM49BAMCMHcxCzAJ
|
|
13
|
-
BgNVBAYTAlRXMQ4wDAYDVQQIDAVTdGF0ZTENMAsGA1UEBwwEQ2l0eTEVMBMGA1UE
|
|
14
|
-
CgwMT3JnYW5pemF0aW9uMRwwGgYDVQQLDBNPcmdhbml6YXRpb25hbCBVbml0MRQw
|
|
15
|
-
EgYDVQQDDAtDb21tb24gTmFtZTAeFw0yNjA1MjUxNDI4MzdaFw0zNjA1MjIxNDI4
|
|
16
|
-
MzdaMHcxCzAJBgNVBAYTAlRXMQ4wDAYDVQQIDAVTdGF0ZTENMAsGA1UEBwwEQ2l0
|
|
17
|
-
eTEVMBMGA1UECgwMT3JnYW5pemF0aW9uMRwwGgYDVQQLDBNPcmdhbml6YXRpb25h
|
|
18
|
-
bCBVbml0MRQwEgYDVQQDDAtDb21tb24gTmFtZTBZMBMGByqGSM49AgEGCCqGSM49
|
|
19
|
-
AwEHA0IABFod2T//qFJGnPm15+ptQ6RTkPGcSTukdQLqzoCvEPg8K9Jkv9PpzsMB
|
|
20
|
-
OtzHKGzLBVIhjN7d2Hc3yp5UwBAjZW8wCgYIKoZIzj0EAwIDSAAwRQIhAMQMHvf4
|
|
21
|
-
CIY36vOEAMrop9Z0RNEGTPF+EQjj95BZNsayAiBxUKgbC3Sdw34xKfUSdO/K3fP7
|
|
22
|
-
ou/8qOK+MUBXLV73zw==
|
|
23
|
-
-----END CERTIFICATE-----
|
|
24
|
-
"""
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
import json
|
|
2
|
-
import time
|
|
3
|
-
|
|
4
|
-
import pytest
|
|
5
|
-
|
|
6
|
-
import PyPtt
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def test_del_own_post(ptt_bots):
|
|
10
|
-
"""Tests that a user can delete their own post."""
|
|
11
|
-
for ptt_bot in ptt_bots:
|
|
12
|
-
# 1. Post an article to delete
|
|
13
|
-
post_title = f"PyPtt Delete Test Post {int(time.time())}"
|
|
14
|
-
post_content = "This is a test post for deletion."
|
|
15
|
-
ptt_bot.post(
|
|
16
|
-
board='Test',
|
|
17
|
-
title_index=1,
|
|
18
|
-
title=post_title,
|
|
19
|
-
content=post_content
|
|
20
|
-
)
|
|
21
|
-
time.sleep(1) # Allow time for post to be indexed
|
|
22
|
-
|
|
23
|
-
newest_index = ptt_bot.get_newest_index(PyPtt.NewIndex.BOARD, board='Test')
|
|
24
|
-
|
|
25
|
-
# 2. Verify we found our post
|
|
26
|
-
for i in range(5):
|
|
27
|
-
post_data = ptt_bot.get_post('Test', index=newest_index - i)
|
|
28
|
-
|
|
29
|
-
if post_data['author'].startswith(ptt_bot.ptt_id):
|
|
30
|
-
break
|
|
31
|
-
|
|
32
|
-
assert post_data['author'].startswith(ptt_bot.ptt_id)
|
|
33
|
-
assert post_data['title'] == f"[測試] {post_title}"
|
|
34
|
-
|
|
35
|
-
# 3. Delete the post
|
|
36
|
-
|
|
37
|
-
for i in range(5): # Retry up to 3 times in case of transient issues
|
|
38
|
-
try:
|
|
39
|
-
ptt_bot.del_post(board='Test', index=newest_index - i)
|
|
40
|
-
except PyPtt.exceptions.NoPermission:
|
|
41
|
-
pass
|
|
42
|
-
|
|
43
|
-
for i in range(5):
|
|
44
|
-
deleted_post_data = ptt_bot.get_post('Test', index=newest_index - i)
|
|
45
|
-
|
|
46
|
-
print(json.dumps(deleted_post_data, ensure_ascii=False, indent=2))
|
|
47
|
-
|
|
48
|
-
if deleted_post_data[PyPtt.PostField.post_status] in [
|
|
49
|
-
PyPtt.PostStatus.DELETED_BY_AUTHOR,
|
|
50
|
-
PyPtt.PostStatus.DELETED_BY_MODERATOR,
|
|
51
|
-
PyPtt.PostStatus.DELETED_BY_UNKNOWN
|
|
52
|
-
]:
|
|
53
|
-
continue
|
|
54
|
-
|
|
55
|
-
if deleted_post_data[PyPtt.PostField.author].split(' ')[0] != post_data[PyPtt.PostField.author].split(' ')[
|
|
56
|
-
0]:
|
|
57
|
-
continue
|
|
58
|
-
|
|
59
|
-
# author is myself but not deleted
|
|
60
|
-
assert False
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def test_del_other_post_permission_error(ptt_bots):
|
|
65
|
-
"""Tests that deleting another user's post raises NoPermission."""
|
|
66
|
-
for ptt_bot in ptt_bots:
|
|
67
|
-
# Find a post on a public board not authored by the bot
|
|
68
|
-
board = 'SYSOP'
|
|
69
|
-
newest_index = ptt_bot.get_newest_index(PyPtt.NewIndex.BOARD, board=board)
|
|
70
|
-
|
|
71
|
-
target_index = -1
|
|
72
|
-
for i in range(10): # Check the last 10 posts
|
|
73
|
-
index_to_check = newest_index - i
|
|
74
|
-
try:
|
|
75
|
-
post = ptt_bot.get_post(board, index=index_to_check, query=True)
|
|
76
|
-
if post[PyPtt.PostField.post_status] == PyPtt.PostStatus.EXISTS and not post['author'].startswith(
|
|
77
|
-
ptt_bot.ptt_id):
|
|
78
|
-
target_index = index_to_check
|
|
79
|
-
break
|
|
80
|
-
except PyPtt.NoSuchPost:
|
|
81
|
-
continue
|
|
82
|
-
|
|
83
|
-
if target_index == -1:
|
|
84
|
-
pytest.skip(f"Could not find a recent post by another user on {board} for host {ptt_bot.host}")
|
|
85
|
-
|
|
86
|
-
# 5. Try to delete it and expect a permission error
|
|
87
|
-
with pytest.raises(PyPtt.NoPermission):
|
|
88
|
-
ptt_bot.del_post(board=board, index=target_index)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|