papi-projects 0.3.1__tar.gz → 0.4.0__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.0}/PKG-INFO +10 -16
  2. {papi_projects-0.3.1 → papi_projects-0.4.0}/papi/__init__.py +50 -4
  3. {papi_projects-0.3.1 → papi_projects-0.4.0}/papi/project.py +1 -0
  4. papi_projects-0.4.0/papi/workorder.py +100 -0
  5. {papi_projects-0.3.1 → papi_projects-0.4.0}/papi/wrappers.py +227 -1
  6. papi_projects-0.4.0/papi_projects.egg-info/PKG-INFO +370 -0
  7. papi_projects-0.4.0/papi_projects.egg-info/SOURCES.txt +26 -0
  8. papi_projects-0.4.0/papi_projects.egg-info/dependency_links.txt +1 -0
  9. papi_projects-0.4.0/papi_projects.egg-info/entry_points.txt +7 -0
  10. papi_projects-0.4.0/papi_projects.egg-info/requires.txt +5 -0
  11. papi_projects-0.4.0/papi_projects.egg-info/top_level.txt +2 -0
  12. papi_projects-0.4.0/pyproject.toml +32 -0
  13. papi_projects-0.4.0/scripts/generate_timesheet.py +133 -0
  14. papi_projects-0.4.0/scripts/test_notion.py +30 -0
  15. papi_projects-0.4.0/setup.cfg +4 -0
  16. papi_projects-0.4.0/tests/test_project.py +142 -0
  17. papi_projects-0.4.0/tests/test_task.py +92 -0
  18. papi_projects-0.4.0/tests/test_user.py +133 -0
  19. papi_projects-0.4.0/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.0}/README.md +0 -0
  22. {papi_projects-0.3.1 → papi_projects-0.4.0}/papi/task.py +0 -0
  23. {papi_projects-0.3.1 → papi_projects-0.4.0}/papi/user.py +0 -0
  24. {papi_projects-0.3.1 → papi_projects-0.4.0}/scripts/__init__.py +0 -0
  25. {papi_projects-0.3.1 → papi_projects-0.4.0}/scripts/collate_toggl_hours.py +0 -0
  26. {papi_projects-0.3.1 → papi_projects-0.4.0}/scripts/create_notion_project.py +0 -0
  27. {papi_projects-0.3.1 → papi_projects-0.4.0}/scripts/create_notion_task.py +0 -0
  28. {papi_projects-0.3.1 → papi_projects-0.4.0}/scripts/create_project.py +0 -0
  29. {papi_projects-0.3.1 → papi_projects-0.4.0}/scripts/create_toggl_project.py +0 -0
@@ -1,21 +1,16 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: papi-projects
3
- Version: 0.3.1
3
+ Version: 0.4.0
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
19
14
 
20
15
  PAPI is an API for managing projects.
21
16
 
@@ -373,4 +368,3 @@ usr.user_id = "JS1"
373
368
  ```
374
369
 
375
370
  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,213 @@ 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
+ print(workorder)
803
+ if len(workorders):
804
+ logger.info(f"{len(workorders)} Notion workorders found")
805
+ else:
806
+ logger.warning("No Notion workorders found")
807
+ return workorders
808
+
809
+
602
810
  def check_project_exists(self, project: Project, projects_db_id: str) -> Project:
603
811
  """Checks whether a Notion project containing the specified
604
812
  project ID already exists. If a name containing that ID is found,
@@ -766,6 +974,7 @@ class NotionWrapper(Protocol):
766
974
  project_owner =[owner["name"] for owner in p["properties"]["Owner"]["people"]]
767
975
  project_status = p["properties"]["Status"]["status"]["name"]
768
976
  project_priority = p["properties"]["Priority"]["select"]["name"]
977
+ print(p["properties"])
769
978
  notion_page_id = p["id"]
770
979
  if "TEMPLATE" not in project_name:
771
980
  user_id = p["properties"]["PI Code"]["rollup"]["array"][0]["rich_text"][0][
@@ -793,6 +1002,7 @@ class NotionWrapper(Protocol):
793
1002
  projects_db_id: str,
794
1003
  project_id: str | None = None,
795
1004
  notion_page_id: str | None = None,
1005
+ workorders_db_id: str | None = None,
796
1006
  ) -> Project | None:
797
1007
  """
798
1008
  Fetches a single project, either by its custom "Project ID" property
@@ -806,6 +1016,8 @@ class NotionWrapper(Protocol):
806
1016
  :type project_id: str or None
807
1017
  :param notion_page_id: The Notion page ID of the project to retrieve.
808
1018
  :type notion_page_id: str or None
1019
+ :param workorders_db_id: ID of the Notion Workorders database to query.
1020
+ :type workorders_db_id: str or None
809
1021
  :return: A Project instance if found, otherwise None.
810
1022
  :rtype: Project | None
811
1023
  """
@@ -867,13 +1079,27 @@ class NotionWrapper(Protocol):
867
1079
  priority = props["Priority"]["select"]["name"]
868
1080
  user_id = props["PI Code"]["rollup"]["array"][0]["rich_text"][0]["plain_text"]
869
1081
  nid = page["id"]
1082
+ if len(props["Workorder"]["relation"]):
1083
+ workorder_page_id = props["Workorder"]["relation"][0]["id"]
1084
+ else:
1085
+ workorder_page_id = None
870
1086
  except (KeyError, IndexError) as e:
871
1087
  logger.error(f"Malformed project properties on page {nid!r}: {e!r}")
872
1088
  return None
873
1089
 
1090
+ if workorders_db_id is not None and workorder_page_id is not None:
1091
+ workorder = self.get_workorder(workorders_db_id=workorders_db_id, notion_page_id=workorder_page_id)
1092
+ if workorder is not None:
1093
+ workorder_id = workorder.id
1094
+ else:
1095
+ workorder_id = None
1096
+ else:
1097
+ workorder_id = None
1098
+
874
1099
  project = Project(
875
1100
  id=pid,
876
1101
  name=name,
1102
+ workorder=workorder_id,
877
1103
  user_id=user_id,
878
1104
  owner=owner,
879
1105
  status=status,
@@ -1119,4 +1345,4 @@ class NotionWrapper(Protocol):
1119
1345
  created = r.json()
1120
1346
  task.notion_page_id = created.get("id")
1121
1347
  logger.info(f'Added task "{task.name!r}" with ID {task.notion_page_id} to {project_id}')
1122
- return task
1348
+ return task