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.
- {papi_projects-0.2.4 → papi_projects-0.2.5}/PKG-INFO +1 -1
- {papi_projects-0.2.4 → papi_projects-0.2.5}/papi/wrappers.py +154 -158
- {papi_projects-0.2.4 → papi_projects-0.2.5}/pyproject.toml +1 -1
- {papi_projects-0.2.4 → papi_projects-0.2.5}/scripts/create_notion_project.py +4 -3
- {papi_projects-0.2.4 → papi_projects-0.2.5}/scripts/create_project.py +3 -2
- {papi_projects-0.2.4 → papi_projects-0.2.5}/README.md +0 -0
- {papi_projects-0.2.4 → papi_projects-0.2.5}/papi/__init__.py +0 -0
- {papi_projects-0.2.4 → papi_projects-0.2.5}/papi/mocks.py +0 -0
- {papi_projects-0.2.4 → papi_projects-0.2.5}/papi/project.py +0 -0
- {papi_projects-0.2.4 → papi_projects-0.2.5}/papi/tests/__init__.py +0 -0
- {papi_projects-0.2.4 → papi_projects-0.2.5}/papi/tests/test_project.py +0 -0
- {papi_projects-0.2.4 → papi_projects-0.2.5}/papi/tests/test_user.py +0 -0
- {papi_projects-0.2.4 → papi_projects-0.2.5}/papi/tests/test_userdb.json +0 -0
- {papi_projects-0.2.4 → papi_projects-0.2.5}/papi/tests/test_wrappers.py +0 -0
- {papi_projects-0.2.4 → papi_projects-0.2.5}/papi/user.py +0 -0
- {papi_projects-0.2.4 → papi_projects-0.2.5}/scripts/__init__.py +0 -0
- {papi_projects-0.2.4 → papi_projects-0.2.5}/scripts/collate_toggl_hours.py +0 -0
- {papi_projects-0.2.4 → papi_projects-0.2.5}/scripts/create_toggl_project.py +0 -0
|
@@ -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(
|
|
777
|
-
|
|
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
|
-
:
|
|
783
|
-
:param
|
|
784
|
-
:
|
|
785
|
-
|
|
786
|
-
|
|
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
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
if
|
|
796
|
-
|
|
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
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
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
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
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
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
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",
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|