papi-projects 0.2.4__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.4
3
+ Version: 0.2.5
4
4
  Summary: PAPI is an API for managing projects
5
5
  License: MIT
6
6
  Author: sandyjmacdonald
@@ -623,6 +623,89 @@ class NotionWrapper(Protocol):
623
623
  self.api_secret = api_secret
624
624
  logger.info("NotionWrapper instance created")
625
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
+
626
709
  def create_user(self, user: User, clients_db_id: str) -> str:
627
710
  """Creates a Notion client (user) from a `User` instance.
628
711
 
@@ -639,10 +722,7 @@ class NotionWrapper(Protocol):
639
722
  user_name = user.user_name
640
723
  user_id = user.user_id
641
724
  email = user.email
642
- headers = {
643
- "Authorization": f"Bearer {self.api_secret}",
644
- "Notion-Version": "2022-06-28",
645
- }
725
+ headers = self._headers()
646
726
  data = {
647
727
  "parent": {"database_id": clients_db_id},
648
728
  "properties": {
@@ -712,10 +792,7 @@ class NotionWrapper(Protocol):
712
792
  """
713
793
  logger.debug("Calling NotionWrapper.get_user_page_id method")
714
794
  user_id = user.user_id
715
- headers = {
716
- "Authorization": f"Bearer {self.api_secret}",
717
- "Notion-Version": "2022-06-28",
718
- }
795
+ headers = self._headers()
719
796
  data = {
720
797
  "filter": {
721
798
  "property": "Code",
@@ -749,10 +826,7 @@ class NotionWrapper(Protocol):
749
826
  :rtype: list
750
827
  """
751
828
  logger.debug("Calling NotionWrapper.get_users method")
752
- headers = {
753
- "Authorization": f"Bearer {self.api_secret}",
754
- "Notion-Version": "2022-06-28",
755
- }
829
+ headers = self._headers()
756
830
  data = {}
757
831
  r = httpx.post(
758
832
  f"https://api.notion.com/v1/databases/{clients_db_id}/query",
@@ -773,155 +847,80 @@ class NotionWrapper(Protocol):
773
847
  logger.warning("No Notion users found")
774
848
  return users
775
849
 
776
- def create_project(self, project: Project, user: User, projects_db_id: str, user_page_id=None) -> str:
777
- """Creates a new project on Notion.
778
-
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
+
779
861
  :param project: A Project instance.
780
- :type project: Project
781
862
  :param user: A User instance.
782
- :type user: User
783
- :param projects_db_id: ID of the Notion projects database in which to add the project.
784
- :type projects_db_id: str
785
- :param user_page_id: ID of the user in Notion, if they've been added. Can get this with
786
- get_user_page_id function.
787
- :type user_page_id: str
788
- :return: The page ID of the project created in Notion.
789
- :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.
790
868
  """
791
869
  logger.debug("Calling NotionWrapper.create_project method")
792
- project_id = project.id
793
- project_name = project.name
794
- user_id = user.user_id
795
- if user_page_id is not None:
796
- 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)
797
878
  else:
798
- pi_relation = []
799
- headers = {
800
- "Authorization": f"Bearer {self.api_secret}",
801
- "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.)
802
897
  }
803
- data = {
804
- "parent": {"database_id": projects_db_id},
805
- "properties": {
806
- "Status": {
807
- "status": None,
808
- },
809
- "Description": {
810
- "type": "rich_text",
811
- "rich_text": [
812
- {
813
- "type": "text",
814
- "text": {
815
- "content": project_name,
816
- "link": None,
817
- },
818
- "annotations": {
819
- "bold": False,
820
- "italic": False,
821
- "strikethrough": False,
822
- "underline": False,
823
- "code": False,
824
- "color": "default",
825
- },
826
- "plain_text": project_name,
827
- "href": None,
828
- }
829
- ],
830
- },
831
- "Hourly Rate": {
832
- "type": "number",
833
- "number": 0.0, # Set an appropriate number
834
- },
835
- "Slug": {
836
- "type": "rich_text",
837
- "rich_text": [
838
- {
839
- "type": "text",
840
- "text": {"content": project_name, "link": None},
841
- "annotations": {
842
- "bold": False,
843
- "italic": False,
844
- "strikethrough": False,
845
- "underline": False,
846
- "code": False,
847
- "color": "default",
848
- },
849
- "plain_text": project_name,
850
- "href": None,
851
- }
852
- ],
853
- },
854
- "Type": {
855
- "type": "select", # Assuming Type is a select property
856
- "select": None,
857
- },
858
- "PI Code": {
859
- "type": "rich_text",
860
- "rich_text": [
861
- {
862
- "type": "text",
863
- "text": {"content": user_id, "link": None},
864
- "annotations": {
865
- "bold": False,
866
- "italic": False,
867
- "strikethrough": False,
868
- "underline": False,
869
- "code": False,
870
- "color": "default",
871
- },
872
- "plain_text": user_id,
873
- "href": None,
874
- }
875
- ],
876
- },
877
- "Priority": {
878
- "type": "select",
879
- "select": None,
880
- },
881
- "Tags": {
882
- "type": "multi_select",
883
- "multi_select": [],
884
- },
885
- "PI": {
886
- "type": "relation",
887
- "relation": pi_relation,
888
- "has_more": False,
889
- },
890
- "Start Date": {
891
- "type": "date",
892
- # "date": {"start": "2024-09-12", "end": None, "time_zone": None},
893
- "date": None,
894
- },
895
- "Owner": {
896
- "type": "people",
897
- "people": [],
898
- },
899
- "Project ID": {
900
- "type": "title",
901
- "title": [
902
- {
903
- "type": "text",
904
- "text": {"content": project_id, "link": None},
905
- "annotations": {
906
- "bold": False,
907
- "italic": False,
908
- "strikethrough": False,
909
- "underline": False,
910
- "code": False,
911
- "color": "default",
912
- },
913
- "plain_text": project_id,
914
- "href": None,
915
- }
916
- ],
917
- },
918
- },
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,
919
906
  }
920
- r = httpx.post("https://api.notion.com/v1/pages", headers=headers, json=data)
921
- r_json = r.json()
922
- page_id = r_json["id"]
923
- logger.info(f"Notion project created with page ID {page_id}")
924
- 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
925
924
 
926
925
  def get_projects(self, projects_db_id: str) -> list:
927
926
  """Gets the Notion projects from the Clients database.
@@ -933,10 +932,7 @@ class NotionWrapper(Protocol):
933
932
  :rtype: list
934
933
  """
935
934
  logger.debug("Calling NotionWrapper.get_projects method")
936
- headers = {
937
- "Authorization": f"Bearer {self.api_secret}",
938
- "Notion-Version": "2022-06-28",
939
- }
935
+ headers = self._headers()
940
936
  data = {}
941
937
  r = httpx.post(
942
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.4"
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"
@@ -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
 
File without changes