papi-projects 0.3.1__tar.gz → 0.4.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. {papi_projects-0.3.1 → papi_projects-0.4.1}/PKG-INFO +11 -16
  2. {papi_projects-0.3.1 → papi_projects-0.4.1}/papi/__init__.py +50 -4
  3. {papi_projects-0.3.1 → papi_projects-0.4.1}/papi/project.py +1 -0
  4. papi_projects-0.4.1/papi/workorder.py +100 -0
  5. {papi_projects-0.3.1 → papi_projects-0.4.1}/papi/wrappers.py +225 -1
  6. papi_projects-0.4.1/papi_projects.egg-info/PKG-INFO +371 -0
  7. papi_projects-0.4.1/papi_projects.egg-info/SOURCES.txt +26 -0
  8. papi_projects-0.4.1/papi_projects.egg-info/dependency_links.txt +1 -0
  9. papi_projects-0.4.1/papi_projects.egg-info/entry_points.txt +7 -0
  10. papi_projects-0.4.1/papi_projects.egg-info/requires.txt +6 -0
  11. papi_projects-0.4.1/papi_projects.egg-info/top_level.txt +2 -0
  12. papi_projects-0.4.1/pyproject.toml +33 -0
  13. papi_projects-0.4.1/scripts/generate_timesheet.py +194 -0
  14. papi_projects-0.4.1/scripts/test_notion.py +30 -0
  15. papi_projects-0.4.1/setup.cfg +4 -0
  16. papi_projects-0.4.1/tests/test_project.py +142 -0
  17. papi_projects-0.4.1/tests/test_task.py +92 -0
  18. papi_projects-0.4.1/tests/test_user.py +133 -0
  19. papi_projects-0.4.1/tests/test_wrappers.py +227 -0
  20. papi_projects-0.3.1/pyproject.toml +0 -31
  21. {papi_projects-0.3.1 → papi_projects-0.4.1}/README.md +0 -0
  22. {papi_projects-0.3.1 → papi_projects-0.4.1}/papi/task.py +0 -0
  23. {papi_projects-0.3.1 → papi_projects-0.4.1}/papi/user.py +0 -0
  24. {papi_projects-0.3.1 → papi_projects-0.4.1}/scripts/__init__.py +0 -0
  25. {papi_projects-0.3.1 → papi_projects-0.4.1}/scripts/collate_toggl_hours.py +0 -0
  26. {papi_projects-0.3.1 → papi_projects-0.4.1}/scripts/create_notion_project.py +0 -0
  27. {papi_projects-0.3.1 → papi_projects-0.4.1}/scripts/create_notion_task.py +0 -0
  28. {papi_projects-0.3.1 → papi_projects-0.4.1}/scripts/create_project.py +0 -0
  29. {papi_projects-0.3.1 → papi_projects-0.4.1}/scripts/create_toggl_project.py +0 -0
@@ -1,21 +1,17 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: papi-projects
3
- Version: 0.3.1
3
+ Version: 0.4.1
4
4
  Summary: PAPI is an API for managing projects
5
- License: MIT
6
- Author: sandyjmacdonald
7
- Author-email: sandyjmacdonald@gmail.com
8
- Requires-Python: >=3.11,<4.0
9
- Classifier: License :: OSI Approved :: MIT License
10
- Classifier: Programming Language :: Python :: 3
11
- Classifier: Programming Language :: Python :: 3.11
12
- Classifier: Programming Language :: Python :: 3.12
13
- Requires-Dist: httpx (>=0.27.2,<0.28.0)
14
- Requires-Dist: pendulum (>=3.0.0,<4.0.0)
15
- Requires-Dist: pyperclip (>=1.9.0,<2.0.0)
16
- Requires-Dist: python-dotenv (>=1.0.0,<2.0.0)
17
- Requires-Dist: tinydb (>=4.8.0,<5.0.0)
5
+ Author-email: sandyjmacdonald <sandyjmacdonald@gmail.com>
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.11
18
8
  Description-Content-Type: text/markdown
9
+ Requires-Dist: pendulum>=3.0.0
10
+ Requires-Dist: httpx>=0.27.2
11
+ Requires-Dist: tinydb>=4.8.0
12
+ Requires-Dist: python-dotenv>=1.0.0
13
+ Requires-Dist: pyperclip>=1.9.0
14
+ Requires-Dist: pandas>=2.3.3
19
15
 
20
16
  PAPI is an API for managing projects.
21
17
 
@@ -373,4 +369,3 @@ usr.user_id = "JS1"
373
369
  ```
374
370
 
375
371
  Setting the `user_id` attribute creates the possibility of a clash in user IDs, therefore the `user` module provides a means to create a basic user database with the TinyDB library. This avoids the possibility of a clash and appends and increments integer numbers to the end of the user ID if a matching one is already in the database.
376
-
@@ -1,18 +1,64 @@
1
1
  import os
2
2
  import logging
3
3
  from dotenv import dotenv_values
4
+ from pathlib import Path
4
5
 
5
- dotenv_path = os.path.join(os.path.dirname(__file__), '.env')
6
- config = dotenv_values(dotenv_path)
6
+ def load_config():
7
+ user_env_path = Path.home() / ".config" / "papi" / ".env"
8
+ config = {}
9
+
10
+ if user_env_path.exists():
11
+ config.update(dotenv_values(user_env_path))
12
+
13
+ for key in (
14
+ "TOGGL_TRACK_API_KEY",
15
+ "TOGGL_TRACK_PASSWORD",
16
+ "NOTION_API_SECRET",
17
+ "NOTION_CLIENTS_DB",
18
+ "NOTION_PROJECTS_DB",
19
+ "NOTION_TASKS_DB",
20
+ "NOTION_TEMPLATE_PAGE_ID",
21
+ "NOTION_WORKORDERS_DB",
22
+ "NOTION_TRAC_COSTS_DB",
23
+ ):
24
+ if key in os.environ:
25
+ config[key] = os.environ[key]
26
+
27
+ local_env_path = Path(__file__).parent / ".env"
28
+ if local_env_path.exists():
29
+ local_vals = dotenv_values(local_env_path)
30
+ for k, v in local_vals.items():
31
+ config.setdefault(k, v)
32
+
33
+ return config
34
+
35
+ config = load_config()
7
36
 
8
37
  TOGGL_TRACK_API_KEY = config["TOGGL_TRACK_API_KEY"]
9
38
  TOGGL_TRACK_PASSWORD = config["TOGGL_TRACK_PASSWORD"]
10
-
11
39
  NOTION_API_SECRET = config["NOTION_API_SECRET"]
12
40
  NOTION_CLIENTS_DB = config["NOTION_CLIENTS_DB"]
13
41
  NOTION_PROJECTS_DB = config["NOTION_PROJECTS_DB"]
14
42
  NOTION_TASKS_DB = config["NOTION_TASKS_DB"]
15
43
  NOTION_TEMPLATE_PAGE_ID = config["NOTION_TEMPLATE_PAGE_ID"]
44
+ NOTION_WORKORDERS_DB = config["NOTION_WORKORDERS_DB"]
45
+ NOTION_TRAC_COSTS_DB = config["NOTION_TRAC_COSTS_DB"]
46
+
47
+ required = [
48
+ "TOGGL_TRACK_API_KEY",
49
+ "TOGGL_TRACK_PASSWORD",
50
+ "NOTION_API_SECRET",
51
+ "NOTION_CLIENTS_DB",
52
+ "NOTION_PROJECTS_DB",
53
+ "NOTION_TASKS_DB",
54
+ "NOTION_TEMPLATE_PAGE_ID",
55
+ "NOTION_WORKORDERS_DB",
56
+ "NOTION_TRAC_COSTS_DB",
57
+ ]
58
+
59
+ missing = [k for k in required if k not in config]
60
+ if missing:
61
+ raise RuntimeError(f"Missing required config values: {', '.join(missing)}")
16
62
 
17
63
  def setup_logger(enable_logging: bool, log_level: str = 'INFO', log_file: str = None):
18
64
  logger = logging.getLogger('papi')
@@ -44,4 +90,4 @@ def setup_logger(enable_logging: bool, log_level: str = 'INFO', log_file: str =
44
90
  # Set a higher log level to suppress lower-level logs
45
91
  logger.setLevel(logging.WARNING)
46
92
 
47
- return logger
93
+ return logger
@@ -266,6 +266,7 @@ class Project(Protocol):
266
266
  f" user_id = {self.user_id},\n"
267
267
  f" priority = {self.priority}\n"
268
268
  f" status = {self.status}\n"
269
+ f" workorder = {self.workorder}\n"
269
270
  f" owner = {self.owner}\n"
270
271
  f" notion_page_id = {self.notion_page_id}\n"
271
272
  f")"
@@ -0,0 +1,100 @@
1
+ import warnings
2
+ import logging
3
+ from typing import Protocol, runtime_checkable
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+ @runtime_checkable
8
+ class Workorder(Protocol):
9
+ """This class represents a workorder and all of its associated metadata.
10
+
11
+ :param id: The Workorder ID e.g. R123456.
12
+ Often starts with R, M, or A depending on the workorder type.
13
+ :type id: str, required
14
+ :param user_id: The ID of the user to which the workorder belongs,
15
+ defaults to None.
16
+ :type user_id: str, optional
17
+ :param funder: The funding source, e.g. Internal or BBSRC, defaults to None.
18
+ :type funder: str, optional
19
+ :param trac_type: TRAC type, e.g. Internal FEC, defaults to None.
20
+ :type trac_type: str, optional
21
+ :param project_location: e.g. Internal or External, defaults to None.
22
+ :type project_location: str, optional
23
+ :param costing_rate: Charity or FEC, defaults to None.
24
+ :type costing_rate: str, optional
25
+ :param payment_type: DI or DA, defaults to None.
26
+ :type payment_type: str, optional
27
+ :param hourly_rate: The hourly rate to charge, defaults to None.
28
+ :type hourly_rate: float, optional
29
+ :param notion_page_id: The internal Notion page UUID for this workorder.
30
+ :type notion_page_id: str, optional
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ id: str = None,
36
+ user_id: str = None,
37
+ funder: str = None,
38
+ trac_type: str = None,
39
+ project_location: str = None,
40
+ costing_rate: str = None,
41
+ payment_type: str = None,
42
+ hourly_rate: float = None,
43
+ notion_page_id: str = None
44
+ ) -> None:
45
+ """Constructor method"""
46
+ logger.debug("Creating Workorder instance")
47
+ self.id = id
48
+ self.user_id = user_id
49
+ self.funder = funder
50
+ if trac_type is not None:
51
+ self.trac_type = trac_type
52
+ self.project_location, self.costing_rate = trac_type.split()
53
+ else:
54
+ self.project_location = project_location
55
+ self.costing_rate = costing_rate
56
+ self.trac_type = f"{project_location} {costing_rate}"
57
+ self.payment_type = payment_type
58
+ self.hourly_rate = hourly_rate
59
+ self.notion_page_id = notion_page_id
60
+ self.required_fields = [
61
+ "id",
62
+ "project_location",
63
+ "costing_rate",
64
+ "trac_type",
65
+ "payment_type",
66
+ "hourly_rate",
67
+ ]
68
+
69
+ logger.info(f"Workorder: '{self.id}' instance created")
70
+
71
+ def __str__(self) -> str:
72
+ """Machine-readable representation of class..
73
+
74
+ :return: basic Workorder() attrs.
75
+ :rtype: str
76
+ """
77
+ logger.debug("Calling Workorder.__str__ method")
78
+ return (
79
+ f"Workorder(\n"
80
+ f" id = {self.id},\n"
81
+ f" user_id = {self.user_id},\n"
82
+ f" funder = {self.funder},\n"
83
+ f" trac_type = {self.trac_type},\n"
84
+ f" project_location = {self.project_location},\n"
85
+ f" costing_rate = {self.costing_rate},\n"
86
+ f" payment_type = {self.payment_type},\n"
87
+ f" hourly_rate = {self.hourly_rate},\n"
88
+ f" notion_page_id = {self.notion_page_id}\n"
89
+ f")"
90
+ )
91
+
92
+ def is_complete(self) -> bool:
93
+ """Checks whether the workorder details are complete.
94
+
95
+ :return: True/False as appropriate
96
+ :rtype: bool
97
+ """
98
+ logger.debug("Calling Workorder.is_complete method")
99
+
100
+ return all(getattr(self, field) is not None for field in self.required_fields)
@@ -10,6 +10,7 @@ from pprint import pprint
10
10
  from papi.project import Project, get_project_ids, decompose_project_name
11
11
  from papi.task import Task
12
12
  from papi.user import User
13
+ from papi.workorder import Workorder
13
14
 
14
15
  logger = logging.getLogger(__name__)
15
16
 
@@ -599,6 +600,212 @@ class NotionWrapper(Protocol):
599
600
  logger.warning("No Notion users found")
600
601
  return users
601
602
 
603
+ def get_trac_costs(
604
+ self,
605
+ trac_costs_db_id: str,
606
+ key: str,
607
+ ) -> dict:
608
+ """
609
+ Fetches TRAC costs give a TRAC type key.
610
+
611
+ You must pass both of `trac_costs_db_id` and `key`.
612
+
613
+ :param trac_costs_db_id: ID of the Notion TRAC Costs database to query.
614
+ :type trac_costs_db_id: str
615
+ :param key: The TRAC type key e.g. Internal Charity.
616
+ :return: A dictionary with the TRAC costs data.
617
+ :rtype: dict
618
+ """
619
+ logger.debug("Calling NotionWrapper.get_trac_costs method")
620
+ headers = self._headers()
621
+ body = {
622
+ "page_size": 1,
623
+ "filter": {
624
+ "property": "key",
625
+ "title": {"equals": key}
626
+ }
627
+ }
628
+ url = f"https://api.notion.com/v1/databases/{trac_costs_db_id}/query"
629
+ request = lambda: self._post_with_retries(
630
+ url, headers=headers, json=body,
631
+ timeout=httpx.Timeout(5.0, read=15.0),
632
+ )
633
+ try:
634
+ resp = request()
635
+ resp.raise_for_status()
636
+ except httpx.ReadTimeout:
637
+ logger.error("Notion TRAC costs lookup timed out after retries")
638
+ return None
639
+ except httpx.HTTPError as err:
640
+ logger.error(f"Failed to fetch TRAC costs: {err!r}")
641
+ return None
642
+ # Extract the page dict, whether from query or retrieve
643
+ results = resp.json().get("results", [])
644
+ if not results:
645
+ logger.warning(f"No TRAC cost found with key={key!r}")
646
+ return None
647
+ page = results[0]
648
+ props = page.get("properties", {})
649
+ rate_raw = props["Rate"]["number"]
650
+ try:
651
+ rate = float(rate_raw)
652
+ except (TypeError, ValueError):
653
+ raise ValueError(f"TRAC rate must be convertible to float, got: {rate_raw}")
654
+ trac_cost = {key: rate}
655
+
656
+ return trac_cost
657
+
658
+ def get_workorder(
659
+ self,
660
+ workorders_db_id: str,
661
+ workorder_id: str | None = None,
662
+ notion_page_id: str | None = None,
663
+ trac_costs_db_id: str | None = None,
664
+ ) -> Workorder | None:
665
+ """
666
+ Fetches a single workorder, either by its workorder ID
667
+ or by its Notion page ID.
668
+
669
+ You must pass exactly one of `workorder_id` or `notion_page_id`.
670
+
671
+ :param workorders_db_id: ID of the Notion Workorders database to query.
672
+ :type workorders_db_id: str
673
+ :param workorder_id: The value of the "Workorder" property to match.
674
+ :type workorder_id: str or None
675
+ :param notion_page_id: The Notion page ID of the workorder to retrieve.
676
+ :type notion_page_id: str or None
677
+ :param trac_costs_db_id: ID of the Notion TRAC costs database to query.
678
+ :type trac_costs_db_id: str
679
+ :return: A Workorder instance if found, otherwise None.
680
+ :rtype: Workorder | None
681
+ """
682
+ logger.debug("Calling NotionWrapper.get_workorder method")
683
+ headers = self._headers()
684
+ if bool(workorder_id) == bool(notion_page_id):
685
+ logger.error("Must provide exactly one of workorder_id or notion_page_id")
686
+ return None
687
+ if workorder_id:
688
+ body = {
689
+ "page_size": 1,
690
+ "filter": {
691
+ "property": "Workorder",
692
+ "title": {"equals": workorder_id}
693
+ }
694
+ }
695
+ url = f"https://api.notion.com/v1/databases/{workorders_db_id}/query"
696
+ request = lambda: self._post_with_retries(
697
+ url, headers=headers, json=body,
698
+ timeout=httpx.Timeout(5.0, read=15.0),
699
+ )
700
+ else:
701
+ url = f"https://api.notion.com/v1/pages/{notion_page_id}"
702
+ request = lambda: httpx.get(
703
+ url, headers=headers,
704
+ timeout=httpx.Timeout(5.0, read=15.0),
705
+ )
706
+ try:
707
+ resp = request()
708
+ resp.raise_for_status()
709
+ except httpx.ReadTimeout:
710
+ logger.error("Notion workorder lookup timed out after retries")
711
+ return None
712
+ except httpx.HTTPError as err:
713
+ logger.error(f"Failed to fetch workorder: {err!r}")
714
+ return None
715
+ # Extract the page dict, whether from query or retrieve
716
+ if workorder_id:
717
+ results = resp.json().get("results", [])
718
+ if not results:
719
+ logger.warning(f"No workorder found with Workorder ID={workorder_id!r}")
720
+ return None
721
+ page = results[0]
722
+ else:
723
+ page = resp.json()
724
+
725
+ props = page.get("properties", {})
726
+ try:
727
+ notion_page_id = page["id"]
728
+ if props["Funder"]["select"] is not None:
729
+ funder = props["Funder"]["select"]["name"]
730
+ else:
731
+ funder = None
732
+ if props["Payment Type"]["select"] is not None:
733
+ payment_type = props["Payment Type"]["select"]["name"]
734
+ else:
735
+ payment_type = None
736
+ id = props["Workorder"]["title"][0]["plain_text"]
737
+ if len(props["Project Location"]["rollup"]["array"]):
738
+ project_location = props["Project Location"]["rollup"]["array"][0]["select"]["name"]
739
+ else:
740
+ project_location = None
741
+ if len(props["Costing"]["rollup"]["array"]):
742
+ costing_rate = props["Costing"]["rollup"]["array"][0]["select"]["name"]
743
+ else:
744
+ costing_rate = None
745
+ if len(props["Hourly Rate"]["rollup"]["array"]):
746
+ hourly_rate = props["Hourly Rate"]["rollup"]["array"][0]["number"]
747
+ else:
748
+ hourly_rate = None
749
+
750
+ workorder = Workorder(
751
+ id=id,
752
+ funder=funder,
753
+ project_location=project_location,
754
+ costing_rate=costing_rate,
755
+ payment_type=payment_type,
756
+ hourly_rate=hourly_rate,
757
+ notion_page_id=notion_page_id,
758
+ )
759
+ except (KeyError, IndexError) as e:
760
+ logger.error(f"Malformed workorder properties on page {nid!r}: {e!r}")
761
+ return None
762
+
763
+ # logger.info(f"Notion project loaded: {pid!r} → page {nid}")
764
+ return workorder
765
+
766
+ def get_workorders(self, workorders_db_id: str) -> list:
767
+ """Gets the Notion workorders from the Workorders database.
768
+
769
+ :param workorders_db_id: ID of the Notion Workorders database from which to get
770
+ the workorders.
771
+ :type workorders_db_id: str
772
+ :return: A list of Workorder instances from Notion.
773
+ :rtype: list
774
+ """
775
+ logger.debug("Calling NotionWrapper.get_workorders method")
776
+ headers = self._headers()
777
+ data = {}
778
+ r = httpx.post(
779
+ f"https://api.notion.com/v1/databases/{workorders_db_id}/query",
780
+ headers=headers,
781
+ json=data,
782
+ )
783
+ r_json = r.json()
784
+ workorders = []
785
+ for w in r_json["results"]:
786
+ notion_page_id = w["id"]
787
+ if w["properties"]["Funder"]["select"] is not None:
788
+ funder = w["properties"]["Funder"]["select"]["name"]
789
+ else:
790
+ funder = None
791
+ if w["properties"]["Payment Type"]["select"] is not None:
792
+ payment_type = w["properties"]["Payment Type"]["select"]["name"]
793
+ else:
794
+ payment_type = None
795
+ id = w["properties"]["Workorder"]["title"][0]["plain_text"]
796
+ workorder = Workorder(
797
+ id=id,
798
+ funder=funder,
799
+ payment_type=payment_type,
800
+ notion_page_id=notion_page_id,
801
+ )
802
+ if len(workorders):
803
+ logger.info(f"{len(workorders)} Notion workorders found")
804
+ else:
805
+ logger.warning("No Notion workorders found")
806
+ return workorders
807
+
808
+
602
809
  def check_project_exists(self, project: Project, projects_db_id: str) -> Project:
603
810
  """Checks whether a Notion project containing the specified
604
811
  project ID already exists. If a name containing that ID is found,
@@ -793,6 +1000,7 @@ class NotionWrapper(Protocol):
793
1000
  projects_db_id: str,
794
1001
  project_id: str | None = None,
795
1002
  notion_page_id: str | None = None,
1003
+ workorders_db_id: str | None = None,
796
1004
  ) -> Project | None:
797
1005
  """
798
1006
  Fetches a single project, either by its custom "Project ID" property
@@ -806,6 +1014,8 @@ class NotionWrapper(Protocol):
806
1014
  :type project_id: str or None
807
1015
  :param notion_page_id: The Notion page ID of the project to retrieve.
808
1016
  :type notion_page_id: str or None
1017
+ :param workorders_db_id: ID of the Notion Workorders database to query.
1018
+ :type workorders_db_id: str or None
809
1019
  :return: A Project instance if found, otherwise None.
810
1020
  :rtype: Project | None
811
1021
  """
@@ -867,13 +1077,27 @@ class NotionWrapper(Protocol):
867
1077
  priority = props["Priority"]["select"]["name"]
868
1078
  user_id = props["PI Code"]["rollup"]["array"][0]["rich_text"][0]["plain_text"]
869
1079
  nid = page["id"]
1080
+ if len(props["Workorder"]["relation"]):
1081
+ workorder_page_id = props["Workorder"]["relation"][0]["id"]
1082
+ else:
1083
+ workorder_page_id = None
870
1084
  except (KeyError, IndexError) as e:
871
1085
  logger.error(f"Malformed project properties on page {nid!r}: {e!r}")
872
1086
  return None
873
1087
 
1088
+ if workorders_db_id is not None and workorder_page_id is not None:
1089
+ workorder = self.get_workorder(workorders_db_id=workorders_db_id, notion_page_id=workorder_page_id)
1090
+ if workorder is not None:
1091
+ workorder_id = workorder.id
1092
+ else:
1093
+ workorder_id = None
1094
+ else:
1095
+ workorder_id = None
1096
+
874
1097
  project = Project(
875
1098
  id=pid,
876
1099
  name=name,
1100
+ workorder=workorder_id,
877
1101
  user_id=user_id,
878
1102
  owner=owner,
879
1103
  status=status,
@@ -1119,4 +1343,4 @@ class NotionWrapper(Protocol):
1119
1343
  created = r.json()
1120
1344
  task.notion_page_id = created.get("id")
1121
1345
  logger.info(f'Added task "{task.name!r}" with ID {task.notion_page_id} to {project_id}')
1122
- return task
1346
+ return task