annofabcli 1.102.0__py3-none-any.whl → 1.103.0__py3-none-any.whl
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.
- annofabcli/annotation/annotation_query.py +9 -29
- annofabcli/annotation/change_annotation_attributes.py +6 -14
- annofabcli/annotation/change_annotation_properties.py +5 -12
- annofabcli/annotation/copy_annotation.py +9 -11
- annofabcli/annotation/delete_annotation.py +21 -26
- annofabcli/annotation/dump_annotation.py +1 -4
- annofabcli/annotation/import_annotation.py +16 -40
- annofabcli/annotation/list_annotation.py +1 -4
- annofabcli/annotation/merge_segmentation.py +10 -16
- annofabcli/annotation/remove_segmentation_overlap.py +14 -30
- annofabcli/annotation/restore_annotation.py +3 -9
- annofabcli/annotation_specs/add_attribute_restriction.py +2 -8
- annofabcli/annotation_specs/attribute_restriction.py +2 -10
- annofabcli/annotation_specs/export_annotation_specs.py +1 -3
- annofabcli/annotation_specs/get_annotation_specs_with_attribute_id_replaced.py +3 -10
- annofabcli/annotation_specs/get_annotation_specs_with_choice_id_replaced.py +4 -10
- annofabcli/annotation_specs/get_annotation_specs_with_label_id_replaced.py +1 -3
- annofabcli/annotation_specs/list_annotation_specs_attribute.py +7 -18
- annofabcli/annotation_specs/list_annotation_specs_choice.py +3 -8
- annofabcli/annotation_specs/list_annotation_specs_history.py +0 -1
- annofabcli/annotation_specs/list_annotation_specs_label.py +3 -8
- annofabcli/annotation_specs/list_annotation_specs_label_attribute.py +4 -9
- annofabcli/annotation_specs/list_attribute_restriction.py +3 -9
- annofabcli/annotation_specs/put_label_color.py +1 -6
- annofabcli/comment/delete_comment.py +3 -9
- annofabcli/comment/list_all_comment.py +2 -4
- annofabcli/comment/list_comment.py +1 -4
- annofabcli/comment/put_comment.py +4 -13
- annofabcli/comment/put_comment_simply.py +2 -6
- annofabcli/comment/put_inspection_comment.py +2 -6
- annofabcli/comment/put_inspection_comment_simply.py +3 -6
- annofabcli/comment/put_onhold_comment.py +2 -6
- annofabcli/comment/put_onhold_comment_simply.py +2 -4
- annofabcli/common/cli.py +5 -43
- annofabcli/common/download.py +8 -25
- annofabcli/common/image.py +5 -9
- annofabcli/common/utils.py +1 -3
- annofabcli/common/visualize.py +2 -4
- annofabcli/filesystem/draw_annotation.py +8 -20
- annofabcli/filesystem/filter_annotation.py +7 -24
- annofabcli/filesystem/mask_user_info.py +3 -6
- annofabcli/filesystem/merge_annotation.py +2 -6
- annofabcli/input_data/change_input_data_name.py +3 -7
- annofabcli/input_data/copy_input_data.py +6 -14
- annofabcli/input_data/delete_input_data.py +7 -24
- annofabcli/input_data/delete_metadata_key_of_input_data.py +5 -16
- annofabcli/input_data/list_all_input_data.py +5 -14
- annofabcli/input_data/list_all_input_data_merged_task.py +8 -23
- annofabcli/input_data/list_input_data.py +5 -16
- annofabcli/input_data/put_input_data.py +7 -19
- annofabcli/input_data/update_metadata_of_input_data.py +6 -14
- annofabcli/instruction/list_instruction_history.py +0 -1
- annofabcli/instruction/upload_instruction.py +1 -4
- annofabcli/job/list_job.py +1 -2
- annofabcli/job/list_last_job.py +1 -3
- annofabcli/organization/list_organization.py +0 -1
- annofabcli/organization_member/change_organization_member.py +1 -3
- annofabcli/organization_member/delete_organization_member.py +32 -16
- annofabcli/organization_member/invite_organization_member.py +25 -14
- annofabcli/organization_member/list_organization_member.py +0 -1
- annofabcli/project/change_organization_of_project.py +257 -0
- annofabcli/project/change_project_status.py +2 -2
- annofabcli/project/copy_project.py +2 -7
- annofabcli/project/diff_projects.py +4 -16
- annofabcli/project/list_project.py +0 -1
- annofabcli/project/put_project.py +2 -6
- annofabcli/project/subcommand_project.py +2 -0
- annofabcli/project_member/change_project_members.py +2 -2
- annofabcli/project_member/copy_project_members.py +2 -7
- annofabcli/project_member/drop_project_members.py +1 -3
- annofabcli/project_member/invite_project_members.py +1 -3
- annofabcli/project_member/list_users.py +0 -1
- annofabcli/project_member/put_project_members.py +4 -12
- annofabcli/stat_visualization/mask_visualization_dir.py +6 -16
- annofabcli/stat_visualization/merge_visualization_dir.py +6 -18
- annofabcli/stat_visualization/summarize_whole_performance_csv.py +3 -7
- annofabcli/stat_visualization/write_graph.py +5 -15
- annofabcli/stat_visualization/write_performance_rating_csv.py +4 -12
- annofabcli/statistics/list_annotation_area.py +3 -7
- annofabcli/statistics/list_annotation_attribute.py +6 -15
- annofabcli/statistics/list_annotation_attribute_filled_count.py +9 -23
- annofabcli/statistics/list_annotation_count.py +18 -44
- annofabcli/statistics/list_annotation_duration.py +14 -40
- annofabcli/statistics/list_video_duration.py +2 -3
- annofabcli/statistics/list_worktime.py +0 -1
- annofabcli/statistics/scatter.py +3 -9
- annofabcli/statistics/summarize_task_count.py +7 -12
- annofabcli/statistics/summarize_task_count_by_task_id_group.py +3 -11
- annofabcli/statistics/summarize_task_count_by_user.py +1 -5
- annofabcli/statistics/visualization/dataframe/annotation_count.py +1 -3
- annofabcli/statistics/visualization/dataframe/cumulative_productivity.py +3 -9
- annofabcli/statistics/visualization/dataframe/productivity_per_date.py +11 -23
- annofabcli/statistics/visualization/dataframe/project_performance.py +1 -3
- annofabcli/statistics/visualization/dataframe/task.py +2 -5
- annofabcli/statistics/visualization/dataframe/task_worktime_by_phase_user.py +6 -20
- annofabcli/statistics/visualization/dataframe/user_performance.py +29 -88
- annofabcli/statistics/visualization/dataframe/whole_performance.py +4 -10
- annofabcli/statistics/visualization/dataframe/whole_productivity_per_date.py +17 -49
- annofabcli/statistics/visualization/dataframe/worktime_per_date.py +3 -9
- annofabcli/statistics/visualization/filtering_query.py +2 -6
- annofabcli/statistics/visualization/project_dir.py +9 -26
- annofabcli/statistics/visualization/visualization_source_files.py +3 -10
- annofabcli/statistics/visualize_annotation_count.py +7 -21
- annofabcli/statistics/visualize_annotation_duration.py +7 -17
- annofabcli/statistics/visualize_statistics.py +17 -52
- annofabcli/statistics/visualize_video_duration.py +8 -19
- annofabcli/supplementary/delete_supplementary_data.py +7 -23
- annofabcli/supplementary/list_supplementary_data.py +1 -1
- annofabcli/supplementary/put_supplementary_data.py +5 -15
- annofabcli/task/cancel_acceptance.py +3 -4
- annofabcli/task/change_operator.py +3 -11
- annofabcli/task/change_status_to_break.py +1 -1
- annofabcli/task/change_status_to_on_hold.py +5 -18
- annofabcli/task/complete_tasks.py +8 -25
- annofabcli/task/copy_tasks.py +2 -3
- annofabcli/task/delete_metadata_key_of_task.py +2 -6
- annofabcli/task/delete_tasks.py +7 -25
- annofabcli/task/list_all_tasks.py +2 -4
- annofabcli/task/list_tasks.py +2 -6
- annofabcli/task/list_tasks_added_task_history.py +7 -21
- annofabcli/task/put_tasks.py +2 -3
- annofabcli/task/put_tasks_by_count.py +3 -7
- annofabcli/task/reject_tasks.py +7 -19
- annofabcli/task/update_metadata_of_task.py +1 -1
- annofabcli/task_history/list_all_task_history.py +2 -5
- annofabcli/task_history/list_task_history.py +0 -1
- annofabcli/task_history_event/list_all_task_history_event.py +4 -11
- annofabcli/task_history_event/list_worktime.py +4 -14
- {annofabcli-1.102.0.dist-info → annofabcli-1.103.0.dist-info}/METADATA +1 -1
- annofabcli-1.103.0.dist-info/RECORD +215 -0
- annofabcli-1.102.0.dist-info/RECORD +0 -214
- {annofabcli-1.102.0.dist-info → annofabcli-1.103.0.dist-info}/WHEEL +0 -0
- {annofabcli-1.102.0.dist-info → annofabcli-1.103.0.dist-info}/entry_points.txt +0 -0
- {annofabcli-1.102.0.dist-info → annofabcli-1.103.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -67,10 +67,7 @@ class UpdateMetadataMain(CommandLineWithConfirm):
|
|
|
67
67
|
logger.warning(f"{logging_prefix} 入力データは存在しないのでスキップします。 :: input_data_id='{input_data_id}'")
|
|
68
68
|
return False
|
|
69
69
|
|
|
70
|
-
logger.debug(
|
|
71
|
-
f"{logging_prefix} input_data_id='{input_data['input_data_id']}', "
|
|
72
|
-
f"input_data_name='{input_data['input_data_name']}', metadata='{json.dumps(input_data['metadata'])}'"
|
|
73
|
-
)
|
|
70
|
+
logger.debug(f"{logging_prefix} input_data_id='{input_data['input_data_id']}', input_data_name='{input_data['input_data_name']}', metadata='{json.dumps(input_data['metadata'])}'")
|
|
74
71
|
if not self.confirm_processing(get_confirm_message()):
|
|
75
72
|
return False
|
|
76
73
|
|
|
@@ -84,9 +81,7 @@ class UpdateMetadataMain(CommandLineWithConfirm):
|
|
|
84
81
|
logger.debug(f"{logging_prefix} 入力データのメタデータを更新しました。input_data_id='{input_data['input_data_id']}'")
|
|
85
82
|
return True
|
|
86
83
|
|
|
87
|
-
def set_metadata_to_input_data_wrapper(
|
|
88
|
-
self, tpl: tuple[int, InputDataMetadataInfo], project_id: str, *, overwrite_metadata: bool = False
|
|
89
|
-
) -> bool:
|
|
84
|
+
def set_metadata_to_input_data_wrapper(self, tpl: tuple[int, InputDataMetadataInfo], project_id: str, *, overwrite_metadata: bool = False) -> bool:
|
|
90
85
|
input_data_index, info = tpl
|
|
91
86
|
return self.set_metadata_to_input_data(
|
|
92
87
|
project_id,
|
|
@@ -202,9 +197,7 @@ class UpdateMetadata(CommandLine):
|
|
|
202
197
|
sys.exit(COMMAND_LINE_ERROR_STATUS_CODE)
|
|
203
198
|
|
|
204
199
|
if input_data_id_list is not None:
|
|
205
|
-
metadata_by_input_data_id = {
|
|
206
|
-
input_data_id: metadata for input_data_id, metadata in metadata_by_input_data_id.items() if input_data_id in input_data_id_list
|
|
207
|
-
}
|
|
200
|
+
metadata_by_input_data_id = {input_data_id: metadata for input_data_id, metadata in metadata_by_input_data_id.items() if input_data_id in input_data_id_list}
|
|
208
201
|
else:
|
|
209
202
|
raise RuntimeError("'--metadata'か'--metadata_by_input_data_id'のどちらかを指定する必要があります。")
|
|
210
203
|
|
|
@@ -233,8 +226,7 @@ def parse_args(parser: argparse.ArgumentParser) -> None:
|
|
|
233
226
|
metadata_group_parser.add_argument(
|
|
234
227
|
"--metadata",
|
|
235
228
|
type=str,
|
|
236
|
-
help="入力データに設定する ``metadata`` をJSON形式で指定してください。メタデータの値は文字列です。"
|
|
237
|
-
" ``file://`` を先頭に付けると、JSON形式のファイルを指定できます。",
|
|
229
|
+
help="入力データに設定する ``metadata`` をJSON形式で指定してください。メタデータの値は文字列です。 ``file://`` を先頭に付けると、JSON形式のファイルを指定できます。",
|
|
238
230
|
)
|
|
239
231
|
|
|
240
232
|
sample_metadata_by_input_data_id = {"input_data1": {"country": "japan"}}
|
|
@@ -251,14 +243,14 @@ def parse_args(parser: argparse.ArgumentParser) -> None:
|
|
|
251
243
|
parser.add_argument(
|
|
252
244
|
"--overwrite",
|
|
253
245
|
action="store_true",
|
|
254
|
-
help="指定した場合、メタデータを上書きして更新します(すでに設定されているメタデータは削除されます)。指定しない場合、 ``--metadata`` に指定されたキーのみ更新されます。",
|
|
246
|
+
help="指定した場合、メタデータを上書きして更新します(すでに設定されているメタデータは削除されます)。指定しない場合、 ``--metadata`` に指定されたキーのみ更新されます。",
|
|
255
247
|
)
|
|
256
248
|
|
|
257
249
|
parser.add_argument(
|
|
258
250
|
"--parallelism",
|
|
259
251
|
type=int,
|
|
260
252
|
choices=PARALLELISM_CHOICES,
|
|
261
|
-
help="使用するプロセス数(並列度)を指定してください。指定する場合は必ず ``--yes`` を指定してください。指定しない場合は、逐次的に処理します。",
|
|
253
|
+
help="使用するプロセス数(並列度)を指定してください。指定する場合は必ず ``--yes`` を指定してください。指定しない場合は、逐次的に処理します。",
|
|
262
254
|
)
|
|
263
255
|
|
|
264
256
|
parser.set_defaults(subcommand_func=main)
|
|
@@ -41,7 +41,6 @@ def parse_args(parser: argparse.ArgumentParser) -> None:
|
|
|
41
41
|
argument_parser.add_output()
|
|
42
42
|
|
|
43
43
|
argument_parser.add_format(choices=[FormatArgument.CSV, FormatArgument.JSON, FormatArgument.PRETTY_JSON], default=FormatArgument.CSV)
|
|
44
|
-
argument_parser.add_csv_format()
|
|
45
44
|
|
|
46
45
|
parser.set_defaults(subcommand_func=main)
|
|
47
46
|
|
|
@@ -147,10 +147,7 @@ def parse_args(parser: argparse.ArgumentParser) -> None:
|
|
|
147
147
|
def add_parser(subparsers: Optional[argparse._SubParsersAction] = None) -> argparse.ArgumentParser:
|
|
148
148
|
subcommand_name = "upload"
|
|
149
149
|
subcommand_help = "HTMLファイルを作業ガイドとして登録します。"
|
|
150
|
-
description =
|
|
151
|
-
"HTMLファイルを作業ガイドとして登録します。"
|
|
152
|
-
"img要素のsrc属性がローカルの画像を参照している場合(http, https, dataスキーマが付与されていない)、画像もアップロードします。"
|
|
153
|
-
)
|
|
150
|
+
description = "HTMLファイルを作業ガイドとして登録します。img要素のsrc属性がローカルの画像を参照している場合(http, https, dataスキーマが付与されていない)、画像もアップロードします。"
|
|
154
151
|
epilog = "チェッカーまたはオーナロールを持つユーザで実行してください。"
|
|
155
152
|
parser = annofabcli.common.cli.add_parser(subparsers, subcommand_name, subcommand_help, description, epilog=epilog)
|
|
156
153
|
parse_args(parser)
|
annofabcli/job/list_job.py
CHANGED
|
@@ -74,12 +74,11 @@ def parse_args(parser: argparse.ArgumentParser) -> None:
|
|
|
74
74
|
type=str,
|
|
75
75
|
choices=job_choices,
|
|
76
76
|
required=True,
|
|
77
|
-
help="ジョブタイプを指定します。指定できる値については https://annofab-cli.readthedocs.io/ja/latest/user_guide/command_line_options.html#job-type を参照してください。",
|
|
77
|
+
help="ジョブタイプを指定します。指定できる値については https://annofab-cli.readthedocs.io/ja/latest/user_guide/command_line_options.html#job-type を参照してください。",
|
|
78
78
|
)
|
|
79
79
|
|
|
80
80
|
argument_parser.add_format(choices=[FormatArgument.CSV, FormatArgument.JSON, FormatArgument.PRETTY_JSON], default=FormatArgument.CSV)
|
|
81
81
|
argument_parser.add_output()
|
|
82
|
-
argument_parser.add_csv_format()
|
|
83
82
|
|
|
84
83
|
parser.set_defaults(subcommand_func=main)
|
|
85
84
|
|
annofabcli/job/list_last_job.py
CHANGED
|
@@ -148,8 +148,7 @@ def parse_args(parser: argparse.ArgumentParser) -> None:
|
|
|
148
148
|
"-org",
|
|
149
149
|
"--organization",
|
|
150
150
|
type=str,
|
|
151
|
-
help="
|
|
152
|
-
"自分が所属している進行中のプロジェクトが対象になります。",
|
|
151
|
+
help="組織配下のすべてのプロジェクトのジョブを出力したい場合は、組織名を指定してください。自分が所属している進行中のプロジェクトが対象になります。",
|
|
153
152
|
)
|
|
154
153
|
|
|
155
154
|
parser.add_argument(
|
|
@@ -160,7 +159,6 @@ def parse_args(parser: argparse.ArgumentParser) -> None:
|
|
|
160
159
|
|
|
161
160
|
argument_parser.add_format(choices=[FormatArgument.CSV, FormatArgument.JSON, FormatArgument.PRETTY_JSON], default=FormatArgument.CSV)
|
|
162
161
|
argument_parser.add_output()
|
|
163
|
-
argument_parser.add_csv_format()
|
|
164
162
|
|
|
165
163
|
parser.set_defaults(subcommand_func=main)
|
|
166
164
|
|
|
@@ -49,9 +49,7 @@ class ChangeOrganizationMemberMain(CommandLineWithConfirm):
|
|
|
49
49
|
logger.warning(f"組織メンバにuser_id='{user_id}'のユーザが存在しません。")
|
|
50
50
|
continue
|
|
51
51
|
|
|
52
|
-
if not self.confirm_processing(
|
|
53
|
-
f"user_id='{user_id}'のユーザの組織メンバロールを'{role}'に変更しますか? :: username='{member['username']}', role='{member['role']}'"
|
|
54
|
-
):
|
|
52
|
+
if not self.confirm_processing(f"user_id='{user_id}'のユーザの組織メンバロールを'{role}'に変更しますか? :: username='{member['username']}', role='{member['role']}'"):
|
|
55
53
|
continue
|
|
56
54
|
|
|
57
55
|
try:
|
|
@@ -7,6 +7,7 @@ from typing import Any, Optional
|
|
|
7
7
|
|
|
8
8
|
import annofabapi
|
|
9
9
|
import more_itertools
|
|
10
|
+
from annofabapi.models import OrganizationMemberRole
|
|
10
11
|
|
|
11
12
|
import annofabcli
|
|
12
13
|
import annofabcli.common.cli
|
|
@@ -35,8 +36,12 @@ class DeleteOrganizationMemberMain(CommandLineWithConfirm):
|
|
|
35
36
|
def get_member(organization_member_list: list[dict[str, Any]], user_id: str) -> Optional[dict[str, Any]]:
|
|
36
37
|
return more_itertools.first_true(organization_member_list, pred=lambda e: e["user_id"] == user_id)
|
|
37
38
|
|
|
38
|
-
def
|
|
39
|
-
|
|
39
|
+
def delete_organization_members_from_organization(self, organization_name: str, user_ids: Collection[str]) -> None:
|
|
40
|
+
if not self.facade.contains_any_organization_member_role(organization_name, {OrganizationMemberRole.ADMINISTRATOR, OrganizationMemberRole.OWNER}):
|
|
41
|
+
logger.warning(f"組織'{organization_name}'に所属していないか、組織メンバーを脱退できるロールを持たないため、スキップします。")
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
logger.info(f"{len(user_ids)} 件のメンバーを組織'{organization_name}'から脱退させます。")
|
|
40
45
|
|
|
41
46
|
member_list = self.service.wrapper.get_all_organization_members(organization_name)
|
|
42
47
|
|
|
@@ -45,24 +50,28 @@ class DeleteOrganizationMemberMain(CommandLineWithConfirm):
|
|
|
45
50
|
for user_id in user_ids:
|
|
46
51
|
member = self.get_member(member_list, user_id)
|
|
47
52
|
if member is None:
|
|
48
|
-
logger.warning(f"
|
|
53
|
+
logger.warning(f"組織'{organization_name}'に user_id='{user_id}'のメンバーが存在しません。")
|
|
49
54
|
continue
|
|
50
55
|
|
|
51
|
-
if not self.confirm_processing(
|
|
52
|
-
f"user_id='{user_id}'のユーザを組織から脱退させますか? :: username='{member['username']}', role='{member['role']}'"
|
|
53
|
-
):
|
|
56
|
+
if not self.confirm_processing(f"組織'{organization_name}'に所属する user_id='{user_id}'のメンバーを脱退させますか? :: username='{member['username']}'"):
|
|
54
57
|
continue
|
|
55
58
|
|
|
56
|
-
logger.debug(f"user_id='{user_id}'のユーザを組織から脱退させます。 :: username='{member['username']}', role='{member['role']}'")
|
|
57
59
|
try:
|
|
58
60
|
self.service.api.delete_organization_member(organization_name, user_id)
|
|
59
|
-
logger.debug(f"
|
|
61
|
+
logger.debug(f"組織'{organization_name}'から user_id='{user_id}'のメンバーを脱退させました。")
|
|
60
62
|
success_count += 1
|
|
61
63
|
|
|
62
64
|
except Exception: # pylint: disable=broad-except
|
|
63
|
-
logger.warning(f"
|
|
65
|
+
logger.warning(f"組織'{organization_name}'から user_id='{user_id}'のメンバーを脱退させるのに失敗しました。", exc_info=True)
|
|
66
|
+
|
|
67
|
+
logger.info(f"{success_count} / {len(user_ids)} 件のメンバーを組織'{organization_name}'から脱退させました。")
|
|
64
68
|
|
|
65
|
-
|
|
69
|
+
def delete_organization_members_from_organizations(self, organization_names: list[str], user_ids: Collection[str]) -> None:
|
|
70
|
+
for organization_name in organization_names:
|
|
71
|
+
try:
|
|
72
|
+
self.delete_organization_members_from_organization(organization_name, user_ids)
|
|
73
|
+
except Exception: # pylint
|
|
74
|
+
logger.warning(f"組織'{organization_name}'からメンバーを脱退させるのに失敗しました。", exc_info=True)
|
|
66
75
|
|
|
67
76
|
|
|
68
77
|
class DeleteOrganizationMember(CommandLine):
|
|
@@ -70,9 +79,10 @@ class DeleteOrganizationMember(CommandLine):
|
|
|
70
79
|
args = self.args
|
|
71
80
|
|
|
72
81
|
user_id_list = annofabcli.common.cli.get_list_from_args(args.user_id)
|
|
82
|
+
organization_name_list = annofabcli.common.cli.get_list_from_args(args.organization)
|
|
73
83
|
|
|
74
84
|
main_obj = DeleteOrganizationMemberMain(self.service, all_yes=args.yes)
|
|
75
|
-
main_obj.
|
|
85
|
+
main_obj.delete_organization_members_from_organizations(organization_name_list, user_id_list)
|
|
76
86
|
|
|
77
87
|
|
|
78
88
|
def main(args: argparse.Namespace) -> None:
|
|
@@ -82,7 +92,14 @@ def main(args: argparse.Namespace) -> None:
|
|
|
82
92
|
|
|
83
93
|
|
|
84
94
|
def parse_args(parser: argparse.ArgumentParser) -> None:
|
|
85
|
-
parser.add_argument(
|
|
95
|
+
parser.add_argument(
|
|
96
|
+
"-org",
|
|
97
|
+
"--organization",
|
|
98
|
+
nargs="+",
|
|
99
|
+
required=True,
|
|
100
|
+
type=str,
|
|
101
|
+
help="対象の組織名を指定してます。 ``file://`` を先頭に付けると、一覧が記載されたファイルを指定できます。",
|
|
102
|
+
)
|
|
86
103
|
|
|
87
104
|
parser.add_argument(
|
|
88
105
|
"-u",
|
|
@@ -90,7 +107,7 @@ def parse_args(parser: argparse.ArgumentParser) -> None:
|
|
|
90
107
|
type=str,
|
|
91
108
|
nargs="+",
|
|
92
109
|
required=True,
|
|
93
|
-
help="
|
|
110
|
+
help="組織から脱退させるメンバーのuser_idを指定します。 ``file://`` を先頭に付けると、一覧が記載されたファイルを指定できます。",
|
|
94
111
|
)
|
|
95
112
|
|
|
96
113
|
parser.set_defaults(subcommand_func=main)
|
|
@@ -98,10 +115,9 @@ def parse_args(parser: argparse.ArgumentParser) -> None:
|
|
|
98
115
|
|
|
99
116
|
def add_parser(subparsers: Optional[argparse._SubParsersAction] = None) -> argparse.ArgumentParser:
|
|
100
117
|
subcommand_name = "delete"
|
|
101
|
-
subcommand_help = "
|
|
102
|
-
description = "組織からユーザを脱退させます。"
|
|
118
|
+
subcommand_help = "組織からメンバーを脱退させます。"
|
|
103
119
|
epilog = "組織オーナまたは組織管理者ロールを持つユーザで実行してください。"
|
|
104
120
|
|
|
105
|
-
parser = annofabcli.common.cli.add_parser(subparsers, subcommand_name, command_help=subcommand_help,
|
|
121
|
+
parser = annofabcli.common.cli.add_parser(subparsers, subcommand_name, command_help=subcommand_help, epilog=epilog)
|
|
106
122
|
parse_args(parser)
|
|
107
123
|
return parser
|
|
@@ -29,8 +29,12 @@ class InviteOrganizationMemberMain(CommandLineWithConfirm):
|
|
|
29
29
|
self.facade = AnnofabApiFacade(service)
|
|
30
30
|
super().__init__(all_yes)
|
|
31
31
|
|
|
32
|
-
def
|
|
33
|
-
|
|
32
|
+
def invite_members_to_organization(self, organization_name: str, user_ids: Collection[str], role: str) -> None:
|
|
33
|
+
if not self.facade.contains_any_organization_member_role(organization_name, {OrganizationMemberRole.ADMINISTRATOR, OrganizationMemberRole.OWNER}):
|
|
34
|
+
logger.warning(f"組織'{organization_name}'に所属していないか、組織メンバーを招待できるロールを持たないため、スキップします。")
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
logger.info(f"{len(user_ids)} 件のメンバーを組織'{organization_name}'に招待して、ロール'{role}'を付与します。")
|
|
34
38
|
|
|
35
39
|
organization_member_list = self.service.wrapper.get_all_organization_members(organization_name)
|
|
36
40
|
all_user_ids = {e["user_id"] for e in organization_member_list}
|
|
@@ -39,21 +43,28 @@ class InviteOrganizationMemberMain(CommandLineWithConfirm):
|
|
|
39
43
|
success_count = 0
|
|
40
44
|
for user_id in user_ids:
|
|
41
45
|
if user_id in all_user_ids:
|
|
42
|
-
logger.warning(f"user_id='{user_id}'
|
|
46
|
+
logger.warning(f"user_id='{user_id}'のメンバーは、すでに組織'{organization_name}'に存在しているため、スキップします。")
|
|
43
47
|
continue
|
|
44
48
|
|
|
45
|
-
if not self.confirm_processing(f"user_id='{user_id}'
|
|
49
|
+
if not self.confirm_processing(f"user_id='{user_id}'のメンバーを組織'{organization_name}'に招待して、ロール'{role}'を付与しますか?"):
|
|
46
50
|
continue
|
|
47
51
|
|
|
48
52
|
try:
|
|
49
53
|
self.service.api.invite_organization_member(organization_name, user_id, request_body={"role": role})
|
|
50
|
-
logger.debug(f"user_id='{user_id}'
|
|
54
|
+
logger.debug(f"user_id='{user_id}'のメンバーを組織'{organization_name}'に招待しました。")
|
|
51
55
|
success_count += 1
|
|
52
56
|
|
|
53
57
|
except Exception: # pylint: disable=broad-except
|
|
54
|
-
logger.warning(f"user_id='{user_id}'
|
|
58
|
+
logger.warning(f"user_id='{user_id}'のメンバーを組織'{organization_name}'に招待するのに失敗しました。", exc_info=True)
|
|
59
|
+
|
|
60
|
+
logger.info(f"{success_count} / {len(user_ids)} 件のメンバーを組織'{organization_name}'に招待しました。")
|
|
55
61
|
|
|
56
|
-
|
|
62
|
+
def invite_members_to_organizations(self, organization_names: list[str], user_ids: Collection[str], role: str) -> None:
|
|
63
|
+
for organization_name in organization_names:
|
|
64
|
+
try:
|
|
65
|
+
self.invite_members_to_organization(organization_name, user_ids, role)
|
|
66
|
+
except Exception: # pylint
|
|
67
|
+
logger.warning(f"組織'{organization_name}'にメンバーを招待するのに失敗しました。", exc_info=True)
|
|
57
68
|
|
|
58
69
|
|
|
59
70
|
class InviteOrganizationMember(CommandLine):
|
|
@@ -61,9 +72,10 @@ class InviteOrganizationMember(CommandLine):
|
|
|
61
72
|
args = self.args
|
|
62
73
|
|
|
63
74
|
user_id_list = annofabcli.common.cli.get_list_from_args(args.user_id)
|
|
75
|
+
organization_name_list = annofabcli.common.cli.get_list_from_args(args.organization)
|
|
64
76
|
|
|
65
77
|
main_obj = InviteOrganizationMemberMain(self.service, all_yes=args.yes)
|
|
66
|
-
main_obj.
|
|
78
|
+
main_obj.invite_members_to_organizations(organization_name_list, user_id_list, role=args.role)
|
|
67
79
|
|
|
68
80
|
|
|
69
81
|
def main(args: argparse.Namespace) -> None:
|
|
@@ -73,7 +85,7 @@ def main(args: argparse.Namespace) -> None:
|
|
|
73
85
|
|
|
74
86
|
|
|
75
87
|
def parse_args(parser: argparse.ArgumentParser) -> None:
|
|
76
|
-
parser.add_argument("-org", "--organization", required=True, type=str, help="
|
|
88
|
+
parser.add_argument("-org", "--organization", nargs="+", required=True, type=str, help="招待先の組織名を指定してください。")
|
|
77
89
|
|
|
78
90
|
parser.add_argument(
|
|
79
91
|
"-u",
|
|
@@ -81,7 +93,7 @@ def parse_args(parser: argparse.ArgumentParser) -> None:
|
|
|
81
93
|
type=str,
|
|
82
94
|
nargs="+",
|
|
83
95
|
required=True,
|
|
84
|
-
help="
|
|
96
|
+
help="組織に招待するメンバーのuser_idを指定してます。 ``file://`` を先頭に付けると、一覧が記載されたファイルを指定できます。",
|
|
85
97
|
)
|
|
86
98
|
|
|
87
99
|
role_choices = [e.value for e in OrganizationMemberRole]
|
|
@@ -90,7 +102,7 @@ def parse_args(parser: argparse.ArgumentParser) -> None:
|
|
|
90
102
|
type=str,
|
|
91
103
|
choices=role_choices,
|
|
92
104
|
required=True,
|
|
93
|
-
help="
|
|
105
|
+
help="招待するメンバーに割り当てるロールを指定してください。",
|
|
94
106
|
)
|
|
95
107
|
|
|
96
108
|
parser.set_defaults(subcommand_func=main)
|
|
@@ -98,10 +110,9 @@ def parse_args(parser: argparse.ArgumentParser) -> None:
|
|
|
98
110
|
|
|
99
111
|
def add_parser(subparsers: Optional[argparse._SubParsersAction] = None) -> argparse.ArgumentParser:
|
|
100
112
|
subcommand_name = "invite"
|
|
101
|
-
subcommand_help = "
|
|
102
|
-
description = "組織にユーザを招待します。"
|
|
113
|
+
subcommand_help = "組織にメンバーを招待します。"
|
|
103
114
|
epilog = "組織オーナまたは組織管理者ロールを持つユーザで実行してください。"
|
|
104
115
|
|
|
105
|
-
parser = annofabcli.common.cli.add_parser(subparsers, subcommand_name, command_help=subcommand_help,
|
|
116
|
+
parser = annofabcli.common.cli.add_parser(subparsers, subcommand_name, command_help=subcommand_help, epilog=epilog)
|
|
106
117
|
parse_args(parser)
|
|
107
118
|
return parser
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import asyncio
|
|
3
|
+
import copy
|
|
4
|
+
import logging
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
import annofabapi
|
|
9
|
+
import more_itertools
|
|
10
|
+
import requests
|
|
11
|
+
from annofabapi.models import JobStatus, OrganizationMemberRole, ProjectJobType
|
|
12
|
+
|
|
13
|
+
import annofabcli
|
|
14
|
+
from annofabcli.common.cli import (
|
|
15
|
+
CommandLine,
|
|
16
|
+
CommandLineWithConfirm,
|
|
17
|
+
build_annofabapi_resource_and_login,
|
|
18
|
+
)
|
|
19
|
+
from annofabcli.common.facade import AnnofabApiFacade
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ChangeProjectOrganizationMain(CommandLineWithConfirm):
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
service: annofabapi.Resource,
|
|
28
|
+
*,
|
|
29
|
+
is_force: bool = False,
|
|
30
|
+
all_yes: bool = False,
|
|
31
|
+
) -> None:
|
|
32
|
+
self.service = service
|
|
33
|
+
self.is_force = is_force
|
|
34
|
+
self.facade = AnnofabApiFacade(service)
|
|
35
|
+
super().__init__(all_yes)
|
|
36
|
+
|
|
37
|
+
async def wait_until_jobs_finished_async(self, jobs: list[dict[str, Any]]) -> None:
|
|
38
|
+
tasks = [
|
|
39
|
+
self.wait_until_job_finished_async(
|
|
40
|
+
project_id=job["project_id"],
|
|
41
|
+
job_type=ProjectJobType(job["job_type"]),
|
|
42
|
+
job_id=job["job_id"],
|
|
43
|
+
job_access_interval=60,
|
|
44
|
+
max_job_access=360,
|
|
45
|
+
)
|
|
46
|
+
for job in jobs
|
|
47
|
+
]
|
|
48
|
+
results = await asyncio.gather(*tasks)
|
|
49
|
+
success_count = 0
|
|
50
|
+
for result in results:
|
|
51
|
+
if result is not None and result == JobStatus.SUCCEEDED:
|
|
52
|
+
success_count += 1
|
|
53
|
+
|
|
54
|
+
logger.info(f"{success_count} 件のプロジェクトの組織の変更が成功しました。")
|
|
55
|
+
|
|
56
|
+
async def wait_until_job_finished_async(
|
|
57
|
+
self,
|
|
58
|
+
project_id: str,
|
|
59
|
+
job_type: ProjectJobType,
|
|
60
|
+
job_id: str,
|
|
61
|
+
job_access_interval: int = 60,
|
|
62
|
+
max_job_access: int = 360,
|
|
63
|
+
) -> Optional["JobStatus"]:
|
|
64
|
+
"""
|
|
65
|
+
指定したジョブが終了するまで非同期で待つ。
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
project_id: プロジェクトID
|
|
69
|
+
job_type: ジョブ種別
|
|
70
|
+
job_id: ジョブID。Noneの場合は、現在進行中のジョブが終了するまで待つ。
|
|
71
|
+
job_access_interval: ジョブにアクセスする間隔[sec]
|
|
72
|
+
max_job_access: ジョブに最大何回アクセスするか
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
指定した時間(アクセス頻度と回数)待った後のジョブのステータスを返す。
|
|
76
|
+
指定したジョブ(job_idがNoneの場合は現在進行中のジョブ)が存在しない場合は、Noneを返す。
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def get_job_from_job_id(arg_job_id: str) -> Optional[dict[str, Any]]:
|
|
80
|
+
content, _ = self.service.api.get_project_job(project_id, query_params={"type": job_type.value})
|
|
81
|
+
job_list = content["list"]
|
|
82
|
+
return more_itertools.first_true(job_list, pred=lambda e: e["job_id"] == arg_job_id)
|
|
83
|
+
|
|
84
|
+
job_access_count = 0
|
|
85
|
+
while True:
|
|
86
|
+
# API呼び出しは同期なので、スレッドでラップ
|
|
87
|
+
job = get_job_from_job_id(job_id)
|
|
88
|
+
if job is None:
|
|
89
|
+
logger.info(
|
|
90
|
+
"project_id='%s', job_id='%s', job_type='%s' のジョブは存在しません。",
|
|
91
|
+
project_id,
|
|
92
|
+
job_type.value,
|
|
93
|
+
job_id,
|
|
94
|
+
)
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
job_access_count += 1
|
|
98
|
+
|
|
99
|
+
if job["job_status"] == JobStatus.SUCCEEDED.value:
|
|
100
|
+
logger.info(
|
|
101
|
+
"project_id='%s', job_id='%s', job_type='%s' のジョブが成功しました。",
|
|
102
|
+
project_id,
|
|
103
|
+
job_id,
|
|
104
|
+
job_type.value,
|
|
105
|
+
)
|
|
106
|
+
return JobStatus.SUCCEEDED
|
|
107
|
+
|
|
108
|
+
elif job["job_status"] == JobStatus.FAILED.value:
|
|
109
|
+
logger.info(
|
|
110
|
+
"project_id='%s', job_id='%s', job_type='%s' のジョブが失敗しました。:: errors='%s'",
|
|
111
|
+
project_id,
|
|
112
|
+
job_id,
|
|
113
|
+
job_type.value,
|
|
114
|
+
job["errors"],
|
|
115
|
+
)
|
|
116
|
+
return JobStatus.FAILED
|
|
117
|
+
|
|
118
|
+
elif job_access_count < max_job_access:
|
|
119
|
+
logger.info(
|
|
120
|
+
"project_id='%s', job_id='%s', job_type='%s' のジョブは進行中です。%d 秒間待ちます。",
|
|
121
|
+
project_id,
|
|
122
|
+
job_id,
|
|
123
|
+
job_type.value,
|
|
124
|
+
job_access_interval,
|
|
125
|
+
)
|
|
126
|
+
await asyncio.sleep(job_access_interval)
|
|
127
|
+
else:
|
|
128
|
+
logger.info(
|
|
129
|
+
"project_id='%s', job_id='%s', job_type='%s' のジョブは %.1f 分以上経過しても、終了しませんでした。",
|
|
130
|
+
project_id,
|
|
131
|
+
job["job_id"],
|
|
132
|
+
job_type.value,
|
|
133
|
+
job_access_interval * job_access_count / 60,
|
|
134
|
+
)
|
|
135
|
+
return JobStatus.PROGRESS
|
|
136
|
+
|
|
137
|
+
def change_organization_for_project(self, project_id: str, organization_name: str) -> Optional[dict[str, Any]]:
|
|
138
|
+
project = self.service.wrapper.get_project_or_none(project_id)
|
|
139
|
+
if project is None:
|
|
140
|
+
logger.warning(f"project_id='{project_id}'のプロジェクトは存在しないので、スキップします。")
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
project_name = project["title"]
|
|
144
|
+
if project["project_status"] == "active":
|
|
145
|
+
if self.is_force:
|
|
146
|
+
if not self.confirm_processing(
|
|
147
|
+
f"project_id='{project_id}'のプロジェクトの状態を停止中にしたあと、所属する組織を'{organization_name}'に変更しますか? :: project_name='{project_name}'"
|
|
148
|
+
):
|
|
149
|
+
return None
|
|
150
|
+
request_body = copy.deepcopy(project)
|
|
151
|
+
request_body.update(
|
|
152
|
+
{
|
|
153
|
+
"status": "suspended",
|
|
154
|
+
"last_updated_datetime": project["updated_datetime"],
|
|
155
|
+
}
|
|
156
|
+
)
|
|
157
|
+
project, _ = self.service.api.put_project(project_id, request_body=request_body, query_params={"v": "2"})
|
|
158
|
+
logger.info(f"project_id='{project_id}'のプロジェクトのステータスを「停止中」に変更しました。 :: project_name='{project_name}'")
|
|
159
|
+
else:
|
|
160
|
+
logger.warning(
|
|
161
|
+
f"project_id='{project_id}'のプロジェクトのステータスは「進行中」のため、組織を変更できません。 `--force`オプションを指定すれば、停止中状態に変更した後組織を変更できます。"
|
|
162
|
+
)
|
|
163
|
+
return None
|
|
164
|
+
elif not self.confirm_processing(f"project_id='{project_id}'のプロジェクトの組織を'{organization_name}'に変更しますか? :: project_name='{project_name}'"):
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
assert project is not None
|
|
168
|
+
request_body = copy.deepcopy(project)
|
|
169
|
+
request_body["organization_name"] = organization_name
|
|
170
|
+
request_body["last_updated_datetime"] = project["updated_datetime"]
|
|
171
|
+
request_body["status"] = project["project_status"]
|
|
172
|
+
|
|
173
|
+
content, _ = self.service.api.put_project(project_id, request_body=request_body, query_params={"v": "2"})
|
|
174
|
+
job = content["job"]
|
|
175
|
+
logger.info(f"project_id='{project_id}'のプロジェクトの所属先組織を'{organization_name}'に変更するジョブを発行しました。 :: project_name='{project_name}', job_id='{job['job_id']}'")
|
|
176
|
+
return job
|
|
177
|
+
|
|
178
|
+
def change_organization_for_project_list(self, project_id_list: list[str], organization_name: str) -> list[dict[str, Any]]:
|
|
179
|
+
if not self.facade.contains_any_organization_member_role(organization_name, [OrganizationMemberRole.OWNER, OrganizationMemberRole.ADMINISTRATOR]):
|
|
180
|
+
logger.warning(f"変更先組織'{organization_name}'に対して管理者ロールまたはオーナロールでないため、プロジェクトの所属する組織を変更できません。")
|
|
181
|
+
return []
|
|
182
|
+
|
|
183
|
+
logger.info(f"{len(project_id_list)} 件のプロジェクトの組織を'{organization_name}'に変更するジョブを発行します。")
|
|
184
|
+
|
|
185
|
+
job_list = []
|
|
186
|
+
for project_id in project_id_list:
|
|
187
|
+
try:
|
|
188
|
+
result = self.change_organization_for_project(project_id, organization_name)
|
|
189
|
+
if result is not None:
|
|
190
|
+
job_list.append(result)
|
|
191
|
+
except requests.HTTPError:
|
|
192
|
+
logger.warning(f"project_id='{project_id}'の組織変更でHTTPエラーが発生しました。", exc_info=True)
|
|
193
|
+
logger.info(f"{len(job_list)}/{len(project_id_list)}件のプロジェクトの組織を'{organization_name}'に変更するジョブを発行しました。")
|
|
194
|
+
return job_list
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class ChangeProjectOrganization(CommandLine):
|
|
198
|
+
def main(self) -> None:
|
|
199
|
+
args = self.args
|
|
200
|
+
project_id_list = annofabcli.common.cli.get_list_from_args(args.project_id)
|
|
201
|
+
main_obj = ChangeProjectOrganizationMain(self.service, all_yes=args.yes, is_force=args.force)
|
|
202
|
+
|
|
203
|
+
job_list = main_obj.change_organization_for_project_list(project_id_list=project_id_list, organization_name=args.organization)
|
|
204
|
+
if len(job_list) == 0:
|
|
205
|
+
logger.info("組織を変更するジョブは発行されませんでした。終了します。")
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
# APIリクエストを減らすため、とりあえず60秒待ちます
|
|
209
|
+
seconds = 60
|
|
210
|
+
logger.info(f"ジョブの完了を{seconds}秒待ちます。")
|
|
211
|
+
time.sleep(seconds)
|
|
212
|
+
|
|
213
|
+
# すべてのジョブが完了するまで待つ
|
|
214
|
+
asyncio.run(main_obj.wait_until_jobs_finished_async(job_list))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def main(args: argparse.Namespace) -> None:
|
|
218
|
+
service = build_annofabapi_resource_and_login(args)
|
|
219
|
+
facade = AnnofabApiFacade(service)
|
|
220
|
+
ChangeProjectOrganization(service, facade, args).main()
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def parse_args(parser: argparse.ArgumentParser) -> None:
|
|
224
|
+
parser.add_argument(
|
|
225
|
+
"-p",
|
|
226
|
+
"--project_id",
|
|
227
|
+
type=str,
|
|
228
|
+
required=True,
|
|
229
|
+
nargs="+",
|
|
230
|
+
help="対象プロジェクトのproject_idを指定します。 ``file://`` を先頭に付けると、project_idの一覧が記載されたファイルを指定できます。",
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
parser.add_argument(
|
|
234
|
+
"-org",
|
|
235
|
+
"--organization",
|
|
236
|
+
type=str,
|
|
237
|
+
required=True,
|
|
238
|
+
help="変更後の組織名を指定してください。",
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
parser.add_argument(
|
|
242
|
+
"--force",
|
|
243
|
+
action="store_true",
|
|
244
|
+
help="強制的に組織を変更します(将来拡張用)。",
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
parser.set_defaults(subcommand_func=main)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def add_parser(subparsers: Optional[argparse._SubParsersAction] = None) -> argparse.ArgumentParser:
|
|
251
|
+
subcommand_name = "change_organization"
|
|
252
|
+
subcommand_help = "プロジェクトの所属する組織を変更します。"
|
|
253
|
+
epilog = "プロジェクトのオーナロール、変更先の組織の管理者またはオーナーロールを持つユーザで実行してください。"
|
|
254
|
+
|
|
255
|
+
parser = annofabcli.common.cli.add_parser(subparsers, subcommand_name, subcommand_help, epilog=epilog)
|
|
256
|
+
parse_args(parser)
|
|
257
|
+
return parser
|
|
@@ -112,7 +112,7 @@ class ChanegProjectStatusMain:
|
|
|
112
112
|
return False
|
|
113
113
|
|
|
114
114
|
if not self.facade.contains_any_project_member_role(project_id, [ProjectMemberRole.OWNER]):
|
|
115
|
-
logger.warning(f"project_id={project_id}:
|
|
115
|
+
logger.warning(f"project_id={project_id}: オーナロールでないため、プロジェクトのステータスを変更できません。project_title={project['title']}")
|
|
116
116
|
return False
|
|
117
117
|
|
|
118
118
|
logger.debug(f"{project['title']} のステータスを{status.value} に変更します。project_id={project_id}")
|
|
@@ -187,7 +187,7 @@ def parse_args(parser: argparse.ArgumentParser) -> None:
|
|
|
187
187
|
parser.add_argument(
|
|
188
188
|
"--force",
|
|
189
189
|
action="store_true",
|
|
190
|
-
help=f"`--status {ProjectStatus.SUSPENDED.value}`を指定している状態で、 ``--force`` を指定した場合、作業中タスクが残っていても停止状態に変更します。",
|
|
190
|
+
help=f"`--status {ProjectStatus.SUSPENDED.value}`を指定している状態で、 ``--force`` を指定した場合、作業中タスクが残っていても停止状態に変更します。",
|
|
191
191
|
)
|
|
192
192
|
|
|
193
193
|
parser.set_defaults(subcommand_func=main)
|