diff --git a/Makefile b/Makefile index 81410aa6..67a45b29 100644 --- a/Makefile +++ b/Makefile @@ -17,8 +17,8 @@ format: pipenv run yapf --verbose --in-place --recursive ${FORMAT_FILES} lint: - pipenv run flake8 ${LINT_FILES} pipenv run mypy ${LINT_FILES} --config-file setup.cfg + pipenv run flake8 ${LINT_FILES} pipenv run pylint ${LINT_FILES} --rcfile setup.cfg test: diff --git a/README.md b/README.md index 88d7421f..eb251db0 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ $ docker run -it -e ANNOFAB_USER_ID=XXXX -e ANNOFAB_PASSWORD=YYYYY annofab-cli a |instruction| upload | HTMLファイルを作業ガイドとして登録します。 |チェッカー/オーナ| |job|list | ジョブ一覧を出力します。 |-| |job|list_last | 複数のプロジェクトに対して、最新のジョブを出力します。 |-| +|labor|list_worktime_by_user | ユーザごとに作業予定時間、作業実績時間を出力します。 || |organization_member|list | 組織メンバ一覧を出力します。 |-| |project| copy | プロジェクトをコピーします。 |オーナ and 組織管理者/組織オーナ| |project| diff | プロジェクト間の差分を表示します。 |チェッカー/オーナ| @@ -645,6 +646,19 @@ $ annofabcli job list_last --project_id prj1 --job_type gen-annotation --add_det ``` +### labor list_worktime_by_user + +ユーザごとに作業予定時間、作業実績時間を出力します。 + +``` +# 組織org1, org2に対して、user1, user2の作業時間を集計します。 +$ annofabcli labor list_worktime_by_user --organization org1 org2 --user_id user1 user2 \ + --start_date 2019-10-01 --end_date 2019-10-31 --output_dir /tmp/output + +# プロジェクトprj1, prj2に対して作業時間を集計します。集計対象のユーザはプロジェクトに所属するメンバです。 +$ annofabcli labor list_worktime_by_user --project_id prj1 prj2 --user_id user1 user2 \ + --start_date 2019-10-01 --end_date 2019-10-31 --output_dir /tmp/output +``` ### organization_member list diff --git a/annofabcli/__main__.py b/annofabcli/__main__.py index 5627f844..9b88342d 100644 --- a/annofabcli/__main__.py +++ b/annofabcli/__main__.py @@ -9,6 +9,7 @@ import annofabcli.inspection_comment.subcommand_inspection_comment import annofabcli.instruction.subcommand_instruction import annofabcli.job.subcommand_job +import annofabcli.labor.subcommand_labor import annofabcli.organization_member.subcommand_organization_member import annofabcli.project.subcommand_project import annofabcli.project_member.subcommand_project_member @@ -41,6 +42,7 @@ def main(arguments: Optional[Sequence[str]] = None): annofabcli.inspection_comment.subcommand_inspection_comment.add_parser(subparsers) annofabcli.instruction.subcommand_instruction.add_parser(subparsers) annofabcli.job.subcommand_job.add_parser(subparsers) + annofabcli.labor.subcommand_labor.add_parser(subparsers) annofabcli.organization_member.subcommand_organization_member.add_parser(subparsers) annofabcli.project.subcommand_project.add_parser(subparsers) annofabcli.project_member.subcommand_project_member.add_parser(subparsers) diff --git a/annofabcli/__version__.py b/annofabcli/__version__.py index e4f2ad49..1c19d78b 100644 --- a/annofabcli/__version__.py +++ b/annofabcli/__version__.py @@ -1 +1 @@ -__version__ = '1.14.0' +__version__ = '1.15.0' diff --git a/annofabcli/common/cli.py b/annofabcli/common/cli.py index c9aa57c7..a1115cd8 100644 --- a/annofabcli/common/cli.py +++ b/annofabcli/common/cli.py @@ -92,7 +92,7 @@ def create_parent_parser() -> argparse.ArgumentParser: def get_list_from_args(str_list: Optional[List[str]] = None) -> List[str]: """ 文字列のListのサイズが1で、プレフィックスが`file://`ならば、ファイルパスとしてファイルを読み込み、行をListとして返す。 - そうでなければ、引数の値をそのままかえす。 + そうでなければ、引数の値をそのまま返す。 ただしNoneの場合は空Listを変えす """ if str_list is None or len(str_list) == 0: diff --git a/annofabcli/labor/__init__.py b/annofabcli/labor/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/annofabcli/labor/list_worktime_by_user.py b/annofabcli/labor/list_worktime_by_user.py new file mode 100644 index 00000000..c4a2c087 --- /dev/null +++ b/annofabcli/labor/list_worktime_by_user.py @@ -0,0 +1,327 @@ +import argparse +import logging +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional, Tuple, Union # pylint: disable=unused-import + +import more_itertools +import pandas +from annofabapi.models import OrganizationMember, Project +from dataclasses_json import dataclass_json + +import annofabcli +from annofabcli import AnnofabApiFacade +from annofabcli.common.cli import AbstractCommandLineInterface, build_annofabapi_resource_and_login, get_list_from_args + +logger = logging.getLogger(__name__) + + +@dataclass_json +@dataclass(frozen=True) +class LaborWorktime: + """ + 労務管理情報 + """ + date: str + organization_id: str + organization_name: str + project_id: str + project_title: str + account_id: str + user_id: str + username: str + worktime_plan_hour: float + worktime_result_hour: float + + +@dataclass_json +@dataclass(frozen=True) +class SumLaborWorktime: + """ + 出力用の作業時間情報 + """ + date: str + user_id: str + worktime_plan_hour: float + worktime_result_hour: float + + +class ListWorktimeByUser(AbstractCommandLineInterface): + """ + 作業時間をユーザごとに出力する。 + """ + + DATE_FORMAT = "%Y-%m-%d" + + @staticmethod + def create_required_columns(df, prior_columns): + remained_columns = list(df.columns.difference(prior_columns)) + all_columns = prior_columns + remained_columns + return all_columns + + @staticmethod + def get_member_from_user_id(organization_member_list: List[OrganizationMember], + user_id: str) -> Optional[OrganizationMember]: + member = more_itertools.first_true(organization_member_list, pred=lambda e: e["user_id"] == user_id) + return member + + @staticmethod + def get_member_from_account_id(organization_member_list: List[OrganizationMember], + account_id: str) -> Optional[OrganizationMember]: + member = more_itertools.first_true(organization_member_list, pred=lambda e: e["account_id"] == account_id) + return member + + @staticmethod + def get_project_title(project_list: List[Project], project_id: str) -> str: + project = more_itertools.first_true(project_list, pred=lambda e: e["project_id"] == project_id) + if project is not None: + return project["title"] + else: + return "" + + @staticmethod + def get_worktime_hour(working_time_by_user: Dict[str, Any], key: str) -> float: + value = working_time_by_user.get(key) + if value is None: + return 0 + else: + return value / 3600 / 1000 + + def _get_labor_worktime(self, labor: Dict[str, Any], member: OrganizationMember, project_title: str, + organization_name: str) -> LaborWorktime: + new_labor = LaborWorktime( + date=labor["date"], + organization_id=labor["organization_id"], + organization_name=organization_name, + project_id=labor["project_id"], + project_title=project_title, + account_id=labor["account_id"], + user_id=member["user_id"] if member is not None else "", + username=member["username"] if member is not None else "", + worktime_plan_hour=self.get_worktime_hour(labor["values"]["working_time_by_user"], "plans"), + worktime_result_hour=self.get_worktime_hour(labor["values"]["working_time_by_user"], "results"), + ) + return new_labor + + def get_labor_list_from_project_id(self, project_id: str, member_list: List[OrganizationMember], start_date: str, + end_date: str) -> List[LaborWorktime]: + organization, _ = self.service.api.get_organization_of_project(project_id) + organization_name = organization["organization_name"] + + labor_list, _ = self.service.api.get_labor_control({ + "project_id": project_id, + "organization_id": organization["organization_id"], + "from": start_date, + "to": end_date + }) + project_title = self.service.api.get_project(project_id)[0]["title"] + + logger.info(f"'{project_title}'プロジェクト('{project_id}')の労務管理情報の件数: {len(labor_list)}") + + new_labor_list = [] + for labor in labor_list: + member = self.get_member_from_account_id(member_list, labor["account_id"]) + + new_labor = self._get_labor_worktime(labor, member=member, project_title=project_title, + organization_name=organization_name) + new_labor_list.append(new_labor) + + return new_labor_list + + def get_labor_list_from_organization_name(self, organization_name: str, member_list: List[OrganizationMember], + start_date: str, end_date: str) -> List[LaborWorktime]: + organization, _ = self.service.api.get_organization(organization_name) + organization_id = organization["organization_id"] + project_list = self.service.wrapper.get_all_projects_of_organization(organization_name) + + labor_list, _ = self.service.api.get_labor_control({ + "organization_id": organization_id, + "from": start_date, + "to": end_date + }) + logger.info(f"'{organization_name}'組織の労務管理情報の件数: {len(labor_list)}") + new_labor_list = [] + for labor in labor_list: + member = self.get_member_from_account_id(member_list, labor["account_id"]) + project_title = self.get_project_title(project_list, labor["project_id"]) + new_labor = self._get_labor_worktime(labor, member=member, project_title=project_title, + organization_name=organization_name) + new_labor_list.append(new_labor) + + return new_labor_list + + @staticmethod + def get_sum_worktime_list(labor_list: List[LaborWorktime], user_id: str, start_date: str, + end_date: str) -> List[SumLaborWorktime]: + sum_labor_list = [] + for date in pandas.date_range(start=start_date, end=end_date): + str_date = date.strftime(ListWorktimeByUser.DATE_FORMAT) + filtered_list = [e for e in labor_list if e.user_id == user_id and e.date == str_date] + worktime_plan_hour = sum([e.worktime_plan_hour for e in filtered_list]) + worktime_result_hour = sum([e.worktime_result_hour for e in filtered_list]) + + labor = SumLaborWorktime(user_id=user_id, date=date, worktime_plan_hour=worktime_plan_hour, + worktime_result_hour=worktime_result_hour) + sum_labor_list.append(labor) + + return sum_labor_list + + @staticmethod + def write_sum_worktime_list(sum_worktime_df: pandas.DataFrame, output_dir: Path): + sum_worktime_df.round(3).to_csv(str(output_dir / "ユーザごとの作業時間.csv"), encoding="utf_8_sig", index=False) + + @staticmethod + def write_worktime_list(worktime_df: pandas.DataFrame, output_dir: Path): + worktime_df = worktime_df.rename(columns={ + "worktime_plan_hour": "作業予定時間", + "worktime_result_hour": "作業実績時間" + }).round(3) + columns = [ + "date", "organization_name", "project_title", "project_id", "username", "user_id", "作業予定時間", "作業実績時間" + ] + worktime_df[columns].to_csv(str(output_dir / "作業時間の詳細一覧.csv"), encoding="utf_8_sig", index=False) + + def get_organization_member_list(self, organization_name_list: Optional[List[str]], + project_id_list: Optional[List[str]]) -> List[OrganizationMember]: + member_list: List[OrganizationMember] = [] + + if project_id_list is not None: + tmp_organization_name_list = [] + for project_id in project_id_list: + organization, _ = self.service.api.get_organization_of_project(project_id) + tmp_organization_name_list.append(organization["organization_name"]) + + organization_name_list = list(set(tmp_organization_name_list)) + + if organization_name_list is not None: + for organization_name in organization_name_list: + member_list.extend(self.service.wrapper.get_all_organization_members(organization_name)) + + return member_list + + def print_labor_worktime_list(self, organization_name_list: Optional[List[str]], + project_id_list: Optional[List[str]], user_id_list: List[str], start_date: str, + end_date: str, output_dir: Path) -> None: + """ + 作業時間の一覧を出力する + """ + labor_list = [] + member_list = self.get_organization_member_list(organization_name_list, project_id_list) + + if project_id_list is not None: + for project_id in project_id_list: + labor_list.extend( + self.get_labor_list_from_project_id(project_id, member_list=member_list, start_date=start_date, + end_date=end_date)) + + elif organization_name_list is not None: + for organization_name in organization_name_list: + labor_list.extend( + self.get_labor_list_from_organization_name(organization_name, member_list=member_list, + start_date=start_date, end_date=end_date)) + + # 集計対象ユーザで絞り込む + labor_list = [e for e in labor_list if e.user_id in user_id_list] + + reform_dict = { + ("date", ""): [ + e.strftime(ListWorktimeByUser.DATE_FORMAT) for e in pandas.date_range(start=start_date, end=end_date) + ], + ("dayofweek", ""): [e.strftime("%a") for e in pandas.date_range(start=start_date, end=end_date)], + } + + for user_id in user_id_list: + sum_worktime_list = self.get_sum_worktime_list(labor_list, user_id=user_id, start_date=start_date, + end_date=end_date) + member = self.get_member_from_user_id(member_list, user_id) + if member is not None: + username = member["username"] + else: + logger.warning(f"user_idが'{user_id}'のユーザは存在しません。") + username = user_id + + reform_dict.update({ + (username, "作業予定"): [e.worktime_plan_hour for e in sum_worktime_list], + (username, "作業実績"): [e.worktime_result_hour for e in sum_worktime_list] + }) + + sum_worktime_df = pandas.DataFrame(reform_dict) + self.write_sum_worktime_list(sum_worktime_df, output_dir) + + worktime_df = pandas.DataFrame([e.to_dict() for e in labor_list]) # type: ignore + if len(worktime_df) > 0: + self.write_worktime_list(worktime_df, output_dir) + else: + logger.warning(f"労務管理情報が0件のため、作業時間の詳細一覧.csv は出力しません。") + + @staticmethod + def validate(args: argparse.Namespace) -> bool: + if args.organization is not None and args.user_id is None: + print("ERROR: argument --user_id: " "`--organization`を指定しているときは、`--user_id`オプションは必須です。", file=sys.stderr) + return False + + return True + + def get_user_id_list_from_project_id_list(self, project_id_list: List[str]) -> List[str]: + member_list: List[Dict[str, Any]] = [] + for project_id in project_id_list: + member_list.extend(self.service.wrapper.get_all_project_members(project_id)) + user_id_list = [e["user_id"] for e in member_list] + return list(set(user_id_list)) + + def main(self): + args = self.args + + if not self.validate(args): + return + + user_id_list = get_list_from_args(args.user_id) if args.user_id is not None else None + project_id_list = get_list_from_args(args.project_id) if args.project_id is not None else None + organization_name_list = get_list_from_args(args.organization) if args.organization is not None else None + + if user_id_list is None and project_id_list is not None: + user_id_list = self.get_user_id_list_from_project_id_list(project_id_list) + + output_dir = Path(args.output_dir) + output_dir.mkdir(exist_ok=True, parents=True) + + self.print_labor_worktime_list(organization_name_list=organization_name_list, project_id_list=project_id_list, + user_id_list=user_id_list, start_date=args.start_date, end_date=args.end_date, + output_dir=output_dir) + + +def main(args): + service = build_annofabapi_resource_and_login() + facade = AnnofabApiFacade(service) + ListWorktimeByUser(service, facade, args).main() + + +def parse_args(parser: argparse.ArgumentParser): + target_group = parser.add_mutually_exclusive_group(required=True) + target_group.add_argument('-org', '--organization', type=str, nargs='+', + help='集計対象の組織名を指定してください。`file://`を先頭に付けると、組織名の一覧が記載されたファイルを指定できます。') + + target_group.add_argument('-p', '--project_id', type=str, nargs='+', + help='集計対象のプロジェクトを指定してください。`file://`を先頭に付けると、project_idの一覧が記載されたファイルを指定できます。') + + parser.add_argument( + '-u', '--user_id', type=str, nargs='+', help='集計対象のユーザのuser_idを指定してください。`--organization`を指定した場合は必須です。' + '指定しない場合は、プロジェクトメンバが指定されます。' + '`file://`を先頭に付けると、user_idの一覧が記載されたファイルを指定できます。') + + parser.add_argument("--start_date", type=str, required=True, help="集計期間の開始日(%%Y-%%m-%%d)") + parser.add_argument("--end_date", type=str, required=True, help="集計期間の終了日(%%Y-%%m-%%d)") + + parser.add_argument('-o', '--output_dir', type=str, required=True, help='出力先のディレクトリのパス') + + parser.set_defaults(subcommand_func=main) + + +def add_parser(subparsers: argparse._SubParsersAction): + subcommand_name = "list_worktime_by_user" + subcommand_help = "ユーザごとに作業予定時間、作業実績時間を出力します。" + description = ("ユーザごとに作業予定時間、作業実績時間を出力します。") + + parser = annofabcli.common.cli.add_parser(subparsers, subcommand_name, subcommand_help, description) + parse_args(parser) diff --git a/annofabcli/labor/subcommand_labor.py b/annofabcli/labor/subcommand_labor.py new file mode 100644 index 00000000..b3b60e6a --- /dev/null +++ b/annofabcli/labor/subcommand_labor.py @@ -0,0 +1,22 @@ +import argparse + +import annofabcli +import annofabcli.common.cli +import annofabcli.labor.list_worktime_by_user + + +def parse_args(parser: argparse.ArgumentParser): + + subparsers = parser.add_subparsers(dest='subcommand_name') + + # サブコマンドの定義 + annofabcli.labor.list_worktime_by_user.add_parser(subparsers) + + +def add_parser(subparsers: argparse._SubParsersAction): + subcommand_name = "labor" + subcommand_help = "労務管理関係のサブコマンド" + description = "労務管理関係のサブコマンド" + + parser = annofabcli.common.cli.add_parser(subparsers, subcommand_name, subcommand_help, description) + parse_args(parser) diff --git a/tests/test_main.py b/tests/test_main.py index 4995a382..90d75fd2 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -25,10 +25,7 @@ out_path = Path('./tests/out') data_path = Path('./tests/data') - -def get_organization_name(project_id: str) -> str: - organization, _ = service.api.get_organization_of_project(project_id) - return organization["organization_name"] +organization_name = service.api.get_organization_of_project(project_id)[0]["organization_name"] class TestAnnotation: @@ -165,12 +162,29 @@ def test_list_last_job(self): ]) +class TestLabor: + def test_list_worktime_by_user_with_project_id(self): + output_dir = str(out_path / 'labor') + main([ + 'labor', 'list_worktime_by_user', '--project_id', project_id, '--user_id', service.api.login_user_id, + '--start_date', '2019-09-01', '--end_date', '2019-09-01', '--output_dir', + str(output_dir) + ]) + + def test_list_worktime_by_user_with_organization_name(self): + output_dir = str(out_path / 'labor') + main([ + 'labor', 'list_worktime_by_user', '--organization', organization_name, '--user_id', + service.api.login_user_id, '--start_date', '2019-09-01', '--end_date', '2019-09-01', '--output_dir', + str(output_dir) + ]) + + class TestOrganizationMember: def test_list_organization_member(self): out_file = str(out_path / 'organization_member.csv') main([ - 'organization_member', 'list', '--organization', - get_organization_name(project_id), '--format', 'csv', '--output', out_file + 'organization_member', 'list', '--organization', organization_name, '--format', 'csv', '--output', out_file ]) @@ -203,7 +217,6 @@ def test_download_project_full_annotation(self): main(['project', 'download', 'full_annotation', '--project_id', project_id, '--output', out_file]) def test_list_project(self): - organization_name = get_organization_name(project_id) out_file = str(out_path / 'project.csv') main([ 'project', 'list', '--organization', organization_name, '--project_query', '{"status": "active"}', @@ -218,7 +231,6 @@ def test_put_project_member(self): def test_list_project_member(self): main(['project_member', 'list', '--project_id', project_id]) - organization_name = get_organization_name(project_id) main(['project_member', 'list', '--organization', organization_name]) def test_copy_project_member(self):