papi-projects 0.2.3__tar.gz → 0.2.5__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: papi-projects
3
- Version: 0.2.3
3
+ Version: 0.2.5
4
4
  Summary: PAPI is an API for managing projects
5
5
  License: MIT
6
6
  Author: sandyjmacdonald
@@ -436,7 +436,8 @@ class TogglTrackWrapper(Protocol):
436
436
  self, start_time=None, end_time=pendulum.now().to_rfc3339_string()
437
437
  ) -> dict:
438
438
  """Gets all of the Toggl Track user's tracked hours for a given
439
- time period. If no end_time is given, then the current time is used.
439
+ time period. If no end_time is given, then the current time is used.
440
+ Only works with start dates up to a maximum of three months ago.
440
441
 
441
442
  :return: A dictionary containing the Toggl Track projects and hours tracked.
442
443
  :rtype: dict
@@ -449,16 +450,20 @@ class TogglTrackWrapper(Protocol):
449
450
  params={"start_date": start_time, "end_date": end_time},
450
451
  )
451
452
  times_json = r.json()
452
- times = {}
453
- for t in times_json:
454
- pid = t["pid"]
455
- seconds = t["duration"]
456
- hours = seconds / 60 / 60
457
- if pid not in times:
458
- times[pid] = hours
459
- else:
460
- times[pid] += hours
461
- return times
453
+ if isinstance(times_json, list):
454
+ times = {}
455
+ for t in times_json:
456
+ pid = t["pid"]
457
+ seconds = t["duration"]
458
+ hours = seconds / 60 / 60
459
+ if pid not in times:
460
+ times[pid] = hours
461
+ else:
462
+ times[pid] += hours
463
+ return times
464
+ else:
465
+ warnings.warn(times_json)
466
+ return None
462
467
  else:
463
468
  warnings.warn("Please provide a valid start date/time in RFC3339 format!")
464
469
  return None
@@ -618,6 +623,89 @@ class NotionWrapper(Protocol):
618
623
  self.api_secret = api_secret
619
624
  logger.info("NotionWrapper instance created")
620
625
 
626
+ def _headers(self) -> dict:
627
+ return {
628
+ "Authorization": f"Bearer {self.api_secret}",
629
+ "Notion-Version": "2022-06-28",
630
+ "Content-Type": "application/json",
631
+ }
632
+
633
+ def _fetch_template_page(self, template_page_id: str) -> dict:
634
+ """Retrieve the template page’s properties."""
635
+ r = httpx.get(
636
+ f"https://api.notion.com/v1/pages/{template_page_id}",
637
+ headers=self._headers()
638
+ )
639
+ r.raise_for_status()
640
+ return r.json()
641
+
642
+ def _fetch_template_blocks(self, template_page_id: str) -> list[dict]:
643
+ """Page through and collect all child blocks of the template."""
644
+ blocks: list[dict] = []
645
+ url = f"https://api.notion.com/v1/blocks/{template_page_id}/children"
646
+ params = {"page_size": 100}
647
+ while True:
648
+ r = httpx.get(url, headers=self._headers(), params=params)
649
+ r.raise_for_status()
650
+ js = r.json()
651
+ blocks.extend(js["results"])
652
+ if not js.get("has_more"):
653
+ break
654
+ params = {"start_cursor": js["next_cursor"], "page_size": 100}
655
+ return blocks
656
+
657
+ def _clean_properties(self, raw: dict) -> dict:
658
+ """Strip out IDs, types, rollup/formula wrappers, leaving only the API-legal values."""
659
+ clean = {}
660
+ for name, meta in raw.items():
661
+ t = meta.get("type")
662
+ if t == "title":
663
+ clean[name] = meta["title"]
664
+ elif t == "rich_text":
665
+ clean[name] = meta["rich_text"]
666
+ elif t == "select":
667
+ sel = meta.get("select")
668
+ if sel and sel.get("name"):
669
+ clean[name] = {"name": sel["name"]}
670
+ elif t == "multi_select":
671
+ clean[name] = [
672
+ {"name": m["name"]} for m in meta.get("multi_select", []) if m.get("name")
673
+ ]
674
+ elif t == "status":
675
+ st = meta.get("status")
676
+ if st and st.get("name"):
677
+ clean[name] = {"name": st["name"]}
678
+ elif t == "date":
679
+ d = meta.get("date")
680
+ if d and d.get("start"):
681
+ # include end only if present
682
+ clean[name] = {"start": d["start"], **({"end": d["end"]} if d.get("end") else {})}
683
+ elif t in ("people", "relation"):
684
+ # both use a list of {id:…}
685
+ key = t
686
+ clean[name] = [ {"id": x["id"]} for x in meta.get(key, []) if x.get("id") ]
687
+ # skip formula, rollup, files, etc.
688
+ return clean
689
+
690
+ def _clean_blocks(self, raw_blocks: list[dict]) -> list[dict]:
691
+ ALLOWED = {
692
+ "paragraph","heading_1","heading_2","heading_3",
693
+ "to_do","bulleted_list_item","numbered_list_item",
694
+ "toggle","quote","callout",
695
+ "image","video","file","embed","bookmark",
696
+ }
697
+ clean = []
698
+ for b in raw_blocks:
699
+ t = b["type"]
700
+ if t not in ALLOWED:
701
+ continue
702
+ minimal = { "type": t, t: b[t] }
703
+ if b.get("has_children"):
704
+ child_raw = self._fetch_template_blocks(b["id"])
705
+ minimal["children"] = self._clean_blocks(child_raw)
706
+ clean.append(minimal)
707
+ return clean
708
+
621
709
  def create_user(self, user: User, clients_db_id: str) -> str:
622
710
  """Creates a Notion client (user) from a `User` instance.
623
711
 
@@ -634,10 +722,7 @@ class NotionWrapper(Protocol):
634
722
  user_name = user.user_name
635
723
  user_id = user.user_id
636
724
  email = user.email
637
- headers = {
638
- "Authorization": f"Bearer {self.api_secret}",
639
- "Notion-Version": "2022-06-28",
640
- }
725
+ headers = self._headers()
641
726
  data = {
642
727
  "parent": {"database_id": clients_db_id},
643
728
  "properties": {
@@ -707,10 +792,7 @@ class NotionWrapper(Protocol):
707
792
  """
708
793
  logger.debug("Calling NotionWrapper.get_user_page_id method")
709
794
  user_id = user.user_id
710
- headers = {
711
- "Authorization": f"Bearer {self.api_secret}",
712
- "Notion-Version": "2022-06-28",
713
- }
795
+ headers = self._headers()
714
796
  data = {
715
797
  "filter": {
716
798
  "property": "Code",
@@ -744,10 +826,7 @@ class NotionWrapper(Protocol):
744
826
  :rtype: list
745
827
  """
746
828
  logger.debug("Calling NotionWrapper.get_users method")
747
- headers = {
748
- "Authorization": f"Bearer {self.api_secret}",
749
- "Notion-Version": "2022-06-28",
750
- }
829
+ headers = self._headers()
751
830
  data = {}
752
831
  r = httpx.post(
753
832
  f"https://api.notion.com/v1/databases/{clients_db_id}/query",
@@ -768,155 +847,80 @@ class NotionWrapper(Protocol):
768
847
  logger.warning("No Notion users found")
769
848
  return users
770
849
 
771
- def create_project(self, project: Project, user: User, projects_db_id: str, user_page_id=None) -> str:
772
- """Creates a new project on Notion.
773
-
850
+ def create_project(
851
+ self,
852
+ project: Project,
853
+ user: User,
854
+ projects_db_id: str,
855
+ user_page_id: str | None = None,
856
+ template_page_id: str | None = None,
857
+ ) -> str:
858
+ """
859
+ Creates a new project on Notion, optionally cloning from a database template.
860
+
774
861
  :param project: A Project instance.
775
- :type project: Project
776
862
  :param user: A User instance.
777
- :type user: User
778
- :param projects_db_id: ID of the Notion projects database in which to add the project.
779
- :type projects_db_id: str
780
- :param user_page_id: ID of the user in Notion, if they've been added. Can get this with
781
- get_user_page_id function.
782
- :type user_page_id: str
783
- :return: The page ID of the project created in Notion.
784
- :rtype: str
863
+ :param projects_db_id: ID of the Notion projects database.
864
+ :param user_page_id: ID of the user in Notion (for the PI relation).
865
+ :param template_page_id: If given, we’ll fetch that page’s properties & children
866
+ and use them as the base for this new record.
867
+ :return: The page ID of the created project.
785
868
  """
786
869
  logger.debug("Calling NotionWrapper.create_project method")
787
- project_id = project.id
788
- project_name = project.name
789
- user_id = user.user_id
790
- if user_page_id is not None:
791
- pi_relation = [{"id": user_page_id}]
870
+ headers = self._headers()
871
+
872
+ # 1) Fetch & clean template if requested
873
+ if template_page_id:
874
+ tpl_raw = self._fetch_template_page(template_page_id)
875
+ base_props = self._clean_properties(tpl_raw["properties"])
876
+ raw_blocks = self._fetch_template_blocks(template_page_id)
877
+ base_blocks = self._clean_blocks(raw_blocks)
792
878
  else:
793
- pi_relation = []
794
- headers = {
795
- "Authorization": f"Bearer {self.api_secret}",
796
- "Notion-Version": "2022-06-28",
879
+ base_props, base_blocks = {}, []
880
+
881
+ # 2) Build project-specific overrides in the exact shape Notion expects
882
+ overrides = {
883
+ "Project ID": [
884
+ {
885
+ "type": "text",
886
+ "text": {"content": project.id}
887
+ }
888
+ ],
889
+ "Description": [
890
+ {
891
+ "type": "text",
892
+ "text": {"content": project.name}
893
+ }
894
+ ],
895
+ "PI": [{"id": user_page_id}] if user_page_id else [],
896
+ # you can add more dynamic fields here (dates, selects, etc.)
797
897
  }
798
- data = {
799
- "parent": {"database_id": projects_db_id},
800
- "properties": {
801
- "Status": {
802
- "status": None,
803
- },
804
- "Description": {
805
- "type": "rich_text",
806
- "rich_text": [
807
- {
808
- "type": "text",
809
- "text": {
810
- "content": project_name,
811
- "link": None,
812
- },
813
- "annotations": {
814
- "bold": False,
815
- "italic": False,
816
- "strikethrough": False,
817
- "underline": False,
818
- "code": False,
819
- "color": "default",
820
- },
821
- "plain_text": project_name,
822
- "href": None,
823
- }
824
- ],
825
- },
826
- "Hourly Rate": {
827
- "type": "number",
828
- "number": 0.0, # Set an appropriate number
829
- },
830
- "Slug": {
831
- "type": "rich_text",
832
- "rich_text": [
833
- {
834
- "type": "text",
835
- "text": {"content": project_name, "link": None},
836
- "annotations": {
837
- "bold": False,
838
- "italic": False,
839
- "strikethrough": False,
840
- "underline": False,
841
- "code": False,
842
- "color": "default",
843
- },
844
- "plain_text": project_name,
845
- "href": None,
846
- }
847
- ],
848
- },
849
- "Type": {
850
- "type": "select", # Assuming Type is a select property
851
- "select": None,
852
- },
853
- "PI Code": {
854
- "type": "rich_text",
855
- "rich_text": [
856
- {
857
- "type": "text",
858
- "text": {"content": user_id, "link": None},
859
- "annotations": {
860
- "bold": False,
861
- "italic": False,
862
- "strikethrough": False,
863
- "underline": False,
864
- "code": False,
865
- "color": "default",
866
- },
867
- "plain_text": user_id,
868
- "href": None,
869
- }
870
- ],
871
- },
872
- "Priority": {
873
- "type": "select",
874
- "select": None,
875
- },
876
- "Tags": {
877
- "type": "multi_select",
878
- "multi_select": [],
879
- },
880
- "PI": {
881
- "type": "relation",
882
- "relation": pi_relation,
883
- "has_more": False,
884
- },
885
- "Start Date": {
886
- "type": "date",
887
- # "date": {"start": "2024-09-12", "end": None, "time_zone": None},
888
- "date": None,
889
- },
890
- "Owner": {
891
- "type": "people",
892
- "people": [],
893
- },
894
- "Project ID": {
895
- "type": "title",
896
- "title": [
897
- {
898
- "type": "text",
899
- "text": {"content": project_id, "link": None},
900
- "annotations": {
901
- "bold": False,
902
- "italic": False,
903
- "strikethrough": False,
904
- "underline": False,
905
- "code": False,
906
- "color": "default",
907
- },
908
- "plain_text": project_id,
909
- "href": None,
910
- }
911
- ],
912
- },
913
- },
898
+
899
+ # 3) Merge so overrides win
900
+ merged_props = {**base_props, **overrides}
901
+
902
+ # 4) Build payload
903
+ payload: dict = {
904
+ "parent": {"database_id": projects_db_id},
905
+ "properties": merged_props,
914
906
  }
915
- r = httpx.post("https://api.notion.com/v1/pages", headers=headers, json=data)
916
- r_json = r.json()
917
- page_id = r_json["id"]
918
- logger.info(f"Notion project created with page ID {page_id}")
919
- return page_id
907
+ if base_blocks:
908
+ payload["children"] = base_blocks
909
+
910
+ # 5) Send and handle errors
911
+ r = httpx.post("https://api.notion.com/v1/pages", headers=headers, json=payload)
912
+ try:
913
+ r.raise_for_status()
914
+ except httpx.HTTPStatusError:
915
+ print("→ Request JSON:")
916
+ print(json.dumps(payload, indent=2))
917
+ print("→ Notion error response:")
918
+ print(r.json())
919
+ raise
920
+
921
+ new_page_id = r.json()["id"]
922
+ logger.info(f"Notion project cloned from template as page ID {new_page_id}")
923
+ return new_page_id
920
924
 
921
925
  def get_projects(self, projects_db_id: str) -> list:
922
926
  """Gets the Notion projects from the Clients database.
@@ -928,10 +932,7 @@ class NotionWrapper(Protocol):
928
932
  :rtype: list
929
933
  """
930
934
  logger.debug("Calling NotionWrapper.get_projects method")
931
- headers = {
932
- "Authorization": f"Bearer {self.api_secret}",
933
- "Notion-Version": "2022-06-28",
934
- }
935
+ headers = self._headers()
935
936
  data = {}
936
937
  r = httpx.post(
937
938
  f"https://api.notion.com/v1/databases/{projects_db_id}/query",
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "papi-projects"
3
- version = "0.2.3"
3
+ version = "0.2.5"
4
4
  description = "PAPI is an API for managing projects"
5
5
  authors = ["sandyjmacdonald <sandyjmacdonald@gmail.com>"]
6
6
  license = "MIT"
@@ -0,0 +1,72 @@
1
+ import argparse
2
+ import pendulum
3
+ import warnings
4
+ from papi.wrappers import TogglTrackWrapper
5
+ from papi import config
6
+
7
+ def main():
8
+ """Main function of collate-toggl-hours script"""
9
+
10
+ # Set up argparse
11
+ parser = argparse.ArgumentParser()
12
+ parser.add_argument(
13
+ "-s", "--start", type=str, help="start date in YYYY-MM-DD format", required=True
14
+ )
15
+ parser.add_argument(
16
+ "-e", "--end", type=str, help="end date in YYYY-MM-DD format, if none supplied then end date is now", default=False
17
+ )
18
+ parser.add_argument(
19
+ "-o",
20
+ "--output",
21
+ type=str,
22
+ help="output TSV filename, omit to write to stdout",
23
+ default=False,
24
+ )
25
+ args = parser.parse_args()
26
+
27
+ # Set up start/end date
28
+ start_date = args.start
29
+ start_time = pendulum.parse(start_date).to_rfc3339_string()
30
+ end_date = args.end
31
+ if not end_date:
32
+ end_time = pendulum.now().to_rfc3339_string()
33
+ else:
34
+ end_time = pendulum.parse(end_date).to_rfc3339_string()
35
+
36
+ # Check that start time is not more than 3 months ago
37
+ if pendulum.now() < pendulum.parse(start_date).add(months=3):
38
+ # Set up Toggl Track API wrapper
39
+ #
40
+ # NOTE: you must have added Toggl Track API key and password, with
41
+ # variable names below to .env file in this directory
42
+ toggl_api_key = config["TOGGL_TRACK_API_KEY"]
43
+ toggl_api_password = config["TOGGL_TRACK_PASSWORD"]
44
+ toggl = TogglTrackWrapper(toggl_api_key, toggl_api_password)
45
+
46
+ # Tell wrapper which workspace to set as default
47
+ toggl_workspace = config["TOGGL_TRACK_WORKSPACE"]
48
+ toggl.set_default_workspace(toggl_workspace)
49
+ toggl.set_me()
50
+
51
+ # Get tracked hours and tracked project IDs/names
52
+
53
+ tracked_hours = toggl.get_user_hours(start_time=start_time, end_time=end_time)
54
+ projects = {p["id"]: p["name"] for p in toggl.get_user_projects() if p["id"] in tracked_hours}
55
+ hours_per_project = [(projects[t], tracked_hours[t]) for t in tracked_hours]
56
+
57
+ output = args.output
58
+
59
+ if output:
60
+ # If output filename provided, write to file
61
+ with open(output, "w") as out:
62
+ for h in sorted(hours_per_project, key=lambda x:x[1], reverse=True):
63
+ out.write(f"{h[0]}\t{h[1]}\n")
64
+ else:
65
+ # Otherwise, print out project names and tracked hours to stdout
66
+ for h in sorted(hours_per_project, key=lambda x:x[1], reverse=True):
67
+ print(f"{h[0]}\t{h[1]}")
68
+ else:
69
+ warnings.warn("Start time must not be more than 3 months ago!")
70
+
71
+ if __name__ == "__main__":
72
+ main()
@@ -39,12 +39,13 @@ def main():
39
39
  #
40
40
  # NOTE: you must have added your Notion API credentials and user and projects
41
41
  # database IDs with variable names below to .env file in this directory
42
- if "NOTION_API_SECRET"not in config and "NOTION_CLIENTS_DB" not in config and "NOTION_PROJECTS_DB" not in config:
42
+ if "NOTION_API_SECRET" not in config and "NOTION_CLIENTS_DB" not in config and "NOTION_PROJECTS_DB" not in config:
43
43
  logger.warning("Please create a .env file with NOTION_API_SECRET and NOTION_CLIENTS_DB and NOTION_PROJECTS_DB set!")
44
44
  return
45
45
  notion_api_secret = config["NOTION_API_SECRET"]
46
46
  notion_clients_db = config["NOTION_CLIENTS_DB"]
47
47
  notion_projects_db = config["NOTION_PROJECTS_DB"]
48
+ notion_template_page_id = config["NOTION_TEMPLATE_PAGE_ID"]
48
49
  notion = NotionWrapper(notion_api_secret)
49
50
 
50
51
  user_id = args.user_id
@@ -75,9 +76,9 @@ def main():
75
76
  # Create project on Notion
76
77
  user_page_id = notion.get_user_page_id(notion_clients_db, user)
77
78
  if user_page_id is not None:
78
- notion_proj_id = notion.create_project(project, user, notion_projects_db, user_page_id=user_page_id)
79
+ notion_proj_id = notion.create_project(project, user, notion_projects_db, user_page_id=user_page_id, template_page_id=notion_template_page_id)
79
80
  else:
80
- notion_proj_id = notion.create_project(project, user, notion_projects_db)
81
+ notion_proj_id = notion.create_project(project, user, notion_projects_db, template_page_id=notion_template_page_id)
81
82
 
82
83
  pyperclip.copy(project.id)
83
84
 
@@ -159,14 +159,15 @@ def main():
159
159
  notion_api_secret = config["NOTION_API_SECRET"]
160
160
  notion_clients_db = config["NOTION_CLIENTS_DB"]
161
161
  notion_projects_db = config["NOTION_PROJECTS_DB"]
162
+ notion_template_page_id = config["NOTION_TEMPLATE_PAGE_ID"]
162
163
  notion = NotionWrapper(notion_api_secret)
163
164
 
164
165
  # Create project on Notion
165
166
  user_page_id = notion.get_user_page_id(notion_clients_db, user)
166
167
  if user_page_id is not None:
167
- notion_proj_id = notion.create_project(project, user, notion_projects_db, user_page_id=user_page_id)
168
+ notion_proj_id = notion.create_project(project, user, notion_projects_db, user_page_id=user_page_id, template_page_id=notion_template_page_id)
168
169
  else:
169
- notion_proj_id = notion.create_project(project, user, notion_projects_db)
170
+ notion_proj_id = notion.create_project(project, user, notion_projects_db, template_page_id=notion_template_page_id)
170
171
 
171
172
  pyperclip.copy(project.id)
172
173
 
@@ -1,66 +0,0 @@
1
- import argparse
2
- import pendulum
3
- from papi.wrappers import TogglTrackWrapper
4
- from papi import config
5
-
6
- def main():
7
- """Main function of collate-toggl-hours script"""
8
-
9
- # Set up argparse
10
- parser = argparse.ArgumentParser()
11
- parser.add_argument(
12
- "-s", "--start", type=str, help="start date in YYYY-MM-DD format", required=True
13
- )
14
- parser.add_argument(
15
- "-e", "--end", type=str, help="end date in YYYY-MM-DD format, if none supplied then end date is now", default=False
16
- )
17
- parser.add_argument(
18
- "-o",
19
- "--output",
20
- type=str,
21
- help="output TSV filename, omit to write to stdout",
22
- default=False,
23
- )
24
- args = parser.parse_args()
25
-
26
- # Set up start/end date
27
- start_date = args.start
28
- start_time = pendulum.parse(start_date).to_rfc3339_string()
29
- end_date = args.end
30
- if not end_date:
31
- end_time = pendulum.now().to_rfc3339_string()
32
- else:
33
- end_time = pendulum.parse(end_date).to_rfc3339_string()
34
-
35
- # Set up Toggl Track API wrapper
36
- #
37
- # NOTE: you must have added Toggl Track API key and password, with
38
- # variable names below to .env file in this directory
39
- toggl_api_key = config["TOGGL_TRACK_API_KEY"]
40
- toggl_api_password = config["TOGGL_TRACK_PASSWORD"]
41
- toggl = TogglTrackWrapper(toggl_api_key, toggl_api_password)
42
-
43
- # Tell wrapper which workspace to set as default
44
- toggl_workspace = config["TOGGL_TRACK_WORKSPACE"]
45
- toggl.set_default_workspace(toggl_workspace)
46
- toggl.set_me()
47
-
48
- # Get tracked hours and tracked project IDs/names
49
- tracked_hours = toggl.get_user_hours(start_time=start_time, end_time=end_time)
50
- projects = {p["id"]: p["name"] for p in toggl.get_user_projects() if p["id"] in tracked_hours}
51
- hours_per_project = [(projects[t], tracked_hours[t]) for t in tracked_hours]
52
-
53
- output = args.output
54
-
55
- if output:
56
- # If output filename provided, write to file
57
- with open(output, "w") as out:
58
- for h in sorted(hours_per_project, key=lambda x:x[1], reverse=True):
59
- out.write(f"{h[0]}\t{h[1]}\n")
60
- else:
61
- # Otherwise, print out project names and tracked hours to stdout
62
- for h in sorted(hours_per_project, key=lambda x:x[1], reverse=True):
63
- print(f"{h[0]}\t{h[1]}")
64
-
65
- if __name__ == "__main__":
66
- main()
File without changes