Add files using upload-large-folder tool
Browse filesThis view is limited to 50 files because it contains too many changes. Β See raw diff
- tasks/notion/easy/computer_science_student_dashboard/simple__code_snippets_go/description.md +27 -0
- tasks/notion/easy/computer_science_student_dashboard/simple__code_snippets_go/meta.json +24 -0
- tasks/notion/easy/computer_science_student_dashboard/simple__code_snippets_go/verify.py +125 -0
- tasks/notion/easy/computer_science_student_dashboard/simple__study_session_tracker/description.md +4 -0
- tasks/notion/easy/computer_science_student_dashboard/simple__study_session_tracker/meta.json +24 -0
- tasks/notion/easy/computer_science_student_dashboard/simple__study_session_tracker/verify.py +132 -0
- tasks/notion/easy/it_trouble_shooting_hub/simple__asset_retirement_migration/description.md +11 -0
- tasks/notion/easy/it_trouble_shooting_hub/simple__asset_retirement_migration/meta.json +26 -0
- tasks/notion/easy/it_trouble_shooting_hub/simple__asset_retirement_migration/verify.py +143 -0
- tasks/notion/easy/japan_travel_planner/simple__remove_osaka_itinerary/description.md +1 -0
- tasks/notion/easy/japan_travel_planner/simple__remove_osaka_itinerary/meta.json +23 -0
- tasks/notion/easy/japan_travel_planner/simple__remove_osaka_itinerary/verify.py +288 -0
- tasks/notion/easy/online_resume/simple__skills_development_tracker/description.md +19 -0
- tasks/notion/easy/online_resume/simple__skills_development_tracker/meta.json +27 -0
- tasks/notion/easy/online_resume/simple__skills_development_tracker/verify.py +206 -0
- tasks/notion/easy/python_roadmap/simple__expert_level_lessons/description.md +30 -0
- tasks/notion/easy/python_roadmap/simple__expert_level_lessons/meta.json +26 -0
- tasks/notion/easy/python_roadmap/simple__expert_level_lessons/verify.py +234 -0
- tasks/notion/easy/self_assessment/simple__faq_column_layout/description.md +6 -0
- tasks/notion/easy/self_assessment/simple__faq_column_layout/meta.json +24 -0
- tasks/notion/easy/self_assessment/simple__faq_column_layout/verify.py +161 -0
- tasks/notion/easy/standard_operating_procedure/simple__section_organization/description.md +10 -0
- tasks/notion/easy/standard_operating_procedure/simple__section_organization/meta.json +24 -0
- tasks/notion/easy/standard_operating_procedure/simple__section_organization/verify.py +76 -0
- tasks/notion/easy/team_projects/simple__swap_tasks/description.md +1 -0
- tasks/notion/easy/team_projects/simple__swap_tasks/meta.json +24 -0
- tasks/notion/easy/team_projects/simple__swap_tasks/verify.py +215 -0
- tasks/notion/easy/toronto_guide/simple__change_color/description.md +7 -0
- tasks/notion/easy/toronto_guide/simple__change_color/meta.json +23 -0
- tasks/notion/easy/toronto_guide/simple__change_color/verify.py +100 -0
- tasks/notion/standard/company_in_a_box/employee_onboarding/description.md +15 -0
- tasks/notion/standard/company_in_a_box/employee_onboarding/meta.json +25 -0
- tasks/notion/standard/company_in_a_box/employee_onboarding/verify.py +177 -0
- tasks/notion/standard/company_in_a_box/goals_restructure/description.md +17 -0
- tasks/notion/standard/company_in_a_box/goals_restructure/meta.json +23 -0
- tasks/notion/standard/company_in_a_box/goals_restructure/verify.py +212 -0
- tasks/notion/standard/company_in_a_box/quarterly_review_dashboard/description.md +19 -0
- tasks/notion/standard/company_in_a_box/quarterly_review_dashboard/meta.json +26 -0
- tasks/notion/standard/company_in_a_box/quarterly_review_dashboard/verify.py +202 -0
- tasks/notion/standard/it_trouble_shooting_hub/asset_retirement_migration/description.md +25 -0
- tasks/notion/standard/it_trouble_shooting_hub/asset_retirement_migration/meta.json +26 -0
- tasks/notion/standard/it_trouble_shooting_hub/asset_retirement_migration/verify.py +226 -0
- tasks/notion/standard/it_trouble_shooting_hub/security_audit_ticket/description.md +21 -0
- tasks/notion/standard/it_trouble_shooting_hub/security_audit_ticket/meta.json +25 -0
- tasks/notion/standard/it_trouble_shooting_hub/security_audit_ticket/verify.py +176 -0
- tasks/notion/standard/it_trouble_shooting_hub/verification_expired_update/description.md +17 -0
- tasks/notion/standard/it_trouble_shooting_hub/verification_expired_update/meta.json +26 -0
- tasks/notion/standard/it_trouble_shooting_hub/verification_expired_update/verify.py +195 -0
- tasks/notion/standard/japan_travel_planner/daily_itinerary_overview/description.md +14 -0
- tasks/notion/standard/japan_travel_planner/daily_itinerary_overview/meta.json +25 -0
tasks/notion/easy/computer_science_student_dashboard/simple__code_snippets_go/description.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Find the page named "Computer Science Student Dashboard" and extend the **Code Snippets** section with Go content.
|
| 2 |
+
|
| 3 |
+
**Task Requirements:**
|
| 4 |
+
1. Add a bold paragraph that contains exactly the text `Go` to mark the start of the Go snippets.
|
| 5 |
+
2. Directly under that heading, add three code blocks configured with `language` set to **go**:
|
| 6 |
+
a. **Basic Go program** β Caption must be `Basic Go program` and the code content must be exactly:
|
| 7 |
+
```go
|
| 8 |
+
package main
|
| 9 |
+
|
| 10 |
+
import "fmt"
|
| 11 |
+
|
| 12 |
+
func main() {
|
| 13 |
+
fmt.Println("Hello, World!")
|
| 14 |
+
}
|
| 15 |
+
```
|
| 16 |
+
b. **For loop in Go** β Caption must be `For loop in Go` and the code content must be exactly:
|
| 17 |
+
```go
|
| 18 |
+
for i := 0; i < 5; i++ {
|
| 19 |
+
fmt.Println(i)
|
| 20 |
+
}
|
| 21 |
+
```
|
| 22 |
+
c. **Function definition in Go** β Caption must be `Function definition in Go` and the code content must be exactly:
|
| 23 |
+
```go
|
| 24 |
+
func add(a, b int) int {
|
| 25 |
+
return a + b
|
| 26 |
+
}
|
| 27 |
+
```
|
tasks/notion/easy/computer_science_student_dashboard/simple__code_snippets_go/meta.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"task_id": "simple__code_snippets_go",
|
| 3 |
+
"task_name": "Simple Code Snippets Go",
|
| 4 |
+
"category_id": "computer_science_student_dashboard",
|
| 5 |
+
"category_name": "Computer Science Student Dashboard",
|
| 6 |
+
"description": "Add a new Go column to the Code Snippets section between Python and JavaScript columns.",
|
| 7 |
+
"author": "Xiangyan Liu",
|
| 8 |
+
"created_at": "2025-11-15",
|
| 9 |
+
"difficulty": "L1",
|
| 10 |
+
"tags": [
|
| 11 |
+
"content organization",
|
| 12 |
+
"visual formatting",
|
| 13 |
+
"template population"
|
| 14 |
+
],
|
| 15 |
+
"mcp": [
|
| 16 |
+
"notion"
|
| 17 |
+
],
|
| 18 |
+
"meta_data": {
|
| 19 |
+
"stateType": "url",
|
| 20 |
+
"stateContent": null,
|
| 21 |
+
"stateUrl": "https://painted-tennis-ebc.notion.site/Computer-Science-Student-Dashboard-23e81626b6d78083b787d3c832b02ef4",
|
| 22 |
+
"stateOriginalUrl": "https://www.notion.so/marketplace/templates/computer-science-student-dashboard"
|
| 23 |
+
}
|
| 24 |
+
}
|
tasks/notion/easy/computer_science_student_dashboard/simple__code_snippets_go/verify.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
from notion_client import Client
|
| 3 |
+
from tasks.utils import notion_utils
|
| 4 |
+
|
| 5 |
+
# Expected code blocks (language=go)
|
| 6 |
+
EXPECTED_CODE_BLOCKS = [
|
| 7 |
+
{
|
| 8 |
+
"caption": "Basic Go program",
|
| 9 |
+
"code": (
|
| 10 |
+
'package main\n\nimport "fmt"\n\nfunc main() {\n fmt.Println("Hello, World!")\n}'
|
| 11 |
+
),
|
| 12 |
+
},
|
| 13 |
+
{
|
| 14 |
+
"caption": "For loop in Go",
|
| 15 |
+
"code": ("for i := 0; i < 5; i++ {\n fmt.Println(i)\n}"),
|
| 16 |
+
},
|
| 17 |
+
{
|
| 18 |
+
"caption": "Function definition in Go",
|
| 19 |
+
"code": ("func add(a, b int) int {\n return a + b\n}"),
|
| 20 |
+
},
|
| 21 |
+
]
|
| 22 |
+
|
| 23 |
+
HEADER_TEXT = "Go"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _normalize(text: str) -> str:
|
| 27 |
+
"""Remove trailing spaces on each line and strip leading/trailing blank lines."""
|
| 28 |
+
return "\n".join(line.rstrip() for line in text.strip().splitlines())
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _find_page(notion: Client, main_id: str | None) -> str | None:
|
| 32 |
+
"""Return a page_id to verify against or None if not found."""
|
| 33 |
+
page_id = None
|
| 34 |
+
if main_id:
|
| 35 |
+
found_id, object_type = notion_utils.find_page_or_database_by_id(
|
| 36 |
+
notion, main_id
|
| 37 |
+
)
|
| 38 |
+
if found_id and object_type == "page":
|
| 39 |
+
page_id = found_id
|
| 40 |
+
if not page_id:
|
| 41 |
+
page_id = notion_utils.find_page(notion, "Computer Science Student Dashboard")
|
| 42 |
+
return page_id
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _has_bold_header_text(block, text: str) -> bool:
|
| 46 |
+
"""Generic bold header/paragraph check for a given text."""
|
| 47 |
+
block_type = block.get("type")
|
| 48 |
+
if block_type not in {"paragraph", "heading_1", "heading_2", "heading_3"}:
|
| 49 |
+
return False
|
| 50 |
+
rich_text_list = block.get(block_type, {}).get("rich_text", [])
|
| 51 |
+
if not rich_text_list:
|
| 52 |
+
return False
|
| 53 |
+
plain = "".join(rt.get("plain_text", "") for rt in rich_text_list).strip()
|
| 54 |
+
if plain != text:
|
| 55 |
+
return False
|
| 56 |
+
return any(rt.get("annotations", {}).get("bold", False) for rt in rich_text_list)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _collect_code_blocks(blocks):
|
| 60 |
+
"""Return list of (code_content, caption) tuples for code blocks with language 'go'."""
|
| 61 |
+
collected = []
|
| 62 |
+
for block in blocks:
|
| 63 |
+
if block.get("type") != "code":
|
| 64 |
+
continue
|
| 65 |
+
code_data = block.get("code", {})
|
| 66 |
+
if code_data.get("language") != "go":
|
| 67 |
+
continue
|
| 68 |
+
code_plain = "".join(
|
| 69 |
+
rt.get("plain_text", "") for rt in code_data.get("rich_text", [])
|
| 70 |
+
)
|
| 71 |
+
caption_plain = "".join(
|
| 72 |
+
rt.get("plain_text", "") for rt in code_data.get("caption", [])
|
| 73 |
+
)
|
| 74 |
+
collected.append((code_plain, caption_plain))
|
| 75 |
+
return collected
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def verify(notion: Client, main_id: str | None = None) -> bool:
|
| 79 |
+
page_id = _find_page(notion, main_id)
|
| 80 |
+
if not page_id:
|
| 81 |
+
print("Error: Target page not found.", file=sys.stderr)
|
| 82 |
+
return False
|
| 83 |
+
|
| 84 |
+
all_blocks = notion_utils.get_all_blocks_recursively(notion, page_id)
|
| 85 |
+
|
| 86 |
+
# Verify header
|
| 87 |
+
header_ok = any(_has_bold_header_text(b, HEADER_TEXT) for b in all_blocks)
|
| 88 |
+
if not header_ok:
|
| 89 |
+
print("Failure: Bold header 'Go' not found.", file=sys.stderr)
|
| 90 |
+
return False
|
| 91 |
+
|
| 92 |
+
# Verify code blocks
|
| 93 |
+
code_blocks_found = _collect_code_blocks(all_blocks)
|
| 94 |
+
|
| 95 |
+
remaining = EXPECTED_CODE_BLOCKS.copy()
|
| 96 |
+
for code, caption in code_blocks_found:
|
| 97 |
+
norm_code = _normalize(code)
|
| 98 |
+
for expected in remaining:
|
| 99 |
+
if (
|
| 100 |
+
_normalize(expected["code"]) == norm_code
|
| 101 |
+
and expected["caption"] == caption
|
| 102 |
+
):
|
| 103 |
+
remaining.remove(expected)
|
| 104 |
+
break
|
| 105 |
+
if remaining:
|
| 106 |
+
missing = ", ".join(exp["caption"] for exp in remaining)
|
| 107 |
+
print(
|
| 108 |
+
f"Failure: Missing or incorrect Go code blocks: {missing}", file=sys.stderr
|
| 109 |
+
)
|
| 110 |
+
return False
|
| 111 |
+
|
| 112 |
+
print(
|
| 113 |
+
"Success: Verified Go header and required Go code blocks."
|
| 114 |
+
)
|
| 115 |
+
return True
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def main():
|
| 119 |
+
notion = notion_utils.get_notion_client()
|
| 120 |
+
main_id = sys.argv[1] if len(sys.argv) > 1 else None
|
| 121 |
+
sys.exit(0 if verify(notion, main_id) else 1)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
if __name__ == "__main__":
|
| 125 |
+
main()
|
tasks/notion/easy/computer_science_student_dashboard/simple__study_session_tracker/description.md
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Create a new study-session entry on the **Computer Science Student Dashboard** page.
|
| 2 |
+
|
| 3 |
+
1. Locate the βοΈ Habit tracker section of the page.
|
| 4 |
+
2. **Insert a new date mention** for `2025-01-29` immediately **after the existing `2022-09-02` items but before the divider block** that follows them. Match the formatting of the existing dates (bold text with a Notion date mention).
|
tasks/notion/easy/computer_science_student_dashboard/simple__study_session_tracker/meta.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"task_id": "simple__study_session_tracker",
|
| 3 |
+
"task_name": "Simple Study Session Tracker",
|
| 4 |
+
"category_id": "computer_science_student_dashboard",
|
| 5 |
+
"category_name": "Computer Science Student Dashboard",
|
| 6 |
+
"description": "Create a new study-session entry in the Habit tracker section with four unchecked to-do items.",
|
| 7 |
+
"author": "Xiangyan Liu",
|
| 8 |
+
"created_at": "2025-11-15",
|
| 9 |
+
"difficulty": "L1",
|
| 10 |
+
"tags": [
|
| 11 |
+
"content organization",
|
| 12 |
+
"visual formatting",
|
| 13 |
+
"status tracking"
|
| 14 |
+
],
|
| 15 |
+
"mcp": [
|
| 16 |
+
"notion"
|
| 17 |
+
],
|
| 18 |
+
"meta_data": {
|
| 19 |
+
"stateType": "url",
|
| 20 |
+
"stateContent": null,
|
| 21 |
+
"stateUrl": "https://painted-tennis-ebc.notion.site/Computer-Science-Student-Dashboard-23e81626b6d78083b787d3c832b02ef4",
|
| 22 |
+
"stateOriginalUrl": "https://www.notion.so/marketplace/templates/computer-science-student-dashboard"
|
| 23 |
+
}
|
| 24 |
+
}
|
tasks/notion/easy/computer_science_student_dashboard/simple__study_session_tracker/verify.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
from notion_client import Client
|
| 3 |
+
from tasks.utils import notion_utils
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def verify(notion: Client, main_id: str | None = None) -> bool:
|
| 7 |
+
"""Verify that the new study-session entry for 2025-01-29 was added correctly.
|
| 8 |
+
|
| 9 |
+
The script checks that:
|
| 10 |
+
1. A bold date-mention with start=2025-01-29 exists.
|
| 11 |
+
2. The mention sits after the 2022-09-02 section but before the divider that originally
|
| 12 |
+
followed that section.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
# ---------------------------------------------------------------------
|
| 16 |
+
# Locate the main page -------------------------------------------------
|
| 17 |
+
# ---------------------------------------------------------------------
|
| 18 |
+
page_id: str | None = None
|
| 19 |
+
|
| 20 |
+
if main_id:
|
| 21 |
+
found_id, object_type = notion_utils.find_page_or_database_by_id(
|
| 22 |
+
notion, main_id
|
| 23 |
+
)
|
| 24 |
+
if found_id and object_type == "page":
|
| 25 |
+
page_id = found_id
|
| 26 |
+
|
| 27 |
+
if not page_id:
|
| 28 |
+
page_id = notion_utils.find_page(notion, "Computer Science Student Dashboard")
|
| 29 |
+
|
| 30 |
+
if not page_id:
|
| 31 |
+
print(
|
| 32 |
+
"Error: Page 'Computer Science Student Dashboard' not found.",
|
| 33 |
+
file=sys.stderr,
|
| 34 |
+
)
|
| 35 |
+
return False
|
| 36 |
+
|
| 37 |
+
# ---------------------------------------------------------------------
|
| 38 |
+
# Fetch all blocks under the page (flattened order) --------------------
|
| 39 |
+
# ---------------------------------------------------------------------
|
| 40 |
+
all_blocks = notion_utils.get_all_blocks_recursively(notion, page_id)
|
| 41 |
+
|
| 42 |
+
# ---------------------------------------------------------------------
|
| 43 |
+
# Locate reference blocks ---------------------------------------------
|
| 44 |
+
# ---------------------------------------------------------------------
|
| 45 |
+
TARGET_DATE = "2025-01-29"
|
| 46 |
+
PREVIOUS_DATE = "2022-09-02"
|
| 47 |
+
|
| 48 |
+
index_previous_date: int | None = None
|
| 49 |
+
index_new_date: int | None = None
|
| 50 |
+
index_divider_after_previous: int | None = None
|
| 51 |
+
|
| 52 |
+
for idx, block in enumerate(all_blocks):
|
| 53 |
+
# Divider detection (we care only about the first divider that appears after
|
| 54 |
+
# the 2022-09-02 block)
|
| 55 |
+
if block.get("type") == "divider":
|
| 56 |
+
if index_previous_date is not None and index_divider_after_previous is None:
|
| 57 |
+
index_divider_after_previous = idx
|
| 58 |
+
|
| 59 |
+
# We only need to inspect paragraph blocks that contain a date mention
|
| 60 |
+
if block.get("type") != "paragraph":
|
| 61 |
+
continue
|
| 62 |
+
|
| 63 |
+
rich_text_list = block["paragraph"].get("rich_text", [])
|
| 64 |
+
for rt in rich_text_list:
|
| 65 |
+
if (
|
| 66 |
+
rt.get("type") != "mention"
|
| 67 |
+
or rt.get("mention", {}).get("type") != "date"
|
| 68 |
+
):
|
| 69 |
+
continue
|
| 70 |
+
|
| 71 |
+
date_start = rt["mention"]["date"].get("start")
|
| 72 |
+
|
| 73 |
+
if date_start == PREVIOUS_DATE and index_previous_date is None:
|
| 74 |
+
index_previous_date = idx
|
| 75 |
+
|
| 76 |
+
if date_start == TARGET_DATE and index_new_date is None:
|
| 77 |
+
index_new_date = idx
|
| 78 |
+
# (1) Verify bold annotation
|
| 79 |
+
if not rt.get("annotations", {}).get("bold", False):
|
| 80 |
+
print(
|
| 81 |
+
"Error: The 2025-01-29 date mention is not bold.",
|
| 82 |
+
file=sys.stderr,
|
| 83 |
+
)
|
| 84 |
+
return False
|
| 85 |
+
|
| 86 |
+
# Ensure all reference indices were found
|
| 87 |
+
if index_previous_date is None:
|
| 88 |
+
print("Error: Could not locate the 2022-09-02 date section.", file=sys.stderr)
|
| 89 |
+
return False
|
| 90 |
+
if index_divider_after_previous is None:
|
| 91 |
+
print(
|
| 92 |
+
"Error: Could not locate the divider that follows the 2022-09-02 section.",
|
| 93 |
+
file=sys.stderr,
|
| 94 |
+
)
|
| 95 |
+
return False
|
| 96 |
+
if index_new_date is None:
|
| 97 |
+
print(
|
| 98 |
+
"Error: Could not locate the new 2025-01-29 date mention.", file=sys.stderr
|
| 99 |
+
)
|
| 100 |
+
return False
|
| 101 |
+
|
| 102 |
+
# (2) Verify ordering
|
| 103 |
+
if not (index_previous_date < index_new_date < index_divider_after_previous):
|
| 104 |
+
print(
|
| 105 |
+
"Error: The 2025-01-29 section is positioned incorrectly.", file=sys.stderr
|
| 106 |
+
)
|
| 107 |
+
return False
|
| 108 |
+
|
| 109 |
+
# ---------------------------------------------------------------------
|
| 110 |
+
# Success --------------------------------------------------------------
|
| 111 |
+
# ---------------------------------------------------------------------
|
| 112 |
+
print("Success: Date mention for 2025-01-29 added in the correct position.")
|
| 113 |
+
return True
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
# -------------------------------------------------------------------------
|
| 117 |
+
# Command-line entry-point -------------------------------------------------
|
| 118 |
+
# -------------------------------------------------------------------------
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def main() -> None:
|
| 122 |
+
notion = notion_utils.get_notion_client()
|
| 123 |
+
main_id = sys.argv[1] if len(sys.argv) > 1 else None
|
| 124 |
+
|
| 125 |
+
if verify(notion, main_id):
|
| 126 |
+
sys.exit(0)
|
| 127 |
+
else:
|
| 128 |
+
sys.exit(1)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
if __name__ == "__main__":
|
| 132 |
+
main()
|
tasks/notion/easy/it_trouble_shooting_hub/simple__asset_retirement_migration/description.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Please migrate expiring assets out of the **IT Inventory** database using the simplified checklist below. Your changes will be verified automatically, so match the details exactly.
|
| 2 |
+
|
| 3 |
+
---
|
| 4 |
+
Task Steps
|
| 5 |
+
1. Inside the **IT Trouble Shooting Hub** page, locate the database named **IT Inventory**.
|
| 6 |
+
2. Collect every page in **IT Inventory** whose **Status** is **Expired** or **To be returned**.
|
| 7 |
+
3. Create a **new full-page database** under the same hub titled **IT Asset Retirement Queue** with exactly these properties (names and types must match):
|
| 8 |
+
β’ Serial β title
|
| 9 |
+
β’ Status β select
|
| 10 |
+
β’ Expiration date β date
|
| 11 |
+
4. For every item gathered in step 2, create a page in **IT Asset Retirement Queue**, copy over the Serial, Status, and Expiration date values, then archive the original inventory page once the copy is made.
|
tasks/notion/easy/it_trouble_shooting_hub/simple__asset_retirement_migration/meta.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"task_id": "simple__asset_retirement_migration",
|
| 3 |
+
"task_name": "Simple Asset Retirement Migration",
|
| 4 |
+
"category_id": "it_trouble_shooting_hub",
|
| 5 |
+
"category_name": "IT Trouble Shooting Hub",
|
| 6 |
+
"description": "Restructure the IT Inventory database by migrating expired assets to a new IT Asset Retirement Queue database.",
|
| 7 |
+
"author": "Xiangyan Liu",
|
| 8 |
+
"created_at": "2025-11-15",
|
| 9 |
+
"difficulty": "L1",
|
| 10 |
+
"tags": [
|
| 11 |
+
"database manipulation",
|
| 12 |
+
"automated migration",
|
| 13 |
+
"conditional filtering",
|
| 14 |
+
"data aggregation",
|
| 15 |
+
"report generation"
|
| 16 |
+
],
|
| 17 |
+
"mcp": [
|
| 18 |
+
"notion"
|
| 19 |
+
],
|
| 20 |
+
"meta_data": {
|
| 21 |
+
"stateType": "url",
|
| 22 |
+
"stateContent": null,
|
| 23 |
+
"stateUrl": "https://painted-tennis-ebc.notion.site/It-Trouble-Shooting-Hub-23e81626b6d78020aba7eb65ae1cc2d5",
|
| 24 |
+
"stateOriginalUrl": "https://www.notion.so/marketplace/templates/it-trouble-shooting-hub"
|
| 25 |
+
}
|
| 26 |
+
}
|
tasks/notion/easy/it_trouble_shooting_hub/simple__asset_retirement_migration/verify.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
from typing import Dict
|
| 3 |
+
from notion_client import Client
|
| 4 |
+
from tasks.utils import notion_utils
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def _get_database(root_page_id: str, notion: Client, name: str) -> str | None:
|
| 8 |
+
"""Helper that finds a child database by title inside a page."""
|
| 9 |
+
return notion_utils.find_database_in_block(notion, root_page_id, name)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _check_property(props: Dict, name: str, expected_type: str) -> bool:
|
| 13 |
+
if name not in props:
|
| 14 |
+
print(f"Error: Property '{name}' missing in database.", file=sys.stderr)
|
| 15 |
+
return False
|
| 16 |
+
if props[name]["type"] != expected_type:
|
| 17 |
+
print(
|
| 18 |
+
f"Error: Property '{name}' expected type '{expected_type}', found '{props[name]['type']}'.",
|
| 19 |
+
file=sys.stderr,
|
| 20 |
+
)
|
| 21 |
+
return False
|
| 22 |
+
return True
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def verify(notion: Client, main_id: str | None = None) -> bool:
|
| 26 |
+
"""Verifies that the IT Asset Retirement Queue was created and populated correctly."""
|
| 27 |
+
|
| 28 |
+
# -------------------------------------------------------------------------
|
| 29 |
+
# Resolve the root IT Trouble Shooting Hub page
|
| 30 |
+
# -------------------------------------------------------------------------
|
| 31 |
+
root_page_id = None
|
| 32 |
+
if main_id:
|
| 33 |
+
found_id, obj_type = notion_utils.find_page_or_database_by_id(notion, main_id)
|
| 34 |
+
if found_id and obj_type == "page":
|
| 35 |
+
root_page_id = found_id
|
| 36 |
+
|
| 37 |
+
if not root_page_id:
|
| 38 |
+
root_page_id = notion_utils.find_page(notion, "IT Trouble Shooting Hub")
|
| 39 |
+
if not root_page_id:
|
| 40 |
+
print(
|
| 41 |
+
"Error: Could not locate the 'IT Trouble Shooting Hub' page.",
|
| 42 |
+
file=sys.stderr,
|
| 43 |
+
)
|
| 44 |
+
return False
|
| 45 |
+
|
| 46 |
+
# -------------------------------------------------------------------------
|
| 47 |
+
# Locate the original and new databases
|
| 48 |
+
# -------------------------------------------------------------------------
|
| 49 |
+
inventory_db_id = _get_database(root_page_id, notion, "IT Inventory")
|
| 50 |
+
if not inventory_db_id:
|
| 51 |
+
print("Error: 'IT Inventory' database not found.", file=sys.stderr)
|
| 52 |
+
return False
|
| 53 |
+
|
| 54 |
+
retirement_db_id = _get_database(root_page_id, notion, "IT Asset Retirement Queue")
|
| 55 |
+
if not retirement_db_id:
|
| 56 |
+
print("Error: 'IT Asset Retirement Queue' database not found.", file=sys.stderr)
|
| 57 |
+
return False
|
| 58 |
+
|
| 59 |
+
# -------------------------------------------------------------------------
|
| 60 |
+
# Validate schema of the retirement queue database
|
| 61 |
+
# -------------------------------------------------------------------------
|
| 62 |
+
retirement_db = notion.databases.retrieve(database_id=retirement_db_id)
|
| 63 |
+
r_props = retirement_db["properties"]
|
| 64 |
+
|
| 65 |
+
required_schema = {
|
| 66 |
+
"Serial": "title",
|
| 67 |
+
"Status": "select",
|
| 68 |
+
"Expiration date": "date",
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
for pname, ptype in required_schema.items():
|
| 72 |
+
if not _check_property(r_props, pname, ptype):
|
| 73 |
+
return False
|
| 74 |
+
|
| 75 |
+
# -------------------------------------------------------------------------
|
| 76 |
+
# Validate that inventory items are moved & archived
|
| 77 |
+
# -------------------------------------------------------------------------
|
| 78 |
+
expired_filter = {
|
| 79 |
+
"property": "Status",
|
| 80 |
+
"select": {"equals": "Expired"},
|
| 81 |
+
}
|
| 82 |
+
to_return_filter = {
|
| 83 |
+
"property": "Status",
|
| 84 |
+
"select": {"equals": "To be returned"},
|
| 85 |
+
}
|
| 86 |
+
compound_filter = {"or": [expired_filter, to_return_filter]}
|
| 87 |
+
|
| 88 |
+
# Query for any *active* items that still match these statuses
|
| 89 |
+
remaining_items = notion.databases.query(
|
| 90 |
+
database_id=inventory_db_id,
|
| 91 |
+
filter=compound_filter,
|
| 92 |
+
archived=False,
|
| 93 |
+
).get("results", [])
|
| 94 |
+
|
| 95 |
+
if remaining_items:
|
| 96 |
+
print(
|
| 97 |
+
f"Error: {len(remaining_items)} 'Expired' / 'To be returned' items still present in IT Inventory.",
|
| 98 |
+
file=sys.stderr,
|
| 99 |
+
)
|
| 100 |
+
return False
|
| 101 |
+
|
| 102 |
+
# There should be at least one entry in the retirement queue
|
| 103 |
+
retirement_pages = notion.databases.query(database_id=retirement_db_id).get(
|
| 104 |
+
"results", []
|
| 105 |
+
)
|
| 106 |
+
expected_serials = {"65XYQ/GB", "36x10PIQ"}
|
| 107 |
+
if len(retirement_pages) != len(expected_serials):
|
| 108 |
+
print(
|
| 109 |
+
f"Error: Expected {len(expected_serials)} retirement pages, found {len(retirement_pages)}.",
|
| 110 |
+
file=sys.stderr,
|
| 111 |
+
)
|
| 112 |
+
return False
|
| 113 |
+
|
| 114 |
+
serials_seen = set()
|
| 115 |
+
for page in retirement_pages:
|
| 116 |
+
props = page["properties"]
|
| 117 |
+
# Collect Serial title
|
| 118 |
+
title_rich = props.get("Serial", {}).get("title", [])
|
| 119 |
+
serial_val = "".join([t.get("plain_text", "") for t in title_rich]).strip()
|
| 120 |
+
serials_seen.add(serial_val)
|
| 121 |
+
|
| 122 |
+
if serials_seen != expected_serials:
|
| 123 |
+
print(
|
| 124 |
+
f"Error: Serial values mismatch. Expected {sorted(expected_serials)}, found {sorted(serials_seen)}.",
|
| 125 |
+
file=sys.stderr,
|
| 126 |
+
)
|
| 127 |
+
return False
|
| 128 |
+
|
| 129 |
+
print("Success: All verification criteria satisfied.")
|
| 130 |
+
return True
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def main():
|
| 134 |
+
notion = notion_utils.get_notion_client()
|
| 135 |
+
main_id = sys.argv[1] if len(sys.argv) > 1 else None
|
| 136 |
+
if verify(notion, main_id):
|
| 137 |
+
sys.exit(0)
|
| 138 |
+
else:
|
| 139 |
+
sys.exit(1)
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
if __name__ == "__main__":
|
| 143 |
+
main()
|
tasks/notion/easy/japan_travel_planner/simple__remove_osaka_itinerary/description.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
Go to Japan Travel Planner, and go to the Travel Itineray database, and remove the itinerary in OSAKA after 6 PM (excluding 6 PM) in Day 1 and Day 2.
|
tasks/notion/easy/japan_travel_planner/simple__remove_osaka_itinerary/meta.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"task_id": "simple__remove_osaka_itinerary",
|
| 3 |
+
"task_name": "Simple Remove Osaka Itinerary",
|
| 4 |
+
"category_id": "japan_travel_planner",
|
| 5 |
+
"category_name": "Japan Travel Planner",
|
| 6 |
+
"description": "Remove the itinerary items in Osaka after 6 PM from Day 1 and Day 2 travel schedules.",
|
| 7 |
+
"author": "Xiangyan Liu",
|
| 8 |
+
"created_at": "2025-11-15",
|
| 9 |
+
"difficulty": "L1",
|
| 10 |
+
"tags": [
|
| 11 |
+
"conditional filtering",
|
| 12 |
+
"automated migration"
|
| 13 |
+
],
|
| 14 |
+
"mcp": [
|
| 15 |
+
"notion"
|
| 16 |
+
],
|
| 17 |
+
"meta_data": {
|
| 18 |
+
"stateType": "url",
|
| 19 |
+
"stateContent": null,
|
| 20 |
+
"stateUrl": "https://painted-tennis-ebc.notion.site/Japan-Travel-Planner-23181626b6d781c4b6bedb12786b5abe",
|
| 21 |
+
"stateOriginalUrl": "https://www.notion.so/marketplace/templates/japantravelplanner101"
|
| 22 |
+
}
|
| 23 |
+
}
|
tasks/notion/easy/japan_travel_planner/simple__remove_osaka_itinerary/verify.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
from notion_client import Client
|
| 3 |
+
from tasks.utils import notion_utils
|
| 4 |
+
|
| 5 |
+
def get_page_title(page_result):
|
| 6 |
+
"""Extract title from a page result"""
|
| 7 |
+
properties = page_result.get('properties', {})
|
| 8 |
+
name_property = properties.get('Name', {})
|
| 9 |
+
if name_property.get('type') == 'title':
|
| 10 |
+
title_array = name_property.get('title', [])
|
| 11 |
+
if title_array and len(title_array) > 0:
|
| 12 |
+
return title_array[0].get('plain_text', '')
|
| 13 |
+
return ''
|
| 14 |
+
|
| 15 |
+
def get_page_time(page_result):
|
| 16 |
+
"""Extract time from Notes field"""
|
| 17 |
+
properties = page_result.get('properties', {})
|
| 18 |
+
notes_property = properties.get('Notes', {})
|
| 19 |
+
if notes_property.get('type') == 'rich_text':
|
| 20 |
+
rich_text_array = notes_property.get('rich_text', [])
|
| 21 |
+
if rich_text_array and len(rich_text_array) > 0:
|
| 22 |
+
notes_text = rich_text_array[0].get('plain_text', '')
|
| 23 |
+
return notes_text.strip()
|
| 24 |
+
return ''
|
| 25 |
+
|
| 26 |
+
def get_page_group(page_result):
|
| 27 |
+
"""Extract group/location from page"""
|
| 28 |
+
properties = page_result.get('properties', {})
|
| 29 |
+
group_property = properties.get('Group', {})
|
| 30 |
+
if group_property.get('type') == 'select':
|
| 31 |
+
select = group_property.get('select')
|
| 32 |
+
if select:
|
| 33 |
+
return select.get('name', '')
|
| 34 |
+
return ''
|
| 35 |
+
|
| 36 |
+
def get_page_day(page_result):
|
| 37 |
+
"""Extract day from page"""
|
| 38 |
+
properties = page_result.get('properties', {})
|
| 39 |
+
day_property = properties.get('Day', {})
|
| 40 |
+
if day_property.get('type') == 'select':
|
| 41 |
+
select = day_property.get('select')
|
| 42 |
+
if select:
|
| 43 |
+
return select.get('name', '')
|
| 44 |
+
return ''
|
| 45 |
+
|
| 46 |
+
def parse_time_to_minutes(time_str):
|
| 47 |
+
"""Convert time string to minutes for comparison
|
| 48 |
+
Returns None if time cannot be parsed"""
|
| 49 |
+
if not time_str:
|
| 50 |
+
return None
|
| 51 |
+
|
| 52 |
+
# Clean the time string
|
| 53 |
+
time_str = time_str.strip().upper()
|
| 54 |
+
|
| 55 |
+
# Remove any text after the time (e.g., "7:30 PM\n" -> "7:30 PM")
|
| 56 |
+
time_str = time_str.split('\n')[0].strip()
|
| 57 |
+
|
| 58 |
+
# Extract time components
|
| 59 |
+
try:
|
| 60 |
+
if 'PM' in time_str:
|
| 61 |
+
time_part = time_str.replace('PM', '').strip()
|
| 62 |
+
if ':' in time_part:
|
| 63 |
+
hours, minutes = time_part.split(':')
|
| 64 |
+
hours = int(hours)
|
| 65 |
+
minutes = int(minutes)
|
| 66 |
+
else:
|
| 67 |
+
hours = int(time_part)
|
| 68 |
+
minutes = 0
|
| 69 |
+
# Convert PM hours (add 12 for PM times except 12 PM)
|
| 70 |
+
if hours != 12:
|
| 71 |
+
hours += 12
|
| 72 |
+
return hours * 60 + minutes
|
| 73 |
+
elif 'AM' in time_str:
|
| 74 |
+
time_part = time_str.replace('AM', '').strip()
|
| 75 |
+
if ':' in time_part:
|
| 76 |
+
hours, minutes = time_part.split(':')
|
| 77 |
+
hours = int(hours)
|
| 78 |
+
minutes = int(minutes)
|
| 79 |
+
else:
|
| 80 |
+
hours = int(time_part)
|
| 81 |
+
minutes = 0
|
| 82 |
+
# Handle 12 AM (midnight)
|
| 83 |
+
if hours == 12:
|
| 84 |
+
hours = 0
|
| 85 |
+
return hours * 60 + minutes
|
| 86 |
+
except:
|
| 87 |
+
return None
|
| 88 |
+
|
| 89 |
+
return None
|
| 90 |
+
|
| 91 |
+
def verify(notion: Client, main_id: str = None) -> bool:
|
| 92 |
+
"""
|
| 93 |
+
Verifies that all OSAKA events after 6PM have been removed from Day 1 and Day 2 in the Japan Travel Planner.
|
| 94 |
+
|
| 95 |
+
Expected items that should be deleted (all in OSAKA, after 6PM, on Day 1 or Day 2):
|
| 96 |
+
1. Rikuro's Namba Main Branch - 7 PM (Day 1)
|
| 97 |
+
2. Shin Sekai "New World" - 8 PM (Day 2)
|
| 98 |
+
3. Katsudon Chiyomatsu - 7:30 PM (Day 2)
|
| 99 |
+
4. Ebisubashi Bridge - 9 PM (Day 1)
|
| 100 |
+
|
| 101 |
+
Note: Kuromon Ichiba Market at 6 PM should NOT be deleted (it's at 6PM, not after)
|
| 102 |
+
Items after 6PM on other days (Day 3-8) should NOT be deleted
|
| 103 |
+
"""
|
| 104 |
+
|
| 105 |
+
# Step 1: Find the main Japan Travel Planner page
|
| 106 |
+
if main_id:
|
| 107 |
+
found_id, object_type = notion_utils.find_page_or_database_by_id(notion, main_id)
|
| 108 |
+
if not found_id or object_type != 'page':
|
| 109 |
+
print("Error: Japan Travel Planner page not found.", file=sys.stderr)
|
| 110 |
+
return False
|
| 111 |
+
else:
|
| 112 |
+
# Try to find the page by searching
|
| 113 |
+
found_id = notion_utils.find_page(notion, "Japan Travel Planner")
|
| 114 |
+
if not found_id:
|
| 115 |
+
print("Error: Japan Travel Planner page not found.", file=sys.stderr)
|
| 116 |
+
return False
|
| 117 |
+
|
| 118 |
+
print(f"Found Japan Travel Planner page: {found_id}")
|
| 119 |
+
|
| 120 |
+
# Step 2: Find the Travel Itinerary database
|
| 121 |
+
all_blocks = notion_utils.get_all_blocks_recursively(notion, found_id)
|
| 122 |
+
travel_itinerary_db_id = None
|
| 123 |
+
|
| 124 |
+
for block in all_blocks:
|
| 125 |
+
if block and block.get("type") == "child_database":
|
| 126 |
+
title = block.get("child_database", {}).get("title", "")
|
| 127 |
+
if "Travel Itinerary" in title:
|
| 128 |
+
travel_itinerary_db_id = block.get("id")
|
| 129 |
+
print(f"Found Travel Itinerary database: {travel_itinerary_db_id}")
|
| 130 |
+
break
|
| 131 |
+
|
| 132 |
+
if not travel_itinerary_db_id:
|
| 133 |
+
print("Error: Travel Itinerary database not found", file=sys.stderr)
|
| 134 |
+
return False
|
| 135 |
+
|
| 136 |
+
# Step 3: Query the database for OSAKA items on Day 1 and Day 2
|
| 137 |
+
try:
|
| 138 |
+
query_result = notion.databases.query(
|
| 139 |
+
database_id=travel_itinerary_db_id,
|
| 140 |
+
filter={
|
| 141 |
+
"and": [
|
| 142 |
+
{"property": "Group", "select": {"equals": "Osaka"}},
|
| 143 |
+
{"or": [
|
| 144 |
+
{"property": "Day", "select": {"equals": "Day 1"}},
|
| 145 |
+
{"property": "Day", "select": {"equals": "Day 2"}}
|
| 146 |
+
]}
|
| 147 |
+
]
|
| 148 |
+
}
|
| 149 |
+
)
|
| 150 |
+
except Exception as e:
|
| 151 |
+
print(f"Error querying Travel Itinerary database: {e}", file=sys.stderr)
|
| 152 |
+
return False
|
| 153 |
+
|
| 154 |
+
# Step 4: Check for items that should have been deleted
|
| 155 |
+
six_pm_minutes = 18 * 60 # 6 PM in minutes (18:00)
|
| 156 |
+
|
| 157 |
+
# Expected deleted items (4 specific items after 6 PM on Day 1 and Day 2)
|
| 158 |
+
expected_deleted = {
|
| 159 |
+
"Rikuro's Namba Main Branch": {"time": "7 PM", "day": "Day 1", "found": False},
|
| 160 |
+
"Shin Sekai \"New World\"": {"time": "8 PM", "day": "Day 2", "found": False},
|
| 161 |
+
"Katsudon Chiyomatsu": {"time": "7:30 PM", "day": "Day 2", "found": False},
|
| 162 |
+
"Ebisubashi Bridge": {"time": "9 PM", "day": "Day 1", "found": False}
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
# Items that should remain (at or before 6 PM)
|
| 166 |
+
expected_remaining = {
|
| 167 |
+
"Kuromon Ichiba Market": {"time": "6 PM", "found": False}
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
osaka_items_after_6pm = []
|
| 171 |
+
osaka_items_at_or_before_6pm = []
|
| 172 |
+
|
| 173 |
+
# Debug: Show total query results
|
| 174 |
+
print(f"Debug: Found {len(query_result.get('results', []))} total OSAKA items on Day 1 and Day 2")
|
| 175 |
+
|
| 176 |
+
# Process all OSAKA items on Day 1 and Day 2
|
| 177 |
+
for page in query_result.get('results', []):
|
| 178 |
+
page_title = get_page_title(page).strip()
|
| 179 |
+
page_time = get_page_time(page)
|
| 180 |
+
page_group = get_page_group(page)
|
| 181 |
+
page_day = get_page_day(page)
|
| 182 |
+
|
| 183 |
+
if page_group != "Osaka":
|
| 184 |
+
continue
|
| 185 |
+
|
| 186 |
+
# Parse time to check if after 6 PM
|
| 187 |
+
time_minutes = parse_time_to_minutes(page_time)
|
| 188 |
+
|
| 189 |
+
if time_minutes is not None and time_minutes > six_pm_minutes:
|
| 190 |
+
osaka_items_after_6pm.append({
|
| 191 |
+
"title": page_title,
|
| 192 |
+
"time": page_time,
|
| 193 |
+
"day": page_day,
|
| 194 |
+
"id": page.get('id')
|
| 195 |
+
})
|
| 196 |
+
|
| 197 |
+
# Check if this is one of the expected deleted items
|
| 198 |
+
for expected_title, expected_info in expected_deleted.items():
|
| 199 |
+
# Clean up the titles for comparison
|
| 200 |
+
clean_page_title = page_title.strip().lower()
|
| 201 |
+
clean_expected_title = expected_title.strip().lower()
|
| 202 |
+
|
| 203 |
+
# Check for "Rikuro's" or "Rikuro's" (different apostrophe types)
|
| 204 |
+
if "rikuro" in clean_page_title and "rikuro" in clean_expected_title:
|
| 205 |
+
title_match = True
|
| 206 |
+
elif clean_page_title == clean_expected_title:
|
| 207 |
+
title_match = True
|
| 208 |
+
elif clean_expected_title in clean_page_title or clean_page_title in clean_expected_title:
|
| 209 |
+
title_match = True
|
| 210 |
+
else:
|
| 211 |
+
title_match = False
|
| 212 |
+
|
| 213 |
+
if title_match and page_day == expected_info["day"]:
|
| 214 |
+
print(f"Debug: Found '{page_title}' on {page_day} at {page_time} - matches expected '{expected_title}'")
|
| 215 |
+
expected_deleted[expected_title]["found"] = True
|
| 216 |
+
|
| 217 |
+
elif time_minutes is not None and time_minutes <= six_pm_minutes:
|
| 218 |
+
osaka_items_at_or_before_6pm.append({
|
| 219 |
+
"title": page_title,
|
| 220 |
+
"time": page_time,
|
| 221 |
+
"day": page_day,
|
| 222 |
+
"id": page.get('id')
|
| 223 |
+
})
|
| 224 |
+
|
| 225 |
+
# Check if this is one of the expected remaining items
|
| 226 |
+
for expected_title in expected_remaining:
|
| 227 |
+
if expected_title.lower() in page_title.lower() or page_title.lower() in expected_title.lower():
|
| 228 |
+
expected_remaining[expected_title]["found"] = True
|
| 229 |
+
|
| 230 |
+
# Step 5: Verify results
|
| 231 |
+
print(f"\nVerification Summary:")
|
| 232 |
+
print(f"=" * 50)
|
| 233 |
+
|
| 234 |
+
all_passed = True
|
| 235 |
+
|
| 236 |
+
# Check that the 4 expected items after 6 PM have been deleted
|
| 237 |
+
print("\n4 Items that should be deleted (after 6 PM on Day 1 and Day 2):")
|
| 238 |
+
for item_name, item_info in expected_deleted.items():
|
| 239 |
+
if item_info["found"]:
|
| 240 |
+
# If found = True, it means the item still exists (was not deleted)
|
| 241 |
+
print(f"β {item_name} ({item_info['day']}, {item_info['time']}) - Still exists, should be deleted", file=sys.stderr)
|
| 242 |
+
all_passed = False
|
| 243 |
+
else:
|
| 244 |
+
# If found = False, it means the item was deleted correctly
|
| 245 |
+
print(f"β {item_name} ({item_info['day']}, {item_info['time']}) - Correctly deleted")
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
# Check that items at or before 6 PM remain
|
| 249 |
+
print("\nItems that should remain (at or before 6 PM on Day 1 and Day 2):")
|
| 250 |
+
for item_name, item_info in expected_remaining.items():
|
| 251 |
+
if item_info["found"]:
|
| 252 |
+
print(f"β {item_name} ({item_info['time']}) - Correctly retained")
|
| 253 |
+
else:
|
| 254 |
+
print(f"β {item_name} ({item_info['time']}) - Missing, should not be deleted", file=sys.stderr)
|
| 255 |
+
all_passed = False
|
| 256 |
+
|
| 257 |
+
# Report any items after 6 PM that still exist
|
| 258 |
+
if osaka_items_after_6pm:
|
| 259 |
+
print(f"\nβ Found {len(osaka_items_after_6pm)} OSAKA item(s) after 6 PM on Day 1/Day 2:", file=sys.stderr)
|
| 260 |
+
for item in osaka_items_after_6pm:
|
| 261 |
+
print(f" - {item['title']} at {item['time']} ({item['day']})", file=sys.stderr)
|
| 262 |
+
else:
|
| 263 |
+
print(f"\nβ No OSAKA items found after 6 PM on Day 1/Day 2 (all correctly deleted)")
|
| 264 |
+
|
| 265 |
+
# Report count summary
|
| 266 |
+
print(f"\nCount Summary:")
|
| 267 |
+
print(f"- OSAKA items after 6 PM on Day 1/Day 2 found: {len(osaka_items_after_6pm)} (should be 0)")
|
| 268 |
+
print(f"- OSAKA items at/before 6 PM on Day 1/Day 2 found: {len(osaka_items_at_or_before_6pm)}")
|
| 269 |
+
print(f"- Expected deletions verified: {sum(1 for item in expected_deleted.values() if not item['found'])}/4")
|
| 270 |
+
|
| 271 |
+
return all_passed
|
| 272 |
+
|
| 273 |
+
def main():
|
| 274 |
+
"""
|
| 275 |
+
Executes the verification process and exits with a status code.
|
| 276 |
+
"""
|
| 277 |
+
notion = notion_utils.get_notion_client()
|
| 278 |
+
main_id = sys.argv[1] if len(sys.argv) > 1 else None
|
| 279 |
+
|
| 280 |
+
if verify(notion, main_id):
|
| 281 |
+
print("\nVerification passed: All 4 required OSAKA events after 6 PM on Day 1 and Day 2 have been removed")
|
| 282 |
+
sys.exit(0)
|
| 283 |
+
else:
|
| 284 |
+
print("\nVerification failed: Some OSAKA events after 6 PM on Day 1/Day 2 still exist")
|
| 285 |
+
sys.exit(1)
|
| 286 |
+
|
| 287 |
+
if __name__ == "__main__":
|
| 288 |
+
main()
|
tasks/notion/easy/online_resume/simple__skills_development_tracker/description.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Create a comprehensive skills audit system by performing the following tasks:
|
| 2 |
+
|
| 3 |
+
**Task Requirements:**
|
| 4 |
+
1. Create a new database named "Skills Development Tracker" as a child database in the main resume page with the following properties:
|
| 5 |
+
- Name (title property)
|
| 6 |
+
- Current Skill (relation to Skills database)
|
| 7 |
+
- Current Proficiency (rollup from related skill's "Skill Level" property)
|
| 8 |
+
- Target Proficiency (number property with format "percent")
|
| 9 |
+
- Gap (formula: Target Proficiency - Current Proficiency)
|
| 10 |
+
- Learning Resources (rich text property)
|
| 11 |
+
- Progress Notes (rich text property)
|
| 12 |
+
|
| 13 |
+
2. Populate the Skills Development Tracker database with entries for all skills that have a proficiency level below 70% (0.7):
|
| 14 |
+
- For each qualifying skill, create an entry with:
|
| 15 |
+
- Name: "[Skill Name] Development Plan"
|
| 16 |
+
- Link to the corresponding skill in Skills database
|
| 17 |
+
- Target Proficiency: Set to Current + 25% (capped at 95%)
|
| 18 |
+
- Learning Resources: "Online courses and practice projects"
|
| 19 |
+
- Progress Notes: "Initial assessment completed"
|
tasks/notion/easy/online_resume/simple__skills_development_tracker/meta.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"task_id": "simple__skills_development_tracker",
|
| 3 |
+
"task_name": "Simple Skills Development Tracker",
|
| 4 |
+
"category_id": "online_resume",
|
| 5 |
+
"category_name": "Online Resume",
|
| 6 |
+
"description": "Create a comprehensive skills audit system with development tracking for skills below 70% proficiency.",
|
| 7 |
+
"author": "Xiangyan Liu",
|
| 8 |
+
"created_at": "2025-11-15",
|
| 9 |
+
"difficulty": "L1",
|
| 10 |
+
"tags": [
|
| 11 |
+
"database manipulation",
|
| 12 |
+
"cross-reference linking",
|
| 13 |
+
"conditional filtering",
|
| 14 |
+
"data aggregation",
|
| 15 |
+
"template population",
|
| 16 |
+
"visual formatting"
|
| 17 |
+
],
|
| 18 |
+
"mcp": [
|
| 19 |
+
"notion"
|
| 20 |
+
],
|
| 21 |
+
"meta_data": {
|
| 22 |
+
"stateType": "url",
|
| 23 |
+
"stateContent": null,
|
| 24 |
+
"stateUrl": "https://painted-tennis-ebc.notion.site/Online-Resume-23181626b6d781159faaeb5eadaf612e",
|
| 25 |
+
"stateOriginalUrl": "https://www.notion.so/marketplace/templates/online-resume"
|
| 26 |
+
}
|
| 27 |
+
}
|
tasks/notion/easy/online_resume/simple__skills_development_tracker/verify.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
from notion_client import Client
|
| 3 |
+
from tasks.utils import notion_utils
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def verify(notion: Client, main_id: str = None) -> bool:
|
| 7 |
+
"""
|
| 8 |
+
Verifies that the Skills Development Tracker database was created correctly.
|
| 9 |
+
"""
|
| 10 |
+
page_id = None
|
| 11 |
+
if main_id:
|
| 12 |
+
found_id, object_type = notion_utils.find_page_or_database_by_id(
|
| 13 |
+
notion, main_id
|
| 14 |
+
)
|
| 15 |
+
if found_id and object_type == "page":
|
| 16 |
+
page_id = found_id
|
| 17 |
+
|
| 18 |
+
if not page_id:
|
| 19 |
+
page_id = notion_utils.find_page(notion, "New Online Resume")
|
| 20 |
+
if not page_id:
|
| 21 |
+
print("Error: Page 'New Online Resume' not found.", file=sys.stderr)
|
| 22 |
+
return False
|
| 23 |
+
|
| 24 |
+
# Step 1: Verify Skills Development Tracker database exists
|
| 25 |
+
tracker_db_id = notion_utils.find_database_in_block(
|
| 26 |
+
notion, page_id, "Skills Development Tracker"
|
| 27 |
+
)
|
| 28 |
+
if not tracker_db_id:
|
| 29 |
+
print(
|
| 30 |
+
"Error: Database 'Skills Development Tracker' not found.", file=sys.stderr
|
| 31 |
+
)
|
| 32 |
+
return False
|
| 33 |
+
|
| 34 |
+
# Step 2: Verify database schema
|
| 35 |
+
try:
|
| 36 |
+
db_info = notion.databases.retrieve(database_id=tracker_db_id)
|
| 37 |
+
properties = db_info.get("properties", {})
|
| 38 |
+
|
| 39 |
+
# Check required properties
|
| 40 |
+
required_props = {
|
| 41 |
+
"Name": "title",
|
| 42 |
+
"Current Skill": "relation",
|
| 43 |
+
"Current Proficiency": "rollup",
|
| 44 |
+
"Target Proficiency": "number",
|
| 45 |
+
"Gap": "formula",
|
| 46 |
+
"Learning Resources": "rich_text",
|
| 47 |
+
"Progress Notes": "rich_text",
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
for prop_name, expected_type in required_props.items():
|
| 51 |
+
if prop_name not in properties:
|
| 52 |
+
print(
|
| 53 |
+
f"Error: Property '{prop_name}' not found in database.",
|
| 54 |
+
file=sys.stderr,
|
| 55 |
+
)
|
| 56 |
+
return False
|
| 57 |
+
if properties[prop_name]["type"] != expected_type:
|
| 58 |
+
print(
|
| 59 |
+
f"Error: Property '{prop_name}' has incorrect type. Expected '{expected_type}', got '{properties[prop_name]['type']}'.",
|
| 60 |
+
file=sys.stderr,
|
| 61 |
+
)
|
| 62 |
+
return False
|
| 63 |
+
|
| 64 |
+
# Verify Target Proficiency is percent format
|
| 65 |
+
if (
|
| 66 |
+
properties["Target Proficiency"].get("number", {}).get("format")
|
| 67 |
+
!= "percent"
|
| 68 |
+
):
|
| 69 |
+
print(
|
| 70 |
+
"Error: Target Proficiency should have 'percent' format.",
|
| 71 |
+
file=sys.stderr,
|
| 72 |
+
)
|
| 73 |
+
return False
|
| 74 |
+
|
| 75 |
+
except Exception as e:
|
| 76 |
+
print(f"Error retrieving database info: {e}", file=sys.stderr)
|
| 77 |
+
return False
|
| 78 |
+
|
| 79 |
+
# Step 3: Get Skills database to check entries
|
| 80 |
+
skills_db_id = notion_utils.find_database_in_block(notion, page_id, "Skills")
|
| 81 |
+
if not skills_db_id:
|
| 82 |
+
print("Error: Skills database not found.", file=sys.stderr)
|
| 83 |
+
return False
|
| 84 |
+
|
| 85 |
+
# Get all skills with proficiency < 70%
|
| 86 |
+
skills_below_70 = []
|
| 87 |
+
try:
|
| 88 |
+
skills_results = notion.databases.query(database_id=skills_db_id).get(
|
| 89 |
+
"results", []
|
| 90 |
+
)
|
| 91 |
+
for skill in skills_results:
|
| 92 |
+
skill_level = (
|
| 93 |
+
skill.get("properties", {}).get("Skill Level", {}).get("number", 1.0)
|
| 94 |
+
)
|
| 95 |
+
if skill_level < 0.7:
|
| 96 |
+
skill_name = (
|
| 97 |
+
skill.get("properties", {}).get("Skill", {}).get("title", [])
|
| 98 |
+
)
|
| 99 |
+
if skill_name:
|
| 100 |
+
skill_name_text = skill_name[0].get("text", {}).get("content", "")
|
| 101 |
+
skills_below_70.append(
|
| 102 |
+
{
|
| 103 |
+
"name": skill_name_text,
|
| 104 |
+
"id": skill["id"],
|
| 105 |
+
"level": skill_level,
|
| 106 |
+
}
|
| 107 |
+
)
|
| 108 |
+
except Exception as e:
|
| 109 |
+
print(f"Error querying Skills database: {e}", file=sys.stderr)
|
| 110 |
+
return False
|
| 111 |
+
|
| 112 |
+
if not skills_below_70:
|
| 113 |
+
print("Warning: No skills found with proficiency below 70%.", file=sys.stderr)
|
| 114 |
+
# This might be OK if all skills are above 70%
|
| 115 |
+
|
| 116 |
+
# Step 4: Verify entries in Skills Development Tracker
|
| 117 |
+
try:
|
| 118 |
+
tracker_results = notion.databases.query(database_id=tracker_db_id).get(
|
| 119 |
+
"results", []
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
# Check that we have entries for skills below 70%
|
| 123 |
+
if len(skills_below_70) > 0 and len(tracker_results) == 0:
|
| 124 |
+
print(
|
| 125 |
+
"Error: No entries found in Skills Development Tracker database.",
|
| 126 |
+
file=sys.stderr,
|
| 127 |
+
)
|
| 128 |
+
return False
|
| 129 |
+
|
| 130 |
+
# Verify each entry
|
| 131 |
+
for entry in tracker_results:
|
| 132 |
+
props = entry.get("properties", {})
|
| 133 |
+
|
| 134 |
+
# Check name format
|
| 135 |
+
name_prop = props.get("Name", {}).get("title", [])
|
| 136 |
+
if not name_prop:
|
| 137 |
+
print("Error: Entry missing Name property.", file=sys.stderr)
|
| 138 |
+
return False
|
| 139 |
+
name_text = name_prop[0].get("text", {}).get("content", "")
|
| 140 |
+
if not name_text.endswith(" Development Plan"):
|
| 141 |
+
print(
|
| 142 |
+
f"Error: Entry name '{name_text}' doesn't follow expected format.",
|
| 143 |
+
file=sys.stderr,
|
| 144 |
+
)
|
| 145 |
+
return False
|
| 146 |
+
|
| 147 |
+
# Check relation to Skills database
|
| 148 |
+
skill_relation = props.get("Current Skill", {}).get("relation", [])
|
| 149 |
+
if not skill_relation:
|
| 150 |
+
print(
|
| 151 |
+
f"Error: Entry '{name_text}' missing Current Skill relation.",
|
| 152 |
+
file=sys.stderr,
|
| 153 |
+
)
|
| 154 |
+
return False
|
| 155 |
+
|
| 156 |
+
# Check Target Proficiency (should be set)
|
| 157 |
+
target_prof = props.get("Target Proficiency", {}).get("number")
|
| 158 |
+
if target_prof is None:
|
| 159 |
+
print(
|
| 160 |
+
f"Error: Entry '{name_text}' missing Target Proficiency.",
|
| 161 |
+
file=sys.stderr,
|
| 162 |
+
)
|
| 163 |
+
return False
|
| 164 |
+
|
| 165 |
+
# Check Learning Resources
|
| 166 |
+
learning_resources = props.get("Learning Resources", {}).get(
|
| 167 |
+
"rich_text", []
|
| 168 |
+
)
|
| 169 |
+
if not learning_resources:
|
| 170 |
+
print(
|
| 171 |
+
f"Error: Entry '{name_text}' missing Learning Resources.",
|
| 172 |
+
file=sys.stderr,
|
| 173 |
+
)
|
| 174 |
+
return False
|
| 175 |
+
|
| 176 |
+
# Check Progress Notes
|
| 177 |
+
progress_notes = props.get("Progress Notes", {}).get("rich_text", [])
|
| 178 |
+
if not progress_notes:
|
| 179 |
+
print(
|
| 180 |
+
f"Error: Entry '{name_text}' missing Progress Notes.",
|
| 181 |
+
file=sys.stderr,
|
| 182 |
+
)
|
| 183 |
+
return False
|
| 184 |
+
|
| 185 |
+
except Exception as e:
|
| 186 |
+
print(f"Error querying Skills Development Tracker: {e}", file=sys.stderr)
|
| 187 |
+
return False
|
| 188 |
+
|
| 189 |
+
print("Success: Skills Development Tracker database verified successfully.")
|
| 190 |
+
return True
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def main():
|
| 194 |
+
"""
|
| 195 |
+
Executes the verification process and exits with a status code.
|
| 196 |
+
"""
|
| 197 |
+
notion = notion_utils.get_notion_client()
|
| 198 |
+
main_id = sys.argv[1] if len(sys.argv) > 1 else None
|
| 199 |
+
if verify(notion, main_id):
|
| 200 |
+
sys.exit(0)
|
| 201 |
+
else:
|
| 202 |
+
sys.exit(1)
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
if __name__ == "__main__":
|
| 206 |
+
main()
|
tasks/notion/easy/python_roadmap/simple__expert_level_lessons/description.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Task: Expert Level Learning Path (Simplified)
|
| 2 |
+
|
| 3 |
+
## Objective
|
| 4 |
+
Extend the Python Roadmap with a new Expert Level chapter, create a bridge lesson, and add two expert lessons that build on existing material.
|
| 5 |
+
|
| 6 |
+
## Requirements
|
| 7 |
+
|
| 8 |
+
### 1. Add the Expert Level chapter
|
| 9 |
+
- **Database**: Chapters
|
| 10 |
+
- **Name**: `Expert Level`
|
| 11 |
+
- **Icon**: π£ (purple circle emoji)
|
| 12 |
+
- Make sure it is linked into the roadmap alongside the existing chapters.
|
| 13 |
+
|
| 14 |
+
### 2. Create the bridge lesson
|
| 15 |
+
Create a lesson that connects advanced material to the new chapter:
|
| 16 |
+
- **Title**: `Advanced Foundations Review`
|
| 17 |
+
- **Status**: Done
|
| 18 |
+
- **Chapter**: Link it to `Expert Level`
|
| 19 |
+
- **Parent item**: Link to the lesson whose title contains "Control" (e.g., "Control Flow")
|
| 20 |
+
- **Sub-items**: Include links to the lessons containing "Decorators" and "Calling API"
|
| 21 |
+
|
| 22 |
+
### 3. Add two expert lessons
|
| 23 |
+
Add the following entries to the Steps database:
|
| 24 |
+
|
| 25 |
+
| Lesson Title | Status | Chapter | Parent item | Date |
|
| 26 |
+
|--------------|--------|---------|-------------|------|
|
| 27 |
+
| `Metaprogramming and AST Manipulation` | To Do | Expert Level | Advanced Foundations Review | 2025-09-15 |
|
| 28 |
+
| `Async Concurrency Patterns` | To Do | Expert Level | Calling API | 2025-09-20 |
|
| 29 |
+
|
| 30 |
+
The lessons must inherit the correct chapter link, parent relationship, and due date as shown above.
|
tasks/notion/easy/python_roadmap/simple__expert_level_lessons/meta.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"task_id": "expert_level_lessons",
|
| 3 |
+
"task_name": "Expert Level Lessons",
|
| 4 |
+
"category_id": "python_roadmap",
|
| 5 |
+
"category_name": "Python Roadmap",
|
| 6 |
+
"description": "Create an Expert Level chapter with sophisticated prerequisite chains and four expert-level lessons.",
|
| 7 |
+
"author": "Xiangyan Liu",
|
| 8 |
+
"created_at": "2025-11-15",
|
| 9 |
+
"difficulty": "L1",
|
| 10 |
+
"tags": [
|
| 11 |
+
"database manipulation",
|
| 12 |
+
"cross-reference linking",
|
| 13 |
+
"conditional filtering",
|
| 14 |
+
"status tracking",
|
| 15 |
+
"template population"
|
| 16 |
+
],
|
| 17 |
+
"mcp": [
|
| 18 |
+
"notion"
|
| 19 |
+
],
|
| 20 |
+
"meta_data": {
|
| 21 |
+
"stateType": "url",
|
| 22 |
+
"stateContent": null,
|
| 23 |
+
"stateUrl": "https://painted-tennis-ebc.notion.site/Python-Roadmap-25281626b6d78012bf2bce1fa8711f4d",
|
| 24 |
+
"stateOriginalUrl": "https://www.notion.so/marketplace/templates/python-roadmap"
|
| 25 |
+
}
|
| 26 |
+
}
|
tasks/notion/easy/python_roadmap/simple__expert_level_lessons/verify.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
from notion_client import Client
|
| 3 |
+
from tasks.utils import notion_utils
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
TARGET_PAGE_TITLE = "Python Roadmap"
|
| 7 |
+
CHAPTER_NAME = "Expert Level"
|
| 8 |
+
CHAPTER_ICON = "π£"
|
| 9 |
+
BRIDGE_TITLE = "Advanced Foundations Review"
|
| 10 |
+
REQUIRED_SUBITEM_TITLES = ["Decorators", "Calling API"]
|
| 11 |
+
|
| 12 |
+
LESSON_REQUIREMENTS = [
|
| 13 |
+
{
|
| 14 |
+
"title": "Metaprogramming and AST Manipulation",
|
| 15 |
+
"status": "To Do",
|
| 16 |
+
"date": "2025-09-15",
|
| 17 |
+
"parent_title": BRIDGE_TITLE,
|
| 18 |
+
},
|
| 19 |
+
{
|
| 20 |
+
"title": "Async Concurrency Patterns",
|
| 21 |
+
"status": "To Do",
|
| 22 |
+
"date": "2025-09-20",
|
| 23 |
+
"parent_title": "Calling API",
|
| 24 |
+
},
|
| 25 |
+
]
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _get_database_ids(notion: Client, page_id: str) -> tuple[str | None, str | None]:
|
| 29 |
+
"""Return the block IDs for the Chapters and Steps databases on the page."""
|
| 30 |
+
chapters_db_id = None
|
| 31 |
+
steps_db_id = None
|
| 32 |
+
|
| 33 |
+
blocks = notion_utils.get_all_blocks_recursively(notion, page_id)
|
| 34 |
+
for block in blocks:
|
| 35 |
+
if block.get("type") != "child_database":
|
| 36 |
+
continue
|
| 37 |
+
title = block.get("child_database", {}).get("title", "")
|
| 38 |
+
if "Chapters" in title and not chapters_db_id:
|
| 39 |
+
chapters_db_id = block["id"]
|
| 40 |
+
elif "Steps" in title and not steps_db_id:
|
| 41 |
+
steps_db_id = block["id"]
|
| 42 |
+
|
| 43 |
+
return chapters_db_id, steps_db_id
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _query_step_by_title(notion: Client, database_id: str, title: str, *, exact: bool = True):
|
| 47 |
+
"""Return the first step entry matching the given title pattern."""
|
| 48 |
+
title_filter = {"equals": title} if exact else {"contains": title}
|
| 49 |
+
response = notion.databases.query(
|
| 50 |
+
database_id=database_id,
|
| 51 |
+
filter={"property": "Lessons", "title": title_filter},
|
| 52 |
+
page_size=5,
|
| 53 |
+
)
|
| 54 |
+
results = response.get("results", [])
|
| 55 |
+
return results[0] if results else None
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def verify(notion: Client, main_id: str | None = None) -> bool:
|
| 59 |
+
"""Verify the simplified Expert Level learning path setup."""
|
| 60 |
+
# Resolve the roadmap page.
|
| 61 |
+
if main_id:
|
| 62 |
+
page_id, object_type = notion_utils.find_page_or_database_by_id(notion, main_id)
|
| 63 |
+
if not page_id or object_type != "page":
|
| 64 |
+
print("Error: Python Roadmap page not found.", file=sys.stderr)
|
| 65 |
+
return False
|
| 66 |
+
else:
|
| 67 |
+
page_id = notion_utils.find_page(notion, TARGET_PAGE_TITLE)
|
| 68 |
+
if not page_id:
|
| 69 |
+
print("Error: Python Roadmap page not found.", file=sys.stderr)
|
| 70 |
+
return False
|
| 71 |
+
|
| 72 |
+
# Locate the Chapters and Steps databases.
|
| 73 |
+
chapters_db_id, steps_db_id = _get_database_ids(notion, page_id)
|
| 74 |
+
if not chapters_db_id:
|
| 75 |
+
print("Error: Chapters database not found on the page.", file=sys.stderr)
|
| 76 |
+
return False
|
| 77 |
+
if not steps_db_id:
|
| 78 |
+
print("Error: Steps database not found on the page.", file=sys.stderr)
|
| 79 |
+
return False
|
| 80 |
+
|
| 81 |
+
# Ensure the Expert Level chapter exists with the purple icon.
|
| 82 |
+
try:
|
| 83 |
+
chapter_resp = notion.databases.query(
|
| 84 |
+
database_id=chapters_db_id,
|
| 85 |
+
filter={"property": "Name", "title": {"equals": CHAPTER_NAME}},
|
| 86 |
+
page_size=1,
|
| 87 |
+
)
|
| 88 |
+
except Exception as exc:
|
| 89 |
+
print(f"Error querying Chapters database: {exc}", file=sys.stderr)
|
| 90 |
+
return False
|
| 91 |
+
|
| 92 |
+
results = chapter_resp.get("results", [])
|
| 93 |
+
if not results:
|
| 94 |
+
print("Error: Expert Level chapter not found.", file=sys.stderr)
|
| 95 |
+
return False
|
| 96 |
+
|
| 97 |
+
expert_chapter = results[0]
|
| 98 |
+
expert_chapter_id = expert_chapter["id"]
|
| 99 |
+
icon = expert_chapter.get("icon") or {}
|
| 100 |
+
if icon.get("type") != "emoji" or icon.get("emoji") != CHAPTER_ICON:
|
| 101 |
+
print("Error: Expert Level chapter must use the purple circle emoji icon.", file=sys.stderr)
|
| 102 |
+
return False
|
| 103 |
+
|
| 104 |
+
print("β Expert Level chapter exists with the correct icon.")
|
| 105 |
+
|
| 106 |
+
# Locate prerequisite lessons (Control Flow, Decorators, Calling API).
|
| 107 |
+
control_lesson = _query_step_by_title(notion, steps_db_id, "Control", exact=False)
|
| 108 |
+
if not control_lesson:
|
| 109 |
+
print("Error: Could not find a lesson containing 'Control' in its title.", file=sys.stderr)
|
| 110 |
+
return False
|
| 111 |
+
control_lesson_id = control_lesson["id"]
|
| 112 |
+
|
| 113 |
+
prerequisite_ids = {}
|
| 114 |
+
for title in REQUIRED_SUBITEM_TITLES:
|
| 115 |
+
lesson = _query_step_by_title(notion, steps_db_id, title, exact=False)
|
| 116 |
+
if not lesson:
|
| 117 |
+
print(f"Error: Required lesson containing '{title}' not found.", file=sys.stderr)
|
| 118 |
+
return False
|
| 119 |
+
prerequisite_ids[title] = lesson["id"]
|
| 120 |
+
|
| 121 |
+
# Verify the bridge lesson.
|
| 122 |
+
bridge_lesson = _query_step_by_title(notion, steps_db_id, BRIDGE_TITLE, exact=True)
|
| 123 |
+
if not bridge_lesson:
|
| 124 |
+
print("Error: Advanced Foundations Review lesson not found.", file=sys.stderr)
|
| 125 |
+
return False
|
| 126 |
+
|
| 127 |
+
status = (bridge_lesson["properties"].get("Status", {}).get("status") or {}).get("name")
|
| 128 |
+
if status != "Done":
|
| 129 |
+
print("Error: Advanced Foundations Review must have status 'Done'.", file=sys.stderr)
|
| 130 |
+
return False
|
| 131 |
+
|
| 132 |
+
# Ensure chapter relation includes Expert Level.
|
| 133 |
+
chapter_rel = bridge_lesson["properties"].get("Chapters", {}).get("relation", [])
|
| 134 |
+
if not any(rel["id"] == expert_chapter_id for rel in chapter_rel):
|
| 135 |
+
print("Error: Advanced Foundations Review must link to the Expert Level chapter.", file=sys.stderr)
|
| 136 |
+
return False
|
| 137 |
+
|
| 138 |
+
# Parent item should be the control lesson.
|
| 139 |
+
parent_rel = bridge_lesson["properties"].get("Parent item", {}).get("relation", [])
|
| 140 |
+
if not parent_rel or parent_rel[0]["id"] != control_lesson_id:
|
| 141 |
+
print("Error: Advanced Foundations Review should use the control lesson as its Parent item.", file=sys.stderr)
|
| 142 |
+
return False
|
| 143 |
+
|
| 144 |
+
# Sub-items must include the required lessons.
|
| 145 |
+
sub_rel = bridge_lesson["properties"].get("Sub-item", {}).get("relation", [])
|
| 146 |
+
sub_ids = {rel["id"] for rel in sub_rel}
|
| 147 |
+
missing = [title for title, rel_id in prerequisite_ids.items() if rel_id not in sub_ids]
|
| 148 |
+
if missing:
|
| 149 |
+
print(
|
| 150 |
+
f"Error: Advanced Foundations Review must include these lessons as sub-items: {', '.join(missing)}.",
|
| 151 |
+
file=sys.stderr,
|
| 152 |
+
)
|
| 153 |
+
return False
|
| 154 |
+
|
| 155 |
+
print("β Bridge lesson configured with the correct status, chapter, parent, and sub-items.")
|
| 156 |
+
|
| 157 |
+
# Verify the two expert lessons.
|
| 158 |
+
overall_success = True
|
| 159 |
+
for spec in LESSON_REQUIREMENTS:
|
| 160 |
+
lesson = _query_step_by_title(notion, steps_db_id, spec["title"], exact=True)
|
| 161 |
+
if not lesson:
|
| 162 |
+
print(f"Error: Lesson '{spec['title']}' not found.", file=sys.stderr)
|
| 163 |
+
overall_success = False
|
| 164 |
+
continue
|
| 165 |
+
|
| 166 |
+
lesson_ok = True
|
| 167 |
+
|
| 168 |
+
# Status check.
|
| 169 |
+
status_name = (lesson["properties"].get("Status", {}).get("status") or {}).get("name")
|
| 170 |
+
if status_name != spec["status"]:
|
| 171 |
+
print(
|
| 172 |
+
f"Error: Lesson '{spec['title']}' should have status '{spec['status']}', found '{status_name}'.",
|
| 173 |
+
file=sys.stderr,
|
| 174 |
+
)
|
| 175 |
+
lesson_ok = False
|
| 176 |
+
|
| 177 |
+
# Chapter relation check.
|
| 178 |
+
lesson_chapters = lesson["properties"].get("Chapters", {}).get("relation", [])
|
| 179 |
+
if not any(rel["id"] == expert_chapter_id for rel in lesson_chapters):
|
| 180 |
+
print(f"Error: Lesson '{spec['title']}' must link to the Expert Level chapter.", file=sys.stderr)
|
| 181 |
+
lesson_ok = False
|
| 182 |
+
|
| 183 |
+
# Parent relation check.
|
| 184 |
+
parent_title = spec["parent_title"]
|
| 185 |
+
if parent_title == BRIDGE_TITLE:
|
| 186 |
+
expected_parent_id = bridge_lesson["id"]
|
| 187 |
+
else:
|
| 188 |
+
expected_parent_id = prerequisite_ids.get(parent_title)
|
| 189 |
+
|
| 190 |
+
parent_relation = lesson["properties"].get("Parent item", {}).get("relation", [])
|
| 191 |
+
if not expected_parent_id:
|
| 192 |
+
print(
|
| 193 |
+
f"Error: Could not resolve expected parent '{parent_title}' for lesson '{spec['title']}'.",
|
| 194 |
+
file=sys.stderr,
|
| 195 |
+
)
|
| 196 |
+
lesson_ok = False
|
| 197 |
+
else:
|
| 198 |
+
if not parent_relation or parent_relation[0]["id"] != expected_parent_id:
|
| 199 |
+
print(
|
| 200 |
+
f"Error: Lesson '{spec['title']}' should have '{parent_title}' as its Parent item.",
|
| 201 |
+
file=sys.stderr,
|
| 202 |
+
)
|
| 203 |
+
lesson_ok = False
|
| 204 |
+
# Date check.
|
| 205 |
+
date_prop = lesson["properties"].get("Date", {}).get("date") or {}
|
| 206 |
+
if date_prop.get("start") != spec["date"]:
|
| 207 |
+
print(
|
| 208 |
+
f"Error: Lesson '{spec['title']}' should use date {spec['date']}, found {date_prop.get('start')}.",
|
| 209 |
+
file=sys.stderr,
|
| 210 |
+
)
|
| 211 |
+
lesson_ok = False
|
| 212 |
+
|
| 213 |
+
if lesson_ok:
|
| 214 |
+
print(f"β Lesson '{spec['title']}' has the expected properties.")
|
| 215 |
+
else:
|
| 216 |
+
overall_success = False
|
| 217 |
+
|
| 218 |
+
if not overall_success:
|
| 219 |
+
return False
|
| 220 |
+
|
| 221 |
+
print("Success: Expert Level chapter, bridge lesson, and expert lessons configured correctly.")
|
| 222 |
+
return True
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def main() -> None:
|
| 226 |
+
notion = notion_utils.get_notion_client()
|
| 227 |
+
main_id = sys.argv[1] if len(sys.argv) > 1 else None
|
| 228 |
+
if verify(notion, main_id):
|
| 229 |
+
sys.exit(0)
|
| 230 |
+
sys.exit(1)
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
if __name__ == "__main__":
|
| 234 |
+
main()
|
tasks/notion/easy/self_assessment/simple__faq_column_layout/description.md
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Navigate to the "Self Assessment" page and reorganize the FAQ toggle content to make it easier to scan.
|
| 2 |
+
|
| 3 |
+
**Task Requirements:**
|
| 4 |
+
1. Add a column list with two columns inside the FAQ toggle.
|
| 5 |
+
2. Move the first two existing Q&A pairs from the FAQ into the left column.
|
| 6 |
+
3. Move the third existing Q&A pair into the right column, keeping the original heading/paragraph formatting.
|
tasks/notion/easy/self_assessment/simple__faq_column_layout/meta.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"task_id": "simple__faq_column_layout",
|
| 3 |
+
"task_name": "Simple FAQ Column Layout",
|
| 4 |
+
"category_id": "self_assessment",
|
| 5 |
+
"category_name": "Self Assessment",
|
| 6 |
+
"description": "Reorganize the FAQ section content into a two-column layout with balanced Q&A pairs.",
|
| 7 |
+
"author": "Xiangyan Liu",
|
| 8 |
+
"created_at": "2025-11-15",
|
| 9 |
+
"difficulty": "L1",
|
| 10 |
+
"tags": [
|
| 11 |
+
"content organization",
|
| 12 |
+
"visual formatting",
|
| 13 |
+
"template population"
|
| 14 |
+
],
|
| 15 |
+
"mcp": [
|
| 16 |
+
"notion"
|
| 17 |
+
],
|
| 18 |
+
"meta_data": {
|
| 19 |
+
"stateType": "url",
|
| 20 |
+
"stateContent": null,
|
| 21 |
+
"stateUrl": "https://painted-tennis-ebc.notion.site/Self-Assessment-24381626b6d780fe9f56c2ba14ea042d",
|
| 22 |
+
"stateOriginalUrl": "https://painted-tennis-ebc.notion.site/Self-Assessment-24381626b6d780fe9f56c2ba14ea042d"
|
| 23 |
+
}
|
| 24 |
+
}
|
tasks/notion/easy/self_assessment/simple__faq_column_layout/verify.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
from notion_client import Client
|
| 3 |
+
from tasks.utils import notion_utils
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def verify(notion: Client, main_id: str = None) -> bool:
|
| 7 |
+
"""
|
| 8 |
+
Verifies that the FAQ toggle has been properly reorganized with a column list.
|
| 9 |
+
"""
|
| 10 |
+
# Start from main_id if provided
|
| 11 |
+
page_id = None
|
| 12 |
+
if main_id:
|
| 13 |
+
found_id, object_type = notion_utils.find_page_or_database_by_id(
|
| 14 |
+
notion, main_id
|
| 15 |
+
)
|
| 16 |
+
if found_id and object_type == "page":
|
| 17 |
+
page_id = found_id
|
| 18 |
+
|
| 19 |
+
if not page_id:
|
| 20 |
+
# Try to find the Self Assessment page
|
| 21 |
+
page_id = notion_utils.find_page(notion, "Self Assessment")
|
| 22 |
+
|
| 23 |
+
if not page_id:
|
| 24 |
+
print("Error: Self Assessment page not found.", file=sys.stderr)
|
| 25 |
+
return False
|
| 26 |
+
|
| 27 |
+
# Get all blocks recursively from the page
|
| 28 |
+
all_blocks = notion_utils.get_all_blocks_recursively(notion, page_id)
|
| 29 |
+
|
| 30 |
+
# Find the FAQ toggle block
|
| 31 |
+
faq_toggle_block = None
|
| 32 |
+
faq_toggle_id = None
|
| 33 |
+
for block in all_blocks:
|
| 34 |
+
if block.get("type") == "toggle":
|
| 35 |
+
block_text = notion_utils.get_block_plain_text(block)
|
| 36 |
+
if "FAQ" in block_text:
|
| 37 |
+
faq_toggle_block = block
|
| 38 |
+
faq_toggle_id = block.get("id")
|
| 39 |
+
print(f"Found FAQ toggle block: {block_text}")
|
| 40 |
+
break
|
| 41 |
+
|
| 42 |
+
if not faq_toggle_block:
|
| 43 |
+
print("Error: FAQ toggle block not found.", file=sys.stderr)
|
| 44 |
+
return False
|
| 45 |
+
|
| 46 |
+
# Find column_list inside the FAQ toggle
|
| 47 |
+
column_list_block = None
|
| 48 |
+
for block in all_blocks:
|
| 49 |
+
if (
|
| 50 |
+
block.get("type") == "column_list"
|
| 51 |
+
and block.get("parent", {}).get("block_id") == faq_toggle_id
|
| 52 |
+
):
|
| 53 |
+
column_list_block = block
|
| 54 |
+
break
|
| 55 |
+
|
| 56 |
+
if not column_list_block:
|
| 57 |
+
print("Error: No column_list found inside FAQ toggle.", file=sys.stderr)
|
| 58 |
+
return False
|
| 59 |
+
|
| 60 |
+
# Check that there are no Q&A pairs directly under FAQ toggle (outside column_list)
|
| 61 |
+
direct_faq_children = []
|
| 62 |
+
for block in all_blocks:
|
| 63 |
+
if block.get("parent", {}).get("block_id") == faq_toggle_id and block.get(
|
| 64 |
+
"id"
|
| 65 |
+
) != column_list_block.get("id"):
|
| 66 |
+
direct_faq_children.append(block)
|
| 67 |
+
|
| 68 |
+
# Check if any of these are heading_3 or paragraph blocks (Q&A content)
|
| 69 |
+
for block in direct_faq_children:
|
| 70 |
+
if block.get("type") in ["heading_3", "paragraph"]:
|
| 71 |
+
print(
|
| 72 |
+
f"Error: Found Q&A content outside column_list: {notion_utils.get_block_plain_text(block)[:50]}...",
|
| 73 |
+
file=sys.stderr,
|
| 74 |
+
)
|
| 75 |
+
return False
|
| 76 |
+
|
| 77 |
+
# Find the two columns
|
| 78 |
+
columns = []
|
| 79 |
+
column_list_id = column_list_block.get("id")
|
| 80 |
+
for block in all_blocks:
|
| 81 |
+
if (
|
| 82 |
+
block.get("type") == "column"
|
| 83 |
+
and block.get("parent", {}).get("block_id") == column_list_id
|
| 84 |
+
):
|
| 85 |
+
columns.append(block)
|
| 86 |
+
|
| 87 |
+
if len(columns) != 2:
|
| 88 |
+
print(f"Error: Expected 2 columns, found {len(columns)}.", file=sys.stderr)
|
| 89 |
+
return False
|
| 90 |
+
|
| 91 |
+
# Count Q&A pairs in each column
|
| 92 |
+
qa_counts = []
|
| 93 |
+
total_pairs = 0
|
| 94 |
+
|
| 95 |
+
for i, column in enumerate(columns[:2]):
|
| 96 |
+
column_id = column.get("id")
|
| 97 |
+
|
| 98 |
+
column_blocks = [
|
| 99 |
+
block
|
| 100 |
+
for block in all_blocks
|
| 101 |
+
if block.get("parent", {}).get("block_id") == column_id
|
| 102 |
+
]
|
| 103 |
+
|
| 104 |
+
qa_pairs = 0
|
| 105 |
+
j = 0
|
| 106 |
+
while j < len(column_blocks):
|
| 107 |
+
if (
|
| 108 |
+
column_blocks[j].get("type") == "heading_3"
|
| 109 |
+
and j + 1 < len(column_blocks)
|
| 110 |
+
and column_blocks[j + 1].get("type") == "paragraph"
|
| 111 |
+
):
|
| 112 |
+
qa_pairs += 1
|
| 113 |
+
j += 2
|
| 114 |
+
else:
|
| 115 |
+
j += 1
|
| 116 |
+
|
| 117 |
+
qa_counts.append(qa_pairs)
|
| 118 |
+
total_pairs += qa_pairs
|
| 119 |
+
print(f"Column {i + 1}: Found {qa_pairs} Q&A pairs")
|
| 120 |
+
|
| 121 |
+
if qa_counts[0] < 2:
|
| 122 |
+
print(
|
| 123 |
+
f"Error: Left column should contain at least 2 Q&A pairs, found {qa_counts[0]}.",
|
| 124 |
+
file=sys.stderr,
|
| 125 |
+
)
|
| 126 |
+
return False
|
| 127 |
+
|
| 128 |
+
if qa_counts[1] < 1:
|
| 129 |
+
print(
|
| 130 |
+
f"Error: Right column should contain at least 1 Q&A pair, found {qa_counts[1]}.",
|
| 131 |
+
file=sys.stderr,
|
| 132 |
+
)
|
| 133 |
+
return False
|
| 134 |
+
|
| 135 |
+
if total_pairs < 3:
|
| 136 |
+
print(
|
| 137 |
+
f"Error: Expected at least 3 total Q&A pairs across both columns, found {total_pairs}.",
|
| 138 |
+
file=sys.stderr,
|
| 139 |
+
)
|
| 140 |
+
return False
|
| 141 |
+
|
| 142 |
+
print(
|
| 143 |
+
"Success: FAQ toggle organized with two columns holding the existing Q&A pairs (two on the left, one on the right)."
|
| 144 |
+
)
|
| 145 |
+
return True
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def main():
|
| 149 |
+
"""
|
| 150 |
+
Executes the verification process and exits with a status code.
|
| 151 |
+
"""
|
| 152 |
+
notion = notion_utils.get_notion_client()
|
| 153 |
+
main_id = sys.argv[1] if len(sys.argv) > 1 else None
|
| 154 |
+
if verify(notion, main_id):
|
| 155 |
+
sys.exit(0)
|
| 156 |
+
else:
|
| 157 |
+
sys.exit(1)
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
if __name__ == "__main__":
|
| 161 |
+
main()
|
tasks/notion/easy/standard_operating_procedure/simple__section_organization/description.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Task: Reorganize Standard Operating Procedure Page Sections
|
| 2 |
+
|
| 3 |
+
## Objective
|
| 4 |
+
Modify the structure of the Standard Operating Procedure page in Notion by updating the order of two sections.
|
| 5 |
+
|
| 6 |
+
## Requirements
|
| 7 |
+
- Navigate to the Standard Operating Procedure page
|
| 8 |
+
- Swap the positions of the "Terminologies" and "Roles & responsibilities" sections
|
| 9 |
+
- Preserve all content within each section exactly as is
|
| 10 |
+
- Maintain the original formatting and structure of each section
|
tasks/notion/easy/standard_operating_procedure/simple__section_organization/meta.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"task_id": "simple__section_organization",
|
| 3 |
+
"task_name": "Simple Section Organization",
|
| 4 |
+
"category_id": "standard_operating_procedure",
|
| 5 |
+
"category_name": "Standard Operating Procedure",
|
| 6 |
+
"description": "Reorganize the Standard Operating Procedure page by swapping sections and creating a column layout.",
|
| 7 |
+
"author": "Xiangyan Liu",
|
| 8 |
+
"created_at": "2025-11-15",
|
| 9 |
+
"difficulty": "L1",
|
| 10 |
+
"tags": [
|
| 11 |
+
"content organization",
|
| 12 |
+
"cross-reference linking",
|
| 13 |
+
"visual formatting"
|
| 14 |
+
],
|
| 15 |
+
"mcp": [
|
| 16 |
+
"notion"
|
| 17 |
+
],
|
| 18 |
+
"meta_data": {
|
| 19 |
+
"stateType": "url",
|
| 20 |
+
"stateContent": null,
|
| 21 |
+
"stateUrl": "https://painted-tennis-ebc.notion.site/Standard-Operating-Procedure-24381626b6d780a8b678f9e62ae5b152",
|
| 22 |
+
"stateOriginalUrl": "https://www.notion.so/marketplace/templates/standard-operating-procedure"
|
| 23 |
+
}
|
| 24 |
+
}
|
tasks/notion/easy/standard_operating_procedure/simple__section_organization/verify.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
from notion_client import Client
|
| 3 |
+
from tasks.utils import notion_utils
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
TARGET_PAGE_TITLE = "Standard Operating Procedure"
|
| 7 |
+
ROLES_HEADING = "Roles & responsibilities"
|
| 8 |
+
TERMINOLOGIES_HEADING = "Terminologies"
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def _find_heading_indices(blocks: list[dict]) -> tuple[int | None, int | None]:
|
| 12 |
+
"""Return the indices of the target headings within the flattened block list."""
|
| 13 |
+
roles_index = None
|
| 14 |
+
terminologies_index = None
|
| 15 |
+
|
| 16 |
+
for index, block in enumerate(blocks):
|
| 17 |
+
if block.get("type") != "heading_2":
|
| 18 |
+
continue
|
| 19 |
+
rich_text = block.get("heading_2", {}).get("rich_text", [])
|
| 20 |
+
if not rich_text:
|
| 21 |
+
continue
|
| 22 |
+
heading_text = rich_text[0].get("text", {}).get("content", "")
|
| 23 |
+
if heading_text == ROLES_HEADING and roles_index is None:
|
| 24 |
+
roles_index = index
|
| 25 |
+
elif heading_text == TERMINOLOGIES_HEADING and terminologies_index is None:
|
| 26 |
+
terminologies_index = index
|
| 27 |
+
|
| 28 |
+
return roles_index, terminologies_index
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def verify(notion: Client, main_id: str | None = None) -> bool:
|
| 32 |
+
"""Ensure the Roles & responsibilities section appears before Terminologies."""
|
| 33 |
+
# Resolve page id.
|
| 34 |
+
if main_id:
|
| 35 |
+
page_id, object_type = notion_utils.find_page_or_database_by_id(notion, main_id)
|
| 36 |
+
if not page_id or object_type != "page":
|
| 37 |
+
print("Error: Standard Operating Procedure page not found.", file=sys.stderr)
|
| 38 |
+
return False
|
| 39 |
+
else:
|
| 40 |
+
page_id = notion_utils.find_page(notion, TARGET_PAGE_TITLE)
|
| 41 |
+
if not page_id:
|
| 42 |
+
print("Error: Standard Operating Procedure page not found.", file=sys.stderr)
|
| 43 |
+
return False
|
| 44 |
+
|
| 45 |
+
# Fetch all blocks (flattened order from top to bottom).
|
| 46 |
+
blocks = notion_utils.get_all_blocks_recursively(notion, page_id)
|
| 47 |
+
roles_index, terminologies_index = _find_heading_indices(blocks)
|
| 48 |
+
|
| 49 |
+
if roles_index is None:
|
| 50 |
+
print("Error: 'Roles & responsibilities' section not found.", file=sys.stderr)
|
| 51 |
+
return False
|
| 52 |
+
if terminologies_index is None:
|
| 53 |
+
print("Error: 'Terminologies' section not found.", file=sys.stderr)
|
| 54 |
+
return False
|
| 55 |
+
|
| 56 |
+
if roles_index >= terminologies_index:
|
| 57 |
+
print(
|
| 58 |
+
"Error: Sections are not swapped. 'Roles & responsibilities' should appear before 'Terminologies'.",
|
| 59 |
+
file=sys.stderr,
|
| 60 |
+
)
|
| 61 |
+
return False
|
| 62 |
+
|
| 63 |
+
print("Success: Section order updated so 'Roles & responsibilities' precedes 'Terminologies'.")
|
| 64 |
+
return True
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def main() -> None:
|
| 68 |
+
notion = notion_utils.get_notion_client()
|
| 69 |
+
main_id = sys.argv[1] if len(sys.argv) > 1 else None
|
| 70 |
+
if verify(notion, main_id):
|
| 71 |
+
sys.exit(0)
|
| 72 |
+
sys.exit(1)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
if __name__ == "__main__":
|
| 76 |
+
main()
|
tasks/notion/easy/team_projects/simple__swap_tasks/description.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
Go to the Team Projects page, find the person responsible for the most tasks (10 in total) and the person responsible for the fewest tasks (3 in total), then swap their assigned tasks.
|
tasks/notion/easy/team_projects/simple__swap_tasks/meta.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"task_id": "simple__swap_tasks",
|
| 3 |
+
"task_name": "Simple Swap Tasks",
|
| 4 |
+
"category_id": "team_projects",
|
| 5 |
+
"category_name": "Team Projects",
|
| 6 |
+
"description": "Find the person responsible for the most and fewest tasks, then swap their assigned tasks.",
|
| 7 |
+
"author": "Xiangyan Liu",
|
| 8 |
+
"created_at": "2025-11-15",
|
| 9 |
+
"difficulty": "L1",
|
| 10 |
+
"tags": [
|
| 11 |
+
"data aggregation",
|
| 12 |
+
"automated migration",
|
| 13 |
+
"conditional filtering"
|
| 14 |
+
],
|
| 15 |
+
"mcp": [
|
| 16 |
+
"notion"
|
| 17 |
+
],
|
| 18 |
+
"meta_data": {
|
| 19 |
+
"stateType": "url",
|
| 20 |
+
"stateContent": null,
|
| 21 |
+
"stateUrl": "https://painted-tennis-ebc.notion.site/Team-Projects-24e81626b6d7809c982fdb7a25825898",
|
| 22 |
+
"stateOriginalUrl": "https://www.notion.so/marketplace/templates/gantt-chart"
|
| 23 |
+
}
|
| 24 |
+
}
|
tasks/notion/easy/team_projects/simple__swap_tasks/verify.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
from notion_client import Client
|
| 3 |
+
from tasks.utils import notion_utils
|
| 4 |
+
|
| 5 |
+
def verify(notion: Client, main_id: str = None) -> bool:
|
| 6 |
+
"""
|
| 7 |
+
Verifies that the task assignees have been swapped correctly.
|
| 8 |
+
Checks:
|
| 9 |
+
1. "Develop a plan for promotion" and "Evaluate different third-party services" have swapped assignees
|
| 10 |
+
2. The person with most tasks and person with least tasks have swapped all their tasks
|
| 11 |
+
"""
|
| 12 |
+
# Step 1: Find the Team Projects page
|
| 13 |
+
if main_id:
|
| 14 |
+
found_id, object_type = notion_utils.find_page_or_database_by_id(notion, main_id)
|
| 15 |
+
if not found_id or object_type != 'page':
|
| 16 |
+
print("Error: Team Projects page not found.", file=sys.stderr)
|
| 17 |
+
return False
|
| 18 |
+
else:
|
| 19 |
+
# Try to find the page by searching
|
| 20 |
+
found_id = notion_utils.find_page(notion, "Team Projects")
|
| 21 |
+
if not found_id:
|
| 22 |
+
print("Error: Team Projects page not found.", file=sys.stderr)
|
| 23 |
+
return False
|
| 24 |
+
|
| 25 |
+
# Get all blocks from the page to find database references
|
| 26 |
+
all_blocks = notion_utils.get_all_blocks_recursively(notion, found_id)
|
| 27 |
+
|
| 28 |
+
# Find Tasks database ID from the page
|
| 29 |
+
tasks_db_id = None
|
| 30 |
+
|
| 31 |
+
for block in all_blocks:
|
| 32 |
+
if block and block.get("type") == "child_database":
|
| 33 |
+
db_title = block.get("child_database", {}).get("title", "")
|
| 34 |
+
if "Tasks" in db_title:
|
| 35 |
+
tasks_db_id = block["id"]
|
| 36 |
+
break
|
| 37 |
+
|
| 38 |
+
if not tasks_db_id:
|
| 39 |
+
print("Error: Tasks database not found.", file=sys.stderr)
|
| 40 |
+
return False
|
| 41 |
+
|
| 42 |
+
print("\nπ Starting verification...")
|
| 43 |
+
|
| 44 |
+
# Step 2: Query all tasks to analyze assignees
|
| 45 |
+
|
| 46 |
+
try:
|
| 47 |
+
all_tasks_response = notion.databases.query(
|
| 48 |
+
database_id=tasks_db_id,
|
| 49 |
+
page_size=100
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
if not all_tasks_response.get("results"):
|
| 53 |
+
print("Error: No tasks found in Tasks database.", file=sys.stderr)
|
| 54 |
+
return False
|
| 55 |
+
|
| 56 |
+
tasks = all_tasks_response["results"]
|
| 57 |
+
|
| 58 |
+
except Exception as e:
|
| 59 |
+
print(f"Error querying Tasks database: {e}", file=sys.stderr)
|
| 60 |
+
return False
|
| 61 |
+
|
| 62 |
+
# Step 3: Check specific tasks have swapped assignees
|
| 63 |
+
|
| 64 |
+
develop_plan_task = None
|
| 65 |
+
evaluate_services_task = None
|
| 66 |
+
|
| 67 |
+
for task in tasks:
|
| 68 |
+
task_name = task["properties"]["Name"]["title"][0]["text"]["content"]
|
| 69 |
+
if task_name == "Develop a plan for promotion":
|
| 70 |
+
develop_plan_task = task
|
| 71 |
+
elif task_name == "Evaluate different third-party services":
|
| 72 |
+
evaluate_services_task = task
|
| 73 |
+
|
| 74 |
+
if not develop_plan_task or not evaluate_services_task:
|
| 75 |
+
print("Error: Could not find both required tasks.", file=sys.stderr)
|
| 76 |
+
return False
|
| 77 |
+
|
| 78 |
+
# Get assignees for these tasks
|
| 79 |
+
develop_plan_assignees = develop_plan_task["properties"]["Assigned"]["people"]
|
| 80 |
+
evaluate_services_assignees = evaluate_services_task["properties"]["Assigned"]["people"]
|
| 81 |
+
|
| 82 |
+
if not develop_plan_assignees or not evaluate_services_assignees:
|
| 83 |
+
print("Error: Tasks don't have assignees.", file=sys.stderr)
|
| 84 |
+
return False
|
| 85 |
+
|
| 86 |
+
develop_plan_assignee_id = develop_plan_assignees[0]["id"]
|
| 87 |
+
evaluate_services_assignee_id = evaluate_services_assignees[0]["id"]
|
| 88 |
+
|
| 89 |
+
# These should be different (swapped)
|
| 90 |
+
if develop_plan_assignee_id == evaluate_services_assignee_id:
|
| 91 |
+
print("Error: Tasks should have different assignees after swap.", file=sys.stderr)
|
| 92 |
+
return False
|
| 93 |
+
|
| 94 |
+
# Step 4: Count tasks per person
|
| 95 |
+
|
| 96 |
+
task_counts = {}
|
| 97 |
+
unassigned_count = 0
|
| 98 |
+
|
| 99 |
+
for task in tasks:
|
| 100 |
+
assignees = task["properties"]["Assigned"]["people"]
|
| 101 |
+
if assignees:
|
| 102 |
+
assignee_id = assignees[0]["id"]
|
| 103 |
+
if assignee_id not in task_counts:
|
| 104 |
+
task_counts[assignee_id] = []
|
| 105 |
+
task_counts[assignee_id].append(task["properties"]["Name"]["title"][0]["text"]["content"])
|
| 106 |
+
else:
|
| 107 |
+
unassigned_count += 1
|
| 108 |
+
|
| 109 |
+
# Sort by task count
|
| 110 |
+
sorted_assignees = sorted(task_counts.items(), key=lambda x: len(x[1]))
|
| 111 |
+
|
| 112 |
+
if len(sorted_assignees) < 2:
|
| 113 |
+
print("Error: Need at least 2 people with tasks to verify swap.", file=sys.stderr)
|
| 114 |
+
return False
|
| 115 |
+
|
| 116 |
+
# Get person with least and most tasks
|
| 117 |
+
person_with_least = sorted_assignees[0]
|
| 118 |
+
person_with_most = sorted_assignees[-1]
|
| 119 |
+
|
| 120 |
+
least_id, least_tasks = person_with_least
|
| 121 |
+
most_id, most_tasks = person_with_most
|
| 122 |
+
|
| 123 |
+
# Step 5: Verify the swap pattern
|
| 124 |
+
|
| 125 |
+
# Original distribution (before swap):
|
| 126 |
+
# - 5ac96c02-49a4-4320-8de6-b663ba83126b had 3 tasks (least)
|
| 127 |
+
# - ac7a3bd0-c111-4464-8f45-8a857a1abc8a had 10 tasks (most)
|
| 128 |
+
|
| 129 |
+
# After complete swap, we expect:
|
| 130 |
+
# - 5ac96c02-49a4-4320-8de6-b663ba83126b should have 10 tasks
|
| 131 |
+
# - ac7a3bd0-c111-4464-8f45-8a857a1abc8a should have 3 tasks
|
| 132 |
+
|
| 133 |
+
original_least_id = "5ac96c02-49a4-4320-8de6-b663ba83126b"
|
| 134 |
+
original_most_id = "ac7a3bd0-c111-4464-8f45-8a857a1abc8a"
|
| 135 |
+
|
| 136 |
+
# Check if the swap has been completed
|
| 137 |
+
swap_completed = False
|
| 138 |
+
for assignee_id, assignee_tasks in task_counts.items():
|
| 139 |
+
if assignee_id == original_least_id and len(assignee_tasks) == 10:
|
| 140 |
+
# Person who had 3 now has 10
|
| 141 |
+
for other_id, other_tasks in task_counts.items():
|
| 142 |
+
if other_id == original_most_id and len(other_tasks) == 3:
|
| 143 |
+
# Person who had 10 now has 3
|
| 144 |
+
swap_completed = True
|
| 145 |
+
break
|
| 146 |
+
|
| 147 |
+
# Step 6: Summary
|
| 148 |
+
print(f"\nπ Task Distribution:")
|
| 149 |
+
print(f" β’ Total tasks: {len(tasks)}")
|
| 150 |
+
print(f" β’ Assigned tasks: {len(tasks) - unassigned_count}")
|
| 151 |
+
print(f" β’ Unassigned tasks: {unassigned_count}")
|
| 152 |
+
print(f" β’ People with tasks: {len(task_counts)}")
|
| 153 |
+
print(f"\n Task counts by person:")
|
| 154 |
+
for assignee_id, assignee_tasks in sorted_assignees:
|
| 155 |
+
print(f" - {assignee_id[:8]}...: {len(assignee_tasks)} tasks")
|
| 156 |
+
|
| 157 |
+
# Step 7: Final verification
|
| 158 |
+
print("\nπ Verification Results:")
|
| 159 |
+
|
| 160 |
+
# Check that the swap has created a significant difference
|
| 161 |
+
if len(most_tasks) - len(least_tasks) < 5:
|
| 162 |
+
print(f"Warning: Difference between most and least is only {len(most_tasks) - len(least_tasks)} tasks", file=sys.stderr)
|
| 163 |
+
|
| 164 |
+
# Verify specific expected outcomes
|
| 165 |
+
verification_passed = True
|
| 166 |
+
|
| 167 |
+
# Check 1: Specific tasks have been swapped
|
| 168 |
+
specific_tasks_swapped = develop_plan_assignee_id != evaluate_services_assignee_id
|
| 169 |
+
if specific_tasks_swapped:
|
| 170 |
+
print(" β Specific tasks have been swapped")
|
| 171 |
+
else:
|
| 172 |
+
print(" β Specific tasks were not swapped", file=sys.stderr)
|
| 173 |
+
verification_passed = False
|
| 174 |
+
|
| 175 |
+
# Check 2: Task distribution shows a complete swap
|
| 176 |
+
if swap_completed:
|
| 177 |
+
print(" β Complete task swap verified (3β10 tasks)")
|
| 178 |
+
else:
|
| 179 |
+
# Show actual distribution for debugging
|
| 180 |
+
person1_tasks = len(task_counts.get(original_least_id, []))
|
| 181 |
+
person2_tasks = len(task_counts.get(original_most_id, []))
|
| 182 |
+
print(f" β Swap incomplete! Expected 5ac96c02β10 tasks, ac7a3bd0β3 tasks", file=sys.stderr)
|
| 183 |
+
print(f" Actual: 5ac96c02β{person1_tasks} tasks, ac7a3bd0β{person2_tasks} tasks", file=sys.stderr)
|
| 184 |
+
verification_passed = False
|
| 185 |
+
|
| 186 |
+
# Check 3: Total task count is preserved
|
| 187 |
+
total_assigned_tasks = sum(len(tasks) for _, tasks in task_counts.items())
|
| 188 |
+
expected_total = len(tasks) - unassigned_count
|
| 189 |
+
|
| 190 |
+
if total_assigned_tasks == expected_total:
|
| 191 |
+
print(f" β Total task count preserved ({total_assigned_tasks} assigned)")
|
| 192 |
+
else:
|
| 193 |
+
print(f" β Task count mismatch: {total_assigned_tasks} vs {expected_total} expected", file=sys.stderr)
|
| 194 |
+
verification_passed = False
|
| 195 |
+
|
| 196 |
+
if verification_passed:
|
| 197 |
+
print("\nβ
All verification checks passed!")
|
| 198 |
+
return True
|
| 199 |
+
else:
|
| 200 |
+
print("\nβ Verification failed", file=sys.stderr)
|
| 201 |
+
return False
|
| 202 |
+
|
| 203 |
+
def main():
|
| 204 |
+
"""
|
| 205 |
+
Executes the verification process and exits with a status code.
|
| 206 |
+
"""
|
| 207 |
+
notion = notion_utils.get_notion_client()
|
| 208 |
+
main_id = sys.argv[1] if len(sys.argv) > 1 else None
|
| 209 |
+
if verify(notion, main_id):
|
| 210 |
+
sys.exit(0)
|
| 211 |
+
else:
|
| 212 |
+
sys.exit(1)
|
| 213 |
+
|
| 214 |
+
if __name__ == "__main__":
|
| 215 |
+
main()
|
tasks/notion/easy/toronto_guide/simple__change_color/description.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Open the **Toronto Guide** page and refresh the colors of the tags in the **Food** database.
|
| 2 |
+
|
| 3 |
+
## Requirements
|
| 4 |
+
1. Find and open the Toronto Guide page in Notion.
|
| 5 |
+
2. Locate the *Food* database on that page.
|
| 6 |
+
3. Update every tag in the Food database that is currently pink so that it uses a different color of your choice (any non-pink color is fine).
|
| 7 |
+
4. Do not modify callouts or tags in the other databases.
|
tasks/notion/easy/toronto_guide/simple__change_color/meta.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"task_id": "simple__change_color",
|
| 3 |
+
"task_name": "Simple Change Color",
|
| 4 |
+
"category_id": "toronto_guide",
|
| 5 |
+
"category_name": "Toronto Guide",
|
| 6 |
+
"description": "Navigate to the Toronto Guide page and change all pink-colored elements to different colors.",
|
| 7 |
+
"author": "Xiangyan Liu",
|
| 8 |
+
"created_at": "2025-11-15",
|
| 9 |
+
"difficulty": "L1",
|
| 10 |
+
"tags": [
|
| 11 |
+
"visual formatting",
|
| 12 |
+
"conditional filtering"
|
| 13 |
+
],
|
| 14 |
+
"mcp": [
|
| 15 |
+
"notion"
|
| 16 |
+
],
|
| 17 |
+
"meta_data": {
|
| 18 |
+
"stateType": "url",
|
| 19 |
+
"stateContent": null,
|
| 20 |
+
"stateUrl": "https://painted-tennis-ebc.notion.site/Toronto-Guide-25281626b6d7802caa7cc394647e901c",
|
| 21 |
+
"stateOriginalUrl": "https://www.notion.so/marketplace/templates/conquering-toronto-a-destination-guide"
|
| 22 |
+
}
|
| 23 |
+
}
|
tasks/notion/easy/toronto_guide/simple__change_color/verify.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
from notion_client import Client
|
| 3 |
+
from tasks.utils import notion_utils
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
TARGET_PAGE_TITLE = "Toronto Guide"
|
| 7 |
+
FOOD_DATABASE_KEYWORD = "Food"
|
| 8 |
+
TARGET_TAG_NAMES = [
|
| 9 |
+
"Middle Eastern",
|
| 10 |
+
"Jamaican",
|
| 11 |
+
"Indian",
|
| 12 |
+
]
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _get_food_database_id(notion: Client, page_id: str) -> str | None:
|
| 16 |
+
"""Return the block ID of the Food database shown on the target page."""
|
| 17 |
+
all_blocks = notion_utils.get_all_blocks_recursively(notion, page_id)
|
| 18 |
+
for block in all_blocks:
|
| 19 |
+
if not block or block.get("type") != "child_database":
|
| 20 |
+
continue
|
| 21 |
+
title = block.get("child_database", {}).get("title", "")
|
| 22 |
+
if FOOD_DATABASE_KEYWORD.lower() in title.lower():
|
| 23 |
+
return block.get("id")
|
| 24 |
+
return None
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def verify(notion: Client, main_id: str | None = None) -> bool:
|
| 28 |
+
"""Check that all target tags in the Food database are no longer pink."""
|
| 29 |
+
# Resolve the Toronto Guide page ID.
|
| 30 |
+
if main_id:
|
| 31 |
+
found_id, object_type = notion_utils.find_page_or_database_by_id(notion, main_id)
|
| 32 |
+
if not found_id or object_type != "page":
|
| 33 |
+
print("Error: Toronto Guide page not found.", file=sys.stderr)
|
| 34 |
+
return False
|
| 35 |
+
page_id = found_id
|
| 36 |
+
else:
|
| 37 |
+
page_id = notion_utils.find_page(notion, TARGET_PAGE_TITLE)
|
| 38 |
+
if not page_id:
|
| 39 |
+
print("Error: Toronto Guide page not found.", file=sys.stderr)
|
| 40 |
+
return False
|
| 41 |
+
|
| 42 |
+
# Locate the Food database block.
|
| 43 |
+
food_db_id = _get_food_database_id(notion, page_id)
|
| 44 |
+
if not food_db_id:
|
| 45 |
+
print("Error: Food database not found on the Toronto Guide page.", file=sys.stderr)
|
| 46 |
+
return False
|
| 47 |
+
|
| 48 |
+
# Fetch database definition and inspect tag options.
|
| 49 |
+
try:
|
| 50 |
+
db_info = notion.databases.retrieve(database_id=food_db_id)
|
| 51 |
+
except Exception as exc:
|
| 52 |
+
print(f"Error: Unable to retrieve Food database ({exc}).", file=sys.stderr)
|
| 53 |
+
return False
|
| 54 |
+
|
| 55 |
+
tags_property = db_info.get("properties", {}).get("Tags", {})
|
| 56 |
+
if tags_property.get("type") != "multi_select":
|
| 57 |
+
print("Error: Food database does not have a multi-select Tags property.", file=sys.stderr)
|
| 58 |
+
return False
|
| 59 |
+
|
| 60 |
+
options = tags_property.get("multi_select", {}).get("options", [])
|
| 61 |
+
remaining_targets = set(TARGET_TAG_NAMES)
|
| 62 |
+
failures = False
|
| 63 |
+
|
| 64 |
+
for option in options:
|
| 65 |
+
tag_name = option.get("name", "").strip()
|
| 66 |
+
if tag_name not in remaining_targets:
|
| 67 |
+
continue
|
| 68 |
+
|
| 69 |
+
remaining_targets.discard(tag_name)
|
| 70 |
+
color = option.get("color")
|
| 71 |
+
if color == "pink":
|
| 72 |
+
print(f"Error: Tag '{tag_name}' in Food database is still pink.", file=sys.stderr)
|
| 73 |
+
failures = True
|
| 74 |
+
else:
|
| 75 |
+
print(f"β Tag '{tag_name}' color updated to '{color}'.")
|
| 76 |
+
|
| 77 |
+
if remaining_targets:
|
| 78 |
+
print(
|
| 79 |
+
f"Error: Food tags not found (expected to exist): {sorted(remaining_targets)}.",
|
| 80 |
+
file=sys.stderr,
|
| 81 |
+
)
|
| 82 |
+
return False
|
| 83 |
+
|
| 84 |
+
if failures:
|
| 85 |
+
return False
|
| 86 |
+
|
| 87 |
+
print("Success: All Food database tags are now non-pink.")
|
| 88 |
+
return True
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def main() -> None:
|
| 92 |
+
notion = notion_utils.get_notion_client()
|
| 93 |
+
main_id = sys.argv[1] if len(sys.argv) > 1 else None
|
| 94 |
+
if verify(notion, main_id):
|
| 95 |
+
sys.exit(0)
|
| 96 |
+
sys.exit(1)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
if __name__ == "__main__":
|
| 100 |
+
main()
|
tasks/notion/standard/company_in_a_box/employee_onboarding/description.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Build an integrated **Employee Onboarding** system for the existing **Company In A Box** page.
|
| 2 |
+
|
| 3 |
+
**Task Requirements:**
|
| 4 |
+
1. Create a new **database** titled **Employee Onboarding Checklist** with the following properties *exactly*:
|
| 5 |
+
β’ **Employee Name** β title
|
| 6 |
+
β’ **Start Date** β date
|
| 7 |
+
β’ **Department** β select (options: Product, Marketing, Sales, HR, Engineering)
|
| 8 |
+
|
| 9 |
+
Populate this database with **3** sample new-hire pages covering three different departments. Every property in each entry must be filled.
|
| 10 |
+
|
| 11 |
+
2. Under the top-level page **Company In A Box**, create a new child page titled **Onboarding Hub** containing, in order:
|
| 12 |
+
1) The **Employee Onboarding Checklist** database embedded at the top.
|
| 13 |
+
2) A section headed **Benefits Overview** that includes linked mentions (@-mentions or link-to-page blocks) to **β₯ 3** distinct benefit-policy pages from the **Company Wiki** (for example *Benefits policy*, *Vacation Policy*, *Corporate travel*).
|
| 14 |
+
3) A section headed **30-Day Timeline** that presents a numbered list with **7** steps covering the first 30 days. **Each step must reference (via @-mention) an existing page or database**.
|
| 15 |
+
4) A section headed **Feedback Form** that provides **β₯ 3** to-do items for new hires to check off.
|
tasks/notion/standard/company_in_a_box/employee_onboarding/meta.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"task_id": "employee_onboarding",
|
| 3 |
+
"task_name": "Employee Onboarding",
|
| 4 |
+
"category_id": "company_in_a_box",
|
| 5 |
+
"category_name": "Company In A Box",
|
| 6 |
+
"description": "Build an integrated Employee Onboarding system for the existing Company In A Box page with a checklist database, onboarding hub, and feedback form.",
|
| 7 |
+
"author": "Zijian Wu",
|
| 8 |
+
"created_at": "2025-07-27",
|
| 9 |
+
"difficulty": "L3",
|
| 10 |
+
"tags": [
|
| 11 |
+
"database manipulation",
|
| 12 |
+
"template population",
|
| 13 |
+
"cross-reference linking",
|
| 14 |
+
"status tracking"
|
| 15 |
+
],
|
| 16 |
+
"mcp": [
|
| 17 |
+
"notion"
|
| 18 |
+
],
|
| 19 |
+
"meta_data": {
|
| 20 |
+
"stateType": "url",
|
| 21 |
+
"stateContent": null,
|
| 22 |
+
"stateUrl": "https://painted-tennis-ebc.notion.site/Company-In-A-Box-23d81626b6d7800098f3d0e64a706cd8",
|
| 23 |
+
"stateOriginalUrl": "https://www.notion.so/marketplace/templates/company-in-a-box"
|
| 24 |
+
}
|
| 25 |
+
}
|
tasks/notion/standard/company_in_a_box/employee_onboarding/verify.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
from typing import Dict, Set
|
| 3 |
+
from notion_client import Client
|
| 4 |
+
from tasks.utils import notion_utils
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def _check_db_schema(db_props: Dict[str, Dict], required: Dict[str, str]) -> bool:
|
| 8 |
+
"""Return True if every required property exists with the correct type."""
|
| 9 |
+
for prop_name, expected_type in required.items():
|
| 10 |
+
if prop_name not in db_props:
|
| 11 |
+
print(
|
| 12 |
+
f"Error: Property '{prop_name}' missing from database.", file=sys.stderr
|
| 13 |
+
)
|
| 14 |
+
return False
|
| 15 |
+
actual_type = db_props[prop_name]["type"]
|
| 16 |
+
if actual_type != expected_type:
|
| 17 |
+
print(
|
| 18 |
+
f"Error: Property '{prop_name}' has type '{actual_type}', expected '{expected_type}'.",
|
| 19 |
+
file=sys.stderr,
|
| 20 |
+
)
|
| 21 |
+
return False
|
| 22 |
+
return True
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def verify(notion: Client, main_id: str | None = None) -> bool: # noqa: C901
|
| 26 |
+
"""Programmatically verify the onboarding system described in description.md."""
|
| 27 |
+
|
| 28 |
+
DB_TITLE = "Employee Onboarding Checklist"
|
| 29 |
+
HUB_PAGE_TITLE = "Onboarding Hub"
|
| 30 |
+
DEPARTMENT_OPTIONS: Set[str] = {
|
| 31 |
+
"Product",
|
| 32 |
+
"Marketing",
|
| 33 |
+
"Sales",
|
| 34 |
+
"HR",
|
| 35 |
+
"Engineering",
|
| 36 |
+
}
|
| 37 |
+
REQUIRED_DB_PROPERTIES = {
|
| 38 |
+
"Employee Name": "title",
|
| 39 |
+
"Start Date": "date",
|
| 40 |
+
"Department": "select",
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
# 1. Locate onboarding database
|
| 44 |
+
db_id = notion_utils.find_database(notion, DB_TITLE)
|
| 45 |
+
if not db_id:
|
| 46 |
+
print(f"Error: Database '{DB_TITLE}' not found.", file=sys.stderr)
|
| 47 |
+
return False
|
| 48 |
+
|
| 49 |
+
try:
|
| 50 |
+
db_obj = notion.databases.retrieve(database_id=db_id)
|
| 51 |
+
except Exception as exc:
|
| 52 |
+
print(f"Error retrieving database: {exc}", file=sys.stderr)
|
| 53 |
+
return False
|
| 54 |
+
|
| 55 |
+
db_props = db_obj.get("properties", {})
|
| 56 |
+
if not _check_db_schema(db_props, REQUIRED_DB_PROPERTIES):
|
| 57 |
+
return False
|
| 58 |
+
|
| 59 |
+
# Extra: validate select options
|
| 60 |
+
dept_options = {opt["name"] for opt in db_props["Department"]["select"]["options"]}
|
| 61 |
+
if not DEPARTMENT_OPTIONS.issubset(dept_options):
|
| 62 |
+
print(
|
| 63 |
+
f"Error: Department select options must include {sorted(DEPARTMENT_OPTIONS)}. Current: {sorted(dept_options)}",
|
| 64 |
+
file=sys.stderr,
|
| 65 |
+
)
|
| 66 |
+
return False
|
| 67 |
+
|
| 68 |
+
# Check there are at least 3 entries in the database
|
| 69 |
+
try:
|
| 70 |
+
db_pages = notion.databases.query(database_id=db_id).get("results", [])
|
| 71 |
+
except Exception as exc:
|
| 72 |
+
print(f"Error querying database: {exc}", file=sys.stderr)
|
| 73 |
+
return False
|
| 74 |
+
if len(db_pages) < 3:
|
| 75 |
+
print(
|
| 76 |
+
"Error: Less than 3 onboarding entries found in the database.",
|
| 77 |
+
file=sys.stderr,
|
| 78 |
+
)
|
| 79 |
+
return False
|
| 80 |
+
|
| 81 |
+
# 2. Locate Onboarding Hub page
|
| 82 |
+
hub_page_id = notion_utils.find_page(notion, HUB_PAGE_TITLE)
|
| 83 |
+
if not hub_page_id:
|
| 84 |
+
print(f"Error: Page '{HUB_PAGE_TITLE}' not found.", file=sys.stderr)
|
| 85 |
+
return False
|
| 86 |
+
|
| 87 |
+
# 3. Ensure the onboarding database is embedded in the hub page
|
| 88 |
+
embedded_db_id = notion_utils.find_database_in_block(notion, hub_page_id, DB_TITLE)
|
| 89 |
+
if embedded_db_id != db_id:
|
| 90 |
+
print(
|
| 91 |
+
"Error: The Employee Onboarding Checklist database is not embedded in the Onboarding Hub page.",
|
| 92 |
+
file=sys.stderr,
|
| 93 |
+
)
|
| 94 |
+
return False
|
| 95 |
+
|
| 96 |
+
# 4. Analyse blocks within the hub page for linked mentions, timeline, and feedback form
|
| 97 |
+
all_blocks = notion_utils.get_all_blocks_recursively(notion, hub_page_id)
|
| 98 |
+
|
| 99 |
+
seen_link_targets: Set[str] = set()
|
| 100 |
+
numbered_list_count = 0
|
| 101 |
+
todo_count = 0
|
| 102 |
+
|
| 103 |
+
for blk in all_blocks:
|
| 104 |
+
blk_type = blk.get("type")
|
| 105 |
+
|
| 106 |
+
# Direct link-to-page blocks
|
| 107 |
+
if blk_type == "link_to_page":
|
| 108 |
+
info = blk.get("link_to_page", {})
|
| 109 |
+
target_id = info.get("page_id") or info.get("database_id")
|
| 110 |
+
if target_id:
|
| 111 |
+
seen_link_targets.add(target_id)
|
| 112 |
+
continue
|
| 113 |
+
|
| 114 |
+
# Rich-text mentions inside content blocks
|
| 115 |
+
if blk_type in {
|
| 116 |
+
"paragraph",
|
| 117 |
+
"numbered_list_item",
|
| 118 |
+
"bulleted_list_item",
|
| 119 |
+
"to_do",
|
| 120 |
+
}:
|
| 121 |
+
content = blk.get(blk_type, {})
|
| 122 |
+
for rt in content.get("rich_text", []):
|
| 123 |
+
if rt.get("type") == "mention":
|
| 124 |
+
mention = rt.get("mention", {})
|
| 125 |
+
if mention.get("type") in {"page", "database"}:
|
| 126 |
+
target_id = mention.get("page", {}).get("id") or mention.get(
|
| 127 |
+
"database", {}
|
| 128 |
+
).get("id")
|
| 129 |
+
if target_id:
|
| 130 |
+
seen_link_targets.add(target_id)
|
| 131 |
+
|
| 132 |
+
# Count numbered list items
|
| 133 |
+
if blk_type == "numbered_list_item":
|
| 134 |
+
numbered_list_count += 1
|
| 135 |
+
|
| 136 |
+
# Count to-do items in Feedback Form
|
| 137 |
+
if blk_type == "to_do":
|
| 138 |
+
todo_count += 1
|
| 139 |
+
|
| 140 |
+
if len(seen_link_targets) < 3:
|
| 141 |
+
print(
|
| 142 |
+
"Error: Fewer than 3 linked mentions to benefit policy pages found in the Benefits Overview section.",
|
| 143 |
+
file=sys.stderr,
|
| 144 |
+
)
|
| 145 |
+
return False
|
| 146 |
+
|
| 147 |
+
if numbered_list_count < 7:
|
| 148 |
+
print(
|
| 149 |
+
"Error: Numbered list contains fewer than 7 steps in the 30-Day Timeline section.",
|
| 150 |
+
file=sys.stderr,
|
| 151 |
+
)
|
| 152 |
+
return False
|
| 153 |
+
|
| 154 |
+
if todo_count < 3:
|
| 155 |
+
print(
|
| 156 |
+
"Error: Feedback Form section contains fewer than 3 to-do items.",
|
| 157 |
+
file=sys.stderr,
|
| 158 |
+
)
|
| 159 |
+
return False
|
| 160 |
+
|
| 161 |
+
print(
|
| 162 |
+
"Success: Verified Employee Onboarding Checklist database, Onboarding Hub page, and all required sections."
|
| 163 |
+
)
|
| 164 |
+
return True
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def main():
|
| 168 |
+
notion = notion_utils.get_notion_client()
|
| 169 |
+
main_id = sys.argv[1] if len(sys.argv) > 1 else None
|
| 170 |
+
if verify(notion, main_id):
|
| 171 |
+
sys.exit(0)
|
| 172 |
+
else:
|
| 173 |
+
sys.exit(1)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
if __name__ == "__main__":
|
| 177 |
+
main()
|
tasks/notion/standard/company_in_a_box/goals_restructure/description.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Please restructure the **Current Goals** section on my **Company In A Box** page as follows:
|
| 2 |
+
|
| 3 |
+
1. **Add a new goal heading** β create a new `heading_3` block titled:
|
| 4 |
+
|
| 5 |
+
`π Digital Transformation Initiative`
|
| 6 |
+
|
| 7 |
+
2. **Convert all four goal headings to toggles** β the three existing goals
|
| 8 |
+
* βοΈ Expand Operations to LATAM
|
| 9 |
+
* π οΈ Push for Enterprise
|
| 10 |
+
* π©Ά Boost Employee Engagement
|
| 11 |
+
* π Digital Transformation Initiative
|
| 12 |
+
|
| 13 |
+
3. **Move descriptions inside the toggles** β every paragraph or list that originally sat directly under a goal heading should become a **child block** of that heading after it is made toggleable.
|
| 14 |
+
|
| 15 |
+
4. **Preserve content & order** β apart from the changes above, do **not** modify the text, formatting, or order of existing goal descriptions.
|
| 16 |
+
|
| 17 |
+
The end result should be a clean **Current Goals** section containing four toggleable goal headings, each with its corresponding details tucked inside.
|
tasks/notion/standard/company_in_a_box/goals_restructure/meta.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"task_id": "goals_restructure",
|
| 3 |
+
"task_name": "Goals Restructure",
|
| 4 |
+
"category_id": "company_in_a_box",
|
| 5 |
+
"category_name": "Company In A Box",
|
| 6 |
+
"description": "Restructure the Current Goals section on the Company In A Box page by adding a new goal heading and converting all goal headings to toggles with content inside.",
|
| 7 |
+
"author": "Zijian Wu",
|
| 8 |
+
"created_at": "2025-07-27",
|
| 9 |
+
"difficulty": "L3",
|
| 10 |
+
"tags": [
|
| 11 |
+
"content organization",
|
| 12 |
+
"visual formatting"
|
| 13 |
+
],
|
| 14 |
+
"mcp": [
|
| 15 |
+
"notion"
|
| 16 |
+
],
|
| 17 |
+
"meta_data": {
|
| 18 |
+
"stateType": "url",
|
| 19 |
+
"stateContent": null,
|
| 20 |
+
"stateUrl": "https://painted-tennis-ebc.notion.site/Company-In-A-Box-23d81626b6d7800098f3d0e64a706cd8",
|
| 21 |
+
"stateOriginalUrl": "https://www.notion.so/marketplace/templates/company-in-a-box"
|
| 22 |
+
}
|
| 23 |
+
}
|
tasks/notion/standard/company_in_a_box/goals_restructure/verify.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
from typing import List
|
| 3 |
+
from notion_client import Client
|
| 4 |
+
from tasks.utils import notion_utils
|
| 5 |
+
|
| 6 |
+
# Expected new goal heading text (including emoji)
|
| 7 |
+
NEW_GOAL_HEADING = "π Digital Transformation Initiative"
|
| 8 |
+
|
| 9 |
+
# Section title to look for
|
| 10 |
+
GOALS_SECTION_TITLE = "Current Goals"
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _plain(block) -> str:
|
| 14 |
+
"""Return concatenated plain text of a block."""
|
| 15 |
+
return notion_utils.get_block_plain_text(block)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
# Some Notion rich-text strings may include non-breaking spaces (\xa0) after emoji.
|
| 19 |
+
# Normalize them to plain spaces so text matching is robust.
|
| 20 |
+
def _normalize_string(s: str) -> str:
|
| 21 |
+
return s.replace("\xa0", " ")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _is_heading(block) -> bool:
|
| 25 |
+
return block.get("type") in ["heading_1", "heading_2", "heading_3"]
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _is_toggle(block) -> bool:
|
| 29 |
+
"""Determine whether a block is a toggle (standard toggle block or toggle-able heading)."""
|
| 30 |
+
btype = block.get("type")
|
| 31 |
+
# In our scenario, goal blocks are headings (usually heading_3) marked as toggleable.
|
| 32 |
+
if btype in ["heading_1", "heading_2", "heading_3"]:
|
| 33 |
+
heading_data = block.get(btype, {})
|
| 34 |
+
return heading_data.get("is_toggleable", False)
|
| 35 |
+
# Some Notion pages may contain classic toggle blocks (type == "toggle"). They are
|
| 36 |
+
# not expected in this task, but keeping this check allows broader compatibility.
|
| 37 |
+
return btype == "toggle"
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _get_children(notion: Client, block_id: str) -> List[dict]:
|
| 41 |
+
"""Retrieve **direct** children of a block (no pagination handling needed for small test pages)."""
|
| 42 |
+
try:
|
| 43 |
+
return notion.blocks.children.list(block_id=block_id).get("results", [])
|
| 44 |
+
except Exception:
|
| 45 |
+
return []
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def verify(notion: Client, main_id: str = None) -> bool:
|
| 49 |
+
"""Verifies that the Company in a Box page has been updated per the task requirements."""
|
| 50 |
+
# 1. Locate the main page
|
| 51 |
+
page_id = None
|
| 52 |
+
if main_id:
|
| 53 |
+
found_id, obj_type = notion_utils.find_page_or_database_by_id(notion, main_id)
|
| 54 |
+
if found_id and obj_type == "page":
|
| 55 |
+
page_id = found_id
|
| 56 |
+
|
| 57 |
+
if not page_id:
|
| 58 |
+
# Try a few case variations just in case
|
| 59 |
+
for title in [
|
| 60 |
+
"Company In A Box",
|
| 61 |
+
]:
|
| 62 |
+
page_id = notion_utils.find_page(notion, title)
|
| 63 |
+
if page_id:
|
| 64 |
+
break
|
| 65 |
+
|
| 66 |
+
if not page_id:
|
| 67 |
+
print("Error: Could not find the 'Company in a Box' page.", file=sys.stderr)
|
| 68 |
+
return False
|
| 69 |
+
|
| 70 |
+
# 2. Recursively locate the "Current Goals" heading and collect its sibling blocks that
|
| 71 |
+
# constitute the section.
|
| 72 |
+
|
| 73 |
+
def _fetch_children(bid: str) -> List[dict]:
|
| 74 |
+
try:
|
| 75 |
+
return notion.blocks.children.list(block_id=bid).get("results", [])
|
| 76 |
+
except Exception:
|
| 77 |
+
return []
|
| 78 |
+
|
| 79 |
+
goals_section_blocks: List[dict] = []
|
| 80 |
+
|
| 81 |
+
# Breadth-first traversal to find the heading
|
| 82 |
+
queue = [page_id]
|
| 83 |
+
found_parent = None
|
| 84 |
+
found_index = None
|
| 85 |
+
|
| 86 |
+
while queue and found_parent is None:
|
| 87 |
+
parent_id = queue.pop(0)
|
| 88 |
+
children = _fetch_children(parent_id)
|
| 89 |
+
for idx, child in enumerate(children):
|
| 90 |
+
if (
|
| 91 |
+
_is_heading(child)
|
| 92 |
+
and GOALS_SECTION_TITLE.lower()
|
| 93 |
+
in _normalize_string(_plain(child)).lower()
|
| 94 |
+
):
|
| 95 |
+
found_parent = parent_id
|
| 96 |
+
found_index = idx
|
| 97 |
+
break
|
| 98 |
+
# enqueue grandchildren for further search
|
| 99 |
+
for ch in children:
|
| 100 |
+
if ch.get("has_children"):
|
| 101 |
+
queue.append(ch["id"])
|
| 102 |
+
|
| 103 |
+
if found_parent is None:
|
| 104 |
+
print(
|
| 105 |
+
"Error: Could not find the 'Current Goals' heading anywhere in the page.",
|
| 106 |
+
file=sys.stderr,
|
| 107 |
+
)
|
| 108 |
+
return False
|
| 109 |
+
|
| 110 |
+
# Retrieve siblings once more to get the final list and slice after heading.
|
| 111 |
+
siblings = _fetch_children(found_parent)
|
| 112 |
+
if found_index is None or found_index >= len(siblings):
|
| 113 |
+
print(
|
| 114 |
+
"Error: Internal logic issue when locating Current Goals section.",
|
| 115 |
+
file=sys.stderr,
|
| 116 |
+
)
|
| 117 |
+
return False
|
| 118 |
+
|
| 119 |
+
goals_section_blocks = siblings[found_index + 1 :]
|
| 120 |
+
|
| 121 |
+
if not goals_section_blocks:
|
| 122 |
+
print("Error: 'Current Goals' section appears to be empty.", file=sys.stderr)
|
| 123 |
+
return False
|
| 124 |
+
|
| 125 |
+
# 3. Identify toggle blocks that represent goals
|
| 126 |
+
toggle_blocks = [b for b in goals_section_blocks if _is_toggle(b)]
|
| 127 |
+
|
| 128 |
+
if len(toggle_blocks) != 4:
|
| 129 |
+
print(
|
| 130 |
+
f"Error: Expected 4 toggle blocks for goals, found {len(toggle_blocks)}.",
|
| 131 |
+
file=sys.stderr,
|
| 132 |
+
)
|
| 133 |
+
return False
|
| 134 |
+
|
| 135 |
+
# 4. Ensure the new goal heading exists among the toggles
|
| 136 |
+
found_new_goal = False
|
| 137 |
+
for tb in toggle_blocks:
|
| 138 |
+
if (
|
| 139 |
+
_normalize_string(NEW_GOAL_HEADING).lower()
|
| 140 |
+
in _normalize_string(_plain(tb)).lower()
|
| 141 |
+
):
|
| 142 |
+
found_new_goal = True
|
| 143 |
+
break
|
| 144 |
+
if not found_new_goal:
|
| 145 |
+
print(
|
| 146 |
+
f"Error: Did not find a toggle block with heading '{NEW_GOAL_HEADING}'.",
|
| 147 |
+
file=sys.stderr,
|
| 148 |
+
)
|
| 149 |
+
return False
|
| 150 |
+
|
| 151 |
+
# 5. Validate that each toggle has at least one child paragraph/description
|
| 152 |
+
for tb in toggle_blocks:
|
| 153 |
+
if (
|
| 154 |
+
_normalize_string(NEW_GOAL_HEADING).lower()
|
| 155 |
+
in _normalize_string(_plain(tb)).lower()
|
| 156 |
+
):
|
| 157 |
+
# Skip checking the new goal itself, as it does not have a description yet.
|
| 158 |
+
continue
|
| 159 |
+
if not tb.get("has_children", False):
|
| 160 |
+
print(
|
| 161 |
+
f"Error: Toggle '{_normalize_string(_plain(tb))}' has no child blocks (description not moved).",
|
| 162 |
+
file=sys.stderr,
|
| 163 |
+
)
|
| 164 |
+
return False
|
| 165 |
+
children = _get_children(notion, tb["id"])
|
| 166 |
+
# Ensure there is at least one content child (paragraph, list item, etc.)
|
| 167 |
+
content_types = {
|
| 168 |
+
"paragraph",
|
| 169 |
+
"bulleted_list_item",
|
| 170 |
+
"numbered_list_item",
|
| 171 |
+
"to_do",
|
| 172 |
+
"callout",
|
| 173 |
+
"quote",
|
| 174 |
+
}
|
| 175 |
+
if not any(c.get("type") in content_types for c in children):
|
| 176 |
+
print(
|
| 177 |
+
f"Error: Toggle '{_normalize_string(_plain(tb))}' seems to lack any description/content inside it.",
|
| 178 |
+
file=sys.stderr,
|
| 179 |
+
)
|
| 180 |
+
return False
|
| 181 |
+
|
| 182 |
+
# 6. Confirm that there are **no** residual heading_3 blocks (non-toggle) for the goals
|
| 183 |
+
non_toggle_headings = [
|
| 184 |
+
b
|
| 185 |
+
for b in goals_section_blocks
|
| 186 |
+
if b.get("type") == "heading_3" and not _is_toggle(b)
|
| 187 |
+
]
|
| 188 |
+
if non_toggle_headings:
|
| 189 |
+
titles = [_normalize_string(_plain(b)) for b in non_toggle_headings]
|
| 190 |
+
print(
|
| 191 |
+
f"Error: Found heading_3 blocks that were not converted to toggles: {titles}.",
|
| 192 |
+
file=sys.stderr,
|
| 193 |
+
)
|
| 194 |
+
return False
|
| 195 |
+
|
| 196 |
+
print(
|
| 197 |
+
"Success: Verified goal restructuring with new toggle blocks and descriptions."
|
| 198 |
+
)
|
| 199 |
+
return True
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def main():
|
| 203 |
+
notion = notion_utils.get_notion_client()
|
| 204 |
+
main_id = sys.argv[1] if len(sys.argv) > 1 else None
|
| 205 |
+
if verify(notion, main_id):
|
| 206 |
+
sys.exit(0)
|
| 207 |
+
else:
|
| 208 |
+
sys.exit(1)
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
if __name__ == "__main__":
|
| 212 |
+
main()
|
tasks/notion/standard/company_in_a_box/quarterly_review_dashboard/description.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Create a quarterly business review dashboard in Notion based on the existing **Company In A Box** workspace.
|
| 2 |
+
|
| 3 |
+
**Task Requirements:**
|
| 4 |
+
1. Inside the **Company Wiki** page you will find a sub-page named **Company Goals**. Extract every departmental objective listed under the four departments β **Product**, **Marketing**, **Sales**, and **HR**.
|
| 5 |
+
2. Under the top-level page **Company In A Box**, create a new child page titled **Q4 2024 Business Review Dashboard**.
|
| 6 |
+
3. Inside that new page build the following structure (all parts must exist):
|
| 7 |
+
1. A single **callout** block near the top that summarises progress toward the three *Current Goals* shown on the main page:
|
| 8 |
+
β’ *LATAM expansion* β’ *Enterprise push* β’ *Employee engagement*
|
| 9 |
+
(All three phrases must appear in the callout text.)
|
| 10 |
+
2. Four separate **section headings** (any heading level) β one for each department (**Product**, **Marketing**, **Sales**, **Human Resources**) β placed below the callout. Under each heading list that departmentβs objectives in a progress-tracking format (e.g. to-dos, check-box list). Each objective from the **Company Goals** page must appear at least once.
|
| 11 |
+
3. Add a **database** named **Action Items** with the following properties *exactly*:
|
| 12 |
+
β’ **Task Name** β title
|
| 13 |
+
β’ **Department** β select (options: Product, Marketing, Sales, HR)
|
| 14 |
+
β’ **Priority** β select (options: High, Medium, Low)
|
| 15 |
+
β’ **Status** β status
|
| 16 |
+
Populate this database with **β₯ 5** action-item pages derived from the departmental objectives, making sure every field in each entry is filled:
|
| 17 |
+
β’ **Task Name** & **Department** must correctly correspond to the underlying objective/department.
|
| 18 |
+
β’ **Priority** and **Status** can be any allowed value, but they must **not** be left empty.
|
| 19 |
+
4. Keep the overall visual style consistent with the existing wiki (use headings, dividers, etc.).
|
tasks/notion/standard/company_in_a_box/quarterly_review_dashboard/meta.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"task_id": "quarterly_review_dashboard",
|
| 3 |
+
"task_name": "Quarterly Review Dashboard",
|
| 4 |
+
"category_id": "company_in_a_box",
|
| 5 |
+
"category_name": "Company In A Box",
|
| 6 |
+
"description": "Create a quarterly business review dashboard in Notion based on the existing Company In A Box workspace with department objectives and action items database.",
|
| 7 |
+
"author": "Zijian Wu",
|
| 8 |
+
"created_at": "2025-07-27",
|
| 9 |
+
"difficulty": "L3",
|
| 10 |
+
"tags": [
|
| 11 |
+
"database manipulation",
|
| 12 |
+
"data aggregation",
|
| 13 |
+
"report generation",
|
| 14 |
+
"status tracking",
|
| 15 |
+
"template population"
|
| 16 |
+
],
|
| 17 |
+
"mcp": [
|
| 18 |
+
"notion"
|
| 19 |
+
],
|
| 20 |
+
"meta_data": {
|
| 21 |
+
"stateType": "url",
|
| 22 |
+
"stateContent": null,
|
| 23 |
+
"stateUrl": "https://painted-tennis-ebc.notion.site/Company-In-A-Box-23d81626b6d7800098f3d0e64a706cd8",
|
| 24 |
+
"stateOriginalUrl": "https://www.notion.so/marketplace/templates/company-in-a-box"
|
| 25 |
+
}
|
| 26 |
+
}
|
tasks/notion/standard/company_in_a_box/quarterly_review_dashboard/verify.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
from typing import List
|
| 3 |
+
from notion_client import Client
|
| 4 |
+
from tasks.utils import notion_utils
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def _contains_keywords(text: str, keywords: List[str]) -> bool:
|
| 8 |
+
lowered = text.lower()
|
| 9 |
+
return all(kw.lower() in lowered for kw in keywords)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def verify(notion: Client, main_id: str = None) -> bool:
|
| 13 |
+
"""Programmatically verify that the dashboard page and its contents meet the
|
| 14 |
+
requirements described in description.md.
|
| 15 |
+
"""
|
| 16 |
+
DASHBOARD_TITLE = "Q4 2024 Business Review Dashboard"
|
| 17 |
+
PARENT_PAGE_TITLE = "Company In A Box"
|
| 18 |
+
CALL_OUT_KEYWORDS = ["latam", "enterprise", "employee engagement"]
|
| 19 |
+
DEPARTMENTS = ["Product", "Marketing", "Sales", "Human Resources"]
|
| 20 |
+
REQUIRED_DB_PROPERTIES = {
|
| 21 |
+
"Task Name": "title",
|
| 22 |
+
"Department": "select",
|
| 23 |
+
"Priority": "select",
|
| 24 |
+
"Status": "status",
|
| 25 |
+
}
|
| 26 |
+
PRIORITY_OPTIONS = {"High", "Medium", "Low"}
|
| 27 |
+
|
| 28 |
+
# 1. Locate the dashboard page
|
| 29 |
+
page_id = None
|
| 30 |
+
if main_id:
|
| 31 |
+
found_id, obj_type = notion_utils.find_page_or_database_by_id(notion, main_id)
|
| 32 |
+
if found_id and obj_type == "page":
|
| 33 |
+
page_id = found_id
|
| 34 |
+
|
| 35 |
+
if not page_id:
|
| 36 |
+
page_id = notion_utils.find_page(notion, DASHBOARD_TITLE)
|
| 37 |
+
|
| 38 |
+
if not page_id:
|
| 39 |
+
print(f"Error: Page '{DASHBOARD_TITLE}' not found.", file=sys.stderr)
|
| 40 |
+
return False
|
| 41 |
+
|
| 42 |
+
# Optional: ensure it is a child of Company In A Box
|
| 43 |
+
try:
|
| 44 |
+
page_obj = notion.pages.retrieve(page_id=page_id)
|
| 45 |
+
parent_id = page_obj.get("parent", {}).get("page_id")
|
| 46 |
+
if parent_id:
|
| 47 |
+
parent_page = notion.pages.retrieve(page_id=parent_id)
|
| 48 |
+
parent_title_rt = (
|
| 49 |
+
parent_page.get("properties", {}).get("title", {}).get("title", [])
|
| 50 |
+
)
|
| 51 |
+
parent_title = (
|
| 52 |
+
parent_title_rt[0].get("plain_text") if parent_title_rt else None
|
| 53 |
+
)
|
| 54 |
+
if parent_title != PARENT_PAGE_TITLE:
|
| 55 |
+
print(
|
| 56 |
+
f"Error: Dashboard page is not a direct child of '{PARENT_PAGE_TITLE}'.",
|
| 57 |
+
file=sys.stderr,
|
| 58 |
+
)
|
| 59 |
+
return False
|
| 60 |
+
except Exception:
|
| 61 |
+
pass # parent check is best-effort only
|
| 62 |
+
|
| 63 |
+
# 2. Verify callout with keywords
|
| 64 |
+
all_blocks = notion_utils.get_all_blocks_recursively(notion, page_id)
|
| 65 |
+
callout_ok = False
|
| 66 |
+
for block in all_blocks:
|
| 67 |
+
if block.get("type") == "callout":
|
| 68 |
+
callout_text = notion_utils.get_block_plain_text(block)
|
| 69 |
+
if _contains_keywords(callout_text, CALL_OUT_KEYWORDS):
|
| 70 |
+
callout_ok = True
|
| 71 |
+
break
|
| 72 |
+
if not callout_ok:
|
| 73 |
+
print(
|
| 74 |
+
"Error: No callout found that includes all three Current Goal keywords (LATAM, Enterprise, Employee engagement).",
|
| 75 |
+
file=sys.stderr,
|
| 76 |
+
)
|
| 77 |
+
return False
|
| 78 |
+
|
| 79 |
+
# 3. Verify department section headings
|
| 80 |
+
found_depts = set()
|
| 81 |
+
for block in all_blocks:
|
| 82 |
+
if block.get("type") in {"heading_1", "heading_2", "heading_3"}:
|
| 83 |
+
heading_text = notion_utils.get_block_plain_text(block)
|
| 84 |
+
for dept in DEPARTMENTS:
|
| 85 |
+
if dept.lower() in heading_text.lower():
|
| 86 |
+
found_depts.add(dept)
|
| 87 |
+
if set(DEPARTMENTS) != found_depts:
|
| 88 |
+
missing = set(DEPARTMENTS) - found_depts
|
| 89 |
+
print(
|
| 90 |
+
f"Error: Missing department headings: {', '.join(missing)}.",
|
| 91 |
+
file=sys.stderr,
|
| 92 |
+
)
|
| 93 |
+
return False
|
| 94 |
+
|
| 95 |
+
# 4. Verify Action Items database exists and has correct schema
|
| 96 |
+
db_id = notion_utils.find_database_in_block(notion, page_id, "Action Items")
|
| 97 |
+
if not db_id:
|
| 98 |
+
print(
|
| 99 |
+
"Error: Database 'Action Items' not found on the dashboard.",
|
| 100 |
+
file=sys.stderr,
|
| 101 |
+
)
|
| 102 |
+
return False
|
| 103 |
+
|
| 104 |
+
try:
|
| 105 |
+
db = notion.databases.retrieve(database_id=db_id)
|
| 106 |
+
except Exception as exc:
|
| 107 |
+
print(f"Error: Unable to retrieve database: {exc}", file=sys.stderr)
|
| 108 |
+
return False
|
| 109 |
+
|
| 110 |
+
db_props = db.get("properties", {})
|
| 111 |
+
for prop_name, expected_type in REQUIRED_DB_PROPERTIES.items():
|
| 112 |
+
if prop_name not in db_props:
|
| 113 |
+
print(
|
| 114 |
+
f"Error: Property '{prop_name}' missing from database.", file=sys.stderr
|
| 115 |
+
)
|
| 116 |
+
return False
|
| 117 |
+
actual_type = db_props[prop_name]["type"]
|
| 118 |
+
if isinstance(expected_type, list):
|
| 119 |
+
if actual_type not in expected_type:
|
| 120 |
+
print(
|
| 121 |
+
f"Error: Property '{prop_name}' has type '{actual_type}', expected one of {expected_type}.",
|
| 122 |
+
file=sys.stderr,
|
| 123 |
+
)
|
| 124 |
+
return False
|
| 125 |
+
else:
|
| 126 |
+
if actual_type != expected_type:
|
| 127 |
+
print(
|
| 128 |
+
f"Error: Property '{prop_name}' has type '{actual_type}', expected '{expected_type}'.",
|
| 129 |
+
file=sys.stderr,
|
| 130 |
+
)
|
| 131 |
+
return False
|
| 132 |
+
# Extra check for Priority options
|
| 133 |
+
if prop_name == "Priority":
|
| 134 |
+
options = {opt["name"] for opt in db_props[prop_name]["select"]["options"]}
|
| 135 |
+
if not PRIORITY_OPTIONS.issubset(options):
|
| 136 |
+
print(
|
| 137 |
+
f"Error: Priority property options must include High/Medium/Low. Current options: {options}",
|
| 138 |
+
file=sys.stderr,
|
| 139 |
+
)
|
| 140 |
+
return False
|
| 141 |
+
|
| 142 |
+
# 5. Verify at least 5 action items exist
|
| 143 |
+
try:
|
| 144 |
+
pages = notion.databases.query(database_id=db_id).get("results", [])
|
| 145 |
+
except Exception as exc:
|
| 146 |
+
print(f"Error querying database pages: {exc}", file=sys.stderr)
|
| 147 |
+
return False
|
| 148 |
+
|
| 149 |
+
if len(pages) < 5:
|
| 150 |
+
print("Error: Database contains fewer than 5 action items.", file=sys.stderr)
|
| 151 |
+
return False
|
| 152 |
+
|
| 153 |
+
# Optional: Verify Department values valid
|
| 154 |
+
for page in pages:
|
| 155 |
+
props = page.get("properties", {})
|
| 156 |
+
|
| 157 |
+
# Task Name must be non-empty
|
| 158 |
+
title_rt = props.get("Task Name", {}).get("title", [])
|
| 159 |
+
task_name = title_rt[0].get("plain_text") if title_rt else ""
|
| 160 |
+
if not task_name.strip():
|
| 161 |
+
print(
|
| 162 |
+
f"Error: Action item '{page.get('id')}' is missing a Task Name.",
|
| 163 |
+
file=sys.stderr,
|
| 164 |
+
)
|
| 165 |
+
return False
|
| 166 |
+
|
| 167 |
+
# Department must be valid
|
| 168 |
+
dept_select = props.get("Department", {}).get("select", {}).get("name")
|
| 169 |
+
if not dept_select or dept_select not in DEPARTMENTS:
|
| 170 |
+
print(
|
| 171 |
+
f"Error: Action item '{page.get('id')}' has invalid or missing Department value.",
|
| 172 |
+
file=sys.stderr,
|
| 173 |
+
)
|
| 174 |
+
return False
|
| 175 |
+
|
| 176 |
+
# Priority and Status must be set (any value)
|
| 177 |
+
priority_val = props.get("Priority", {}).get("select", {}).get("name")
|
| 178 |
+
status_val = props.get("Status", {}).get("status", {}).get("name")
|
| 179 |
+
if not priority_val or not status_val:
|
| 180 |
+
print(
|
| 181 |
+
f"Error: Action item '{page.get('id')}' must have both Priority and Status set.",
|
| 182 |
+
file=sys.stderr,
|
| 183 |
+
)
|
| 184 |
+
return False
|
| 185 |
+
|
| 186 |
+
print(
|
| 187 |
+
"Success: Verified Business Review Dashboard, departmental sections, callout, and Action Items database with β₯5 entries."
|
| 188 |
+
)
|
| 189 |
+
return True
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def main():
|
| 193 |
+
notion = notion_utils.get_notion_client()
|
| 194 |
+
main_id = sys.argv[1] if len(sys.argv) > 1 else None
|
| 195 |
+
if verify(notion, main_id):
|
| 196 |
+
sys.exit(0)
|
| 197 |
+
else:
|
| 198 |
+
sys.exit(1)
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
if __name__ == "__main__":
|
| 202 |
+
main()
|
tasks/notion/standard/it_trouble_shooting_hub/asset_retirement_migration/description.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Please restructure the **IT Inventory** database as described below. Your automation will be checked by an automated script, so follow every detail exactly.
|
| 2 |
+
|
| 3 |
+
---
|
| 4 |
+
Task Steps
|
| 5 |
+
1. Inside the **IT Trouble Shooting Hub** page, locate the database named **IT Inventory**.
|
| 6 |
+
2. Query this database and collect every page whose **Status** property is **Expired** or **To be returned**.
|
| 7 |
+
3. Create a **new full-page database** directly under the same IT Trouble Shooting Hub page called **IT Asset Retirement Queue**.
|
| 8 |
+
4. Configure this new database so that it contains **exactly** the following properties (spellings and types must match):
|
| 9 |
+
β’ Serial β title
|
| 10 |
+
β’ Tags β multi_select
|
| 11 |
+
β’ Status β select
|
| 12 |
+
β’ Vendor β select
|
| 13 |
+
β’ Expiration date β date
|
| 14 |
+
β’ Retirement Reason β select with option set { **Expired License**, **Hardware Obsolete**, **Security Risk**, **User Offboarding** }
|
| 15 |
+
5. For every inventory item gathered in step2:
|
| 16 |
+
a. Create a corresponding page in **IT Asset Retirement Queue** and copy over the values of the Serial, Tags, Status, Vendor and Expiration date properties.
|
| 17 |
+
b. Set **Retirement Reason** to one of the four options above (choose the most appropriate).
|
| 18 |
+
c. Archive the original inventory page **after** the new page has been created.
|
| 19 |
+
6. After all items are migrated:
|
| 20 |
+
a. Update the **description** of the **IT Asset Retirement Queue** database so it is **exactly** `AUTO-GENERATED MIGRATION COMPLETED` (no additional text).
|
| 21 |
+
b. Create a new page under **IT Trouble Shooting Hub** titled **Retirement Migration Log**. Inside this page, add a **callout block** whose text follows the exact pattern:
|
| 22 |
+
|
| 23 |
+
`Successfully migrated <N> assets to the retirement queue on 2025-03-24.`
|
| 24 |
+
|
| 25 |
+
β’ `<N>` is the total number of items moved.
|
tasks/notion/standard/it_trouble_shooting_hub/asset_retirement_migration/meta.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"task_id": "asset_retirement_migration",
|
| 3 |
+
"task_name": "Asset Retirement Migration",
|
| 4 |
+
"category_id": "it_trouble_shooting_hub",
|
| 5 |
+
"category_name": "IT Trouble Shooting Hub",
|
| 6 |
+
"description": "Restructure the IT Inventory database by migrating expired assets to a new IT Asset Retirement Queue database.",
|
| 7 |
+
"author": "Zijian Wu",
|
| 8 |
+
"created_at": "2025-07-27",
|
| 9 |
+
"difficulty": "L3",
|
| 10 |
+
"tags": [
|
| 11 |
+
"database manipulation",
|
| 12 |
+
"automated migration",
|
| 13 |
+
"conditional filtering",
|
| 14 |
+
"data aggregation",
|
| 15 |
+
"report generation"
|
| 16 |
+
],
|
| 17 |
+
"mcp": [
|
| 18 |
+
"notion"
|
| 19 |
+
],
|
| 20 |
+
"meta_data": {
|
| 21 |
+
"stateType": "url",
|
| 22 |
+
"stateContent": null,
|
| 23 |
+
"stateUrl": "https://painted-tennis-ebc.notion.site/It-Trouble-Shooting-Hub-23e81626b6d78020aba7eb65ae1cc2d5",
|
| 24 |
+
"stateOriginalUrl": "https://www.notion.so/marketplace/templates/it-trouble-shooting-hub"
|
| 25 |
+
}
|
| 26 |
+
}
|
tasks/notion/standard/it_trouble_shooting_hub/asset_retirement_migration/verify.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
from typing import Dict, Set
|
| 3 |
+
from notion_client import Client
|
| 4 |
+
from tasks.utils import notion_utils
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def _get_database(root_page_id: str, notion: Client, name: str) -> str | None:
|
| 8 |
+
"""Helper that finds a child database by title inside a page."""
|
| 9 |
+
return notion_utils.find_database_in_block(notion, root_page_id, name)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _check_property(props: Dict, name: str, expected_type: str) -> bool:
|
| 13 |
+
if name not in props:
|
| 14 |
+
print(f"Error: Property '{name}' missing in database.", file=sys.stderr)
|
| 15 |
+
return False
|
| 16 |
+
if props[name]["type"] != expected_type:
|
| 17 |
+
print(
|
| 18 |
+
f"Error: Property '{name}' expected type '{expected_type}', found '{props[name]['type']}'.",
|
| 19 |
+
file=sys.stderr,
|
| 20 |
+
)
|
| 21 |
+
return False
|
| 22 |
+
return True
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def verify(notion: Client, main_id: str | None = None) -> bool:
|
| 26 |
+
"""Verifies that the IT Asset Retirement Queue was created and populated correctly."""
|
| 27 |
+
|
| 28 |
+
# -------------------------------------------------------------------------
|
| 29 |
+
# Resolve the root IT Trouble Shooting Hub page
|
| 30 |
+
# -------------------------------------------------------------------------
|
| 31 |
+
root_page_id = None
|
| 32 |
+
if main_id:
|
| 33 |
+
found_id, obj_type = notion_utils.find_page_or_database_by_id(notion, main_id)
|
| 34 |
+
if found_id and obj_type == "page":
|
| 35 |
+
root_page_id = found_id
|
| 36 |
+
|
| 37 |
+
if not root_page_id:
|
| 38 |
+
root_page_id = notion_utils.find_page(notion, "IT Trouble Shooting Hub")
|
| 39 |
+
if not root_page_id:
|
| 40 |
+
print(
|
| 41 |
+
"Error: Could not locate the 'IT Trouble Shooting Hub' page.",
|
| 42 |
+
file=sys.stderr,
|
| 43 |
+
)
|
| 44 |
+
return False
|
| 45 |
+
|
| 46 |
+
# -------------------------------------------------------------------------
|
| 47 |
+
# Locate the original and new databases
|
| 48 |
+
# -------------------------------------------------------------------------
|
| 49 |
+
inventory_db_id = _get_database(root_page_id, notion, "IT Inventory")
|
| 50 |
+
if not inventory_db_id:
|
| 51 |
+
print("Error: 'IT Inventory' database not found.", file=sys.stderr)
|
| 52 |
+
return False
|
| 53 |
+
|
| 54 |
+
retirement_db_id = _get_database(root_page_id, notion, "IT Asset Retirement Queue")
|
| 55 |
+
if not retirement_db_id:
|
| 56 |
+
print("Error: 'IT Asset Retirement Queue' database not found.", file=sys.stderr)
|
| 57 |
+
return False
|
| 58 |
+
|
| 59 |
+
# -------------------------------------------------------------------------
|
| 60 |
+
# Validate schema of the retirement queue database
|
| 61 |
+
# -------------------------------------------------------------------------
|
| 62 |
+
retirement_db = notion.databases.retrieve(database_id=retirement_db_id)
|
| 63 |
+
r_props = retirement_db["properties"]
|
| 64 |
+
|
| 65 |
+
required_schema = {
|
| 66 |
+
"Serial": "title",
|
| 67 |
+
"Tags": "multi_select",
|
| 68 |
+
"Status": "select",
|
| 69 |
+
"Vendor": "select",
|
| 70 |
+
"Expiration date": "date",
|
| 71 |
+
"Retirement Reason": "select",
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
for pname, ptype in required_schema.items():
|
| 75 |
+
if not _check_property(r_props, pname, ptype):
|
| 76 |
+
return False
|
| 77 |
+
|
| 78 |
+
# Check Retirement Reason options
|
| 79 |
+
expected_reason_options: Set[str] = {
|
| 80 |
+
"Expired License",
|
| 81 |
+
"Hardware Obsolete",
|
| 82 |
+
"Security Risk",
|
| 83 |
+
"User Offboarding",
|
| 84 |
+
}
|
| 85 |
+
actual_options = {
|
| 86 |
+
opt["name"] for opt in r_props["Retirement Reason"]["select"]["options"]
|
| 87 |
+
}
|
| 88 |
+
if actual_options != expected_reason_options:
|
| 89 |
+
print(
|
| 90 |
+
"Error: 'Retirement Reason' select options mismatch.\n"
|
| 91 |
+
f"Expected: {sorted(expected_reason_options)}\n"
|
| 92 |
+
f"Found: {sorted(actual_options)}",
|
| 93 |
+
file=sys.stderr,
|
| 94 |
+
)
|
| 95 |
+
return False
|
| 96 |
+
|
| 97 |
+
# ---------------------------------------------------------------
|
| 98 |
+
# Validate database description starts with required phrase
|
| 99 |
+
# ---------------------------------------------------------------
|
| 100 |
+
desc_rich = retirement_db.get("description", [])
|
| 101 |
+
desc_text = "".join([t.get("plain_text", "") for t in desc_rich])
|
| 102 |
+
required_desc = "AUTO-GENERATED MIGRATION COMPLETED"
|
| 103 |
+
if desc_text.strip() != required_desc:
|
| 104 |
+
print(
|
| 105 |
+
f"Error: Retirement database description must be exactly '{required_desc}'.",
|
| 106 |
+
file=sys.stderr,
|
| 107 |
+
)
|
| 108 |
+
return False
|
| 109 |
+
|
| 110 |
+
# -------------------------------------------------------------------------
|
| 111 |
+
# Validate that inventory items are moved & archived
|
| 112 |
+
# -------------------------------------------------------------------------
|
| 113 |
+
expired_filter = {
|
| 114 |
+
"property": "Status",
|
| 115 |
+
"select": {"equals": "Expired"},
|
| 116 |
+
}
|
| 117 |
+
to_return_filter = {
|
| 118 |
+
"property": "Status",
|
| 119 |
+
"select": {"equals": "To be returned"},
|
| 120 |
+
}
|
| 121 |
+
compound_filter = {"or": [expired_filter, to_return_filter]}
|
| 122 |
+
|
| 123 |
+
# Query for any *active* items that still match these statuses
|
| 124 |
+
remaining_items = notion.databases.query(
|
| 125 |
+
database_id=inventory_db_id,
|
| 126 |
+
filter=compound_filter,
|
| 127 |
+
archived=False,
|
| 128 |
+
).get("results", [])
|
| 129 |
+
|
| 130 |
+
if remaining_items:
|
| 131 |
+
print(
|
| 132 |
+
f"Error: {len(remaining_items)} 'Expired' / 'To be returned' items still present in IT Inventory.",
|
| 133 |
+
file=sys.stderr,
|
| 134 |
+
)
|
| 135 |
+
return False
|
| 136 |
+
|
| 137 |
+
# There should be at least one entry in the retirement queue
|
| 138 |
+
retirement_pages = notion.databases.query(database_id=retirement_db_id).get(
|
| 139 |
+
"results", []
|
| 140 |
+
)
|
| 141 |
+
expected_serials = {"65XYQ/GB", "36x10PIQ"}
|
| 142 |
+
if len(retirement_pages) != len(expected_serials):
|
| 143 |
+
print(
|
| 144 |
+
f"Error: Expected {len(expected_serials)} retirement pages, found {len(retirement_pages)}.",
|
| 145 |
+
file=sys.stderr,
|
| 146 |
+
)
|
| 147 |
+
return False
|
| 148 |
+
|
| 149 |
+
# Each retirement page must have a Retirement Reason
|
| 150 |
+
serials_seen = set()
|
| 151 |
+
for page in retirement_pages:
|
| 152 |
+
props = page["properties"]
|
| 153 |
+
reason = props.get("Retirement Reason", {}).get("select", {})
|
| 154 |
+
if not reason or reason.get("name") not in expected_reason_options:
|
| 155 |
+
print(
|
| 156 |
+
f"Error: Page {page['id']} missing valid 'Retirement Reason'.",
|
| 157 |
+
file=sys.stderr,
|
| 158 |
+
)
|
| 159 |
+
return False
|
| 160 |
+
|
| 161 |
+
# Collect Serial title
|
| 162 |
+
title_rich = props.get("Serial", {}).get("title", [])
|
| 163 |
+
serial_val = "".join([t.get("plain_text", "") for t in title_rich]).strip()
|
| 164 |
+
serials_seen.add(serial_val)
|
| 165 |
+
|
| 166 |
+
if serials_seen != expected_serials:
|
| 167 |
+
print(
|
| 168 |
+
f"Error: Serial values mismatch. Expected {sorted(expected_serials)}, found {sorted(serials_seen)}.",
|
| 169 |
+
file=sys.stderr,
|
| 170 |
+
)
|
| 171 |
+
return False
|
| 172 |
+
|
| 173 |
+
# -----------------------------------------------------------------
|
| 174 |
+
# Verify the migration log page and callout block contents
|
| 175 |
+
# -----------------------------------------------------------------
|
| 176 |
+
log_page_title = "Retirement Migration Log"
|
| 177 |
+
log_page_id = notion_utils.find_page(notion, log_page_title)
|
| 178 |
+
if not log_page_id:
|
| 179 |
+
print(f"Error: Page '{log_page_title}' not found.", file=sys.stderr)
|
| 180 |
+
return False
|
| 181 |
+
|
| 182 |
+
# Search for a callout block with required pattern
|
| 183 |
+
import re
|
| 184 |
+
|
| 185 |
+
callout_pattern = re.compile(
|
| 186 |
+
r"Successfully migrated (\d+) assets to the retirement queue on 2025-03-24\."
|
| 187 |
+
)
|
| 188 |
+
blocks = notion_utils.get_all_blocks_recursively(notion, log_page_id)
|
| 189 |
+
match_found = False
|
| 190 |
+
for blk in blocks:
|
| 191 |
+
if blk.get("type") == "callout":
|
| 192 |
+
text = notion_utils.get_block_plain_text(blk)
|
| 193 |
+
m = callout_pattern.search(text)
|
| 194 |
+
if m:
|
| 195 |
+
migrated_num = int(m.group(1))
|
| 196 |
+
if migrated_num == len(expected_serials):
|
| 197 |
+
match_found = True
|
| 198 |
+
else:
|
| 199 |
+
print(
|
| 200 |
+
f"Error: Callout reports {migrated_num} assets, but {len(retirement_pages)} retirement pages found.",
|
| 201 |
+
file=sys.stderr,
|
| 202 |
+
)
|
| 203 |
+
return False
|
| 204 |
+
break
|
| 205 |
+
if not match_found:
|
| 206 |
+
print(
|
| 207 |
+
"Error: Required callout block not found in migration log page.",
|
| 208 |
+
file=sys.stderr,
|
| 209 |
+
)
|
| 210 |
+
return False
|
| 211 |
+
|
| 212 |
+
print("Success: All verification criteria satisfied.")
|
| 213 |
+
return True
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def main():
|
| 217 |
+
notion = notion_utils.get_notion_client()
|
| 218 |
+
main_id = sys.argv[1] if len(sys.argv) > 1 else None
|
| 219 |
+
if verify(notion, main_id):
|
| 220 |
+
sys.exit(0)
|
| 221 |
+
else:
|
| 222 |
+
sys.exit(1)
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
if __name__ == "__main__":
|
| 226 |
+
main()
|
tasks/notion/standard/it_trouble_shooting_hub/security_audit_ticket/description.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Please help me create a comprehensive security audit ticket based on the data already stored in the **IT Trouble Shooting Hub** page.
|
| 2 |
+
|
| 3 |
+
Your automation should:
|
| 4 |
+
|
| 5 |
+
1. In the **IT Inventory** database, find every item whose **Expiration date** is **before 2023-07-15**.
|
| 6 |
+
2. In the **IT FAQs** database, look up any FAQ entries that have the **"Security"** tag.
|
| 7 |
+
3. **Create a new page** inside the **IT Requests** database with **exact title**:
|
| 8 |
+
|
| 9 |
+
`Quarterly Security Audit - Expired Assets Review`
|
| 10 |
+
4. Set its **Priority** property to **High**.
|
| 11 |
+
5. Set its **Due** property to **2023-06-22**.
|
| 12 |
+
6. In the page body, add a bullet-list block that enumerates **each expired inventory item**. **Each bullet item must follow this exact text format (including the dashes):**
|
| 13 |
+
|
| 14 |
+
`<Serial> - <Tag> - <Recommendation>`
|
| 15 |
+
|
| 16 |
+
β’ `<Serial>` is the itemβs Serial value.
|
| 17 |
+
β’ `<Tag>` is the first tag assigned to the inventory item (e.g., "Laptop").
|
| 18 |
+
β’ `<Recommendation>` is a brief action you suggest based on the security FAQ entry (any text is acceptable).
|
| 19 |
+
|
| 20 |
+
Example (do **not** copy):
|
| 21 |
+
`ABC123 - Laptop - Renew warranty and enable disk encryption`
|
tasks/notion/standard/it_trouble_shooting_hub/security_audit_ticket/meta.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"task_id": "security_audit_ticket",
|
| 3 |
+
"task_name": "Security Audit Ticket",
|
| 4 |
+
"category_id": "it_trouble_shooting_hub",
|
| 5 |
+
"category_name": "IT Trouble Shooting Hub",
|
| 6 |
+
"description": "Create a comprehensive security audit ticket based on expired inventory items and security FAQ entries.",
|
| 7 |
+
"author": "Zijian Wu",
|
| 8 |
+
"created_at": "2025-07-27",
|
| 9 |
+
"difficulty": "L3",
|
| 10 |
+
"tags": [
|
| 11 |
+
"conditional filtering",
|
| 12 |
+
"database manipulation",
|
| 13 |
+
"data aggregation",
|
| 14 |
+
"report generation"
|
| 15 |
+
],
|
| 16 |
+
"mcp": [
|
| 17 |
+
"notion"
|
| 18 |
+
],
|
| 19 |
+
"meta_data": {
|
| 20 |
+
"stateType": "url",
|
| 21 |
+
"stateContent": null,
|
| 22 |
+
"stateUrl": "https://painted-tennis-ebc.notion.site/It-Trouble-Shooting-Hub-23e81626b6d78020aba7eb65ae1cc2d5",
|
| 23 |
+
"stateOriginalUrl": "https://www.notion.so/marketplace/templates/it-trouble-shooting-hub"
|
| 24 |
+
}
|
| 25 |
+
}
|
tasks/notion/standard/it_trouble_shooting_hub/security_audit_ticket/verify.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
from notion_client import Client
|
| 3 |
+
from tasks.utils import notion_utils
|
| 4 |
+
import re
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def _get_title_text(page_properties: dict) -> str:
|
| 8 |
+
"""Extract the plain text of the first title property from a page."""
|
| 9 |
+
for prop in page_properties.values():
|
| 10 |
+
if prop.get("type") == "title":
|
| 11 |
+
title_rich = prop.get("title", [])
|
| 12 |
+
if title_rich:
|
| 13 |
+
return title_rich[0].get("plain_text")
|
| 14 |
+
return ""
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def verify(notion: Client, main_id: str | None = None) -> bool:
|
| 18 |
+
"""Verify that the automation created the expected security audit ticket."""
|
| 19 |
+
|
| 20 |
+
# ----------------------------------------------------------------------------------
|
| 21 |
+
# Locate the root page (IT Trouble Shooting Hub) either via main_id or by title.
|
| 22 |
+
# ----------------------------------------------------------------------------------
|
| 23 |
+
root_page_id = None
|
| 24 |
+
if main_id:
|
| 25 |
+
found_id, object_type = notion_utils.find_page_or_database_by_id(
|
| 26 |
+
notion, main_id
|
| 27 |
+
)
|
| 28 |
+
if found_id and object_type == "page":
|
| 29 |
+
root_page_id = found_id
|
| 30 |
+
|
| 31 |
+
if not root_page_id:
|
| 32 |
+
root_page_id = notion_utils.find_page(notion, "IT Trouble Shooting Hub")
|
| 33 |
+
if not root_page_id:
|
| 34 |
+
print(
|
| 35 |
+
"Error: Could not locate the 'IT Trouble Shooting Hub' page.",
|
| 36 |
+
file=sys.stderr,
|
| 37 |
+
)
|
| 38 |
+
return False
|
| 39 |
+
|
| 40 |
+
# ----------------------------------------------------------------------------------
|
| 41 |
+
# Find the IT Requests database under the root page.
|
| 42 |
+
# ----------------------------------------------------------------------------------
|
| 43 |
+
requests_db_id = notion_utils.find_database_in_block(
|
| 44 |
+
notion, root_page_id, "IT Requests"
|
| 45 |
+
)
|
| 46 |
+
if not requests_db_id:
|
| 47 |
+
print(
|
| 48 |
+
"Error: 'IT Requests' database not found in the workspace.", file=sys.stderr
|
| 49 |
+
)
|
| 50 |
+
return False
|
| 51 |
+
|
| 52 |
+
# ----------------------------------------------------------------------------------
|
| 53 |
+
# Search for the expected ticket inside the IT Requests database.
|
| 54 |
+
# ----------------------------------------------------------------------------------
|
| 55 |
+
expected_title = "Quarterly Security Audit - Expired Assets Review"
|
| 56 |
+
results = notion.databases.query(database_id=requests_db_id).get("results", [])
|
| 57 |
+
|
| 58 |
+
target_page = None
|
| 59 |
+
for page in results:
|
| 60 |
+
title_text = _get_title_text(page.get("properties", {}))
|
| 61 |
+
if title_text == expected_title:
|
| 62 |
+
target_page = page
|
| 63 |
+
break
|
| 64 |
+
|
| 65 |
+
if not target_page:
|
| 66 |
+
print(
|
| 67 |
+
f"Failure: Ticket with title '{expected_title}' was not found in 'IT Requests' database.",
|
| 68 |
+
file=sys.stderr,
|
| 69 |
+
)
|
| 70 |
+
return False
|
| 71 |
+
|
| 72 |
+
props = target_page.get("properties", {})
|
| 73 |
+
|
| 74 |
+
# ----------------------------------------------------------------------------------
|
| 75 |
+
# Validate Priority property.
|
| 76 |
+
# ----------------------------------------------------------------------------------
|
| 77 |
+
priority_value = props.get("Priority", {}).get("select", {}).get("name")
|
| 78 |
+
if priority_value != "High":
|
| 79 |
+
print(
|
| 80 |
+
f"Failure: Expected Priority 'High', found '{priority_value}'.",
|
| 81 |
+
file=sys.stderr,
|
| 82 |
+
)
|
| 83 |
+
return False
|
| 84 |
+
|
| 85 |
+
# ----------------------------------------------------------------------------------
|
| 86 |
+
# Validate Due date property.
|
| 87 |
+
# ----------------------------------------------------------------------------------
|
| 88 |
+
due_date_start = props.get("Due", {}).get("date", {}).get("start")
|
| 89 |
+
expected_due_iso = "2023-06-22"
|
| 90 |
+
if not due_date_start or not due_date_start.startswith(expected_due_iso):
|
| 91 |
+
print(
|
| 92 |
+
f"Failure: Expected Due date '{expected_due_iso}', found '{due_date_start}'.",
|
| 93 |
+
file=sys.stderr,
|
| 94 |
+
)
|
| 95 |
+
return False
|
| 96 |
+
|
| 97 |
+
# ----------------------------------------------------------------------------------
|
| 98 |
+
# Validate the bulleted list contains the correct expired items in required format.
|
| 99 |
+
# ----------------------------------------------------------------------------------
|
| 100 |
+
page_id = target_page["id"]
|
| 101 |
+
blocks = notion.blocks.children.list(block_id=page_id).get("results", [])
|
| 102 |
+
bullet_texts = [
|
| 103 |
+
notion_utils.get_block_plain_text(b)
|
| 104 |
+
for b in blocks
|
| 105 |
+
if b.get("type") == "bulleted_list_item"
|
| 106 |
+
]
|
| 107 |
+
|
| 108 |
+
expected_items = {
|
| 109 |
+
"192371-8910/54": "Computer Accessory",
|
| 110 |
+
"32x11PIP": "Computer Accessory",
|
| 111 |
+
"76x87PCY": "Laptop",
|
| 112 |
+
"36x10PIQ": "Computer Accessory",
|
| 113 |
+
"65XYQ/GB": "License",
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
if len(bullet_texts) != len(expected_items):
|
| 117 |
+
print(
|
| 118 |
+
f"Failure: Expected {len(expected_items)} bullet items, found {len(bullet_texts)}.",
|
| 119 |
+
file=sys.stderr,
|
| 120 |
+
)
|
| 121 |
+
return False
|
| 122 |
+
|
| 123 |
+
bullet_pattern = re.compile(r"^\s*(.*?)\s+-\s+(.*?)\s+-\s+(.+?)\s*$")
|
| 124 |
+
matched = set()
|
| 125 |
+
for text in bullet_texts:
|
| 126 |
+
m = bullet_pattern.match(text)
|
| 127 |
+
if not m:
|
| 128 |
+
print(
|
| 129 |
+
f"Failure: Bullet item '{text}' does not follow '<Serial> - <Tag> - <Recommendation>' format.",
|
| 130 |
+
file=sys.stderr,
|
| 131 |
+
)
|
| 132 |
+
return False
|
| 133 |
+
serial, tag, advice = m.group(1).strip(), m.group(2).strip(), m.group(3).strip()
|
| 134 |
+
if serial not in expected_items:
|
| 135 |
+
print(
|
| 136 |
+
f"Failure: Unexpected Serial '{serial}' found in bullet list.",
|
| 137 |
+
file=sys.stderr,
|
| 138 |
+
)
|
| 139 |
+
return False
|
| 140 |
+
if expected_items[serial] != tag:
|
| 141 |
+
print(
|
| 142 |
+
f"Failure: Serial '{serial}' expected tag '{expected_items[serial]}', found '{tag}'.",
|
| 143 |
+
file=sys.stderr,
|
| 144 |
+
)
|
| 145 |
+
return False
|
| 146 |
+
if not advice:
|
| 147 |
+
print(
|
| 148 |
+
f"Failure: Bullet item for Serial '{serial}' is missing a recommendation/advice.",
|
| 149 |
+
file=sys.stderr,
|
| 150 |
+
)
|
| 151 |
+
return False
|
| 152 |
+
matched.add(serial)
|
| 153 |
+
|
| 154 |
+
if len(matched) != len(expected_items):
|
| 155 |
+
missing = set(expected_items.keys()) - matched
|
| 156 |
+
print(
|
| 157 |
+
f"Failure: Missing bullet items for serials: {', '.join(missing)}.",
|
| 158 |
+
file=sys.stderr,
|
| 159 |
+
)
|
| 160 |
+
return False
|
| 161 |
+
|
| 162 |
+
print("Success: All verification criteria satisfied.")
|
| 163 |
+
return True
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def main():
|
| 167 |
+
notion = notion_utils.get_notion_client()
|
| 168 |
+
main_id = sys.argv[1] if len(sys.argv) > 1 else None
|
| 169 |
+
if verify(notion, main_id):
|
| 170 |
+
sys.exit(0)
|
| 171 |
+
else:
|
| 172 |
+
sys.exit(1)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
if __name__ == "__main__":
|
| 176 |
+
main()
|
tasks/notion/standard/it_trouble_shooting_hub/verification_expired_update/description.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
**Task Overview**
|
| 2 |
+
|
| 3 |
+
My IT knowledge base contains pages whose verification status has expired:
|
| 4 |
+
|
| 5 |
+
**Task Requirements**
|
| 6 |
+
1. Locate the database named **"IT Homepage"** inside the main page **"It Trouble Shooting Hub"**.
|
| 7 |
+
2. Within that database, find every page (except for **"It Inventory"**) where the **Verification** property state contains `expired`.
|
| 8 |
+
3. For **each** expired page:
|
| 9 |
+
β’ Insert a **callout block** at the very top (as the first child block) whose rich-text content is:
|
| 10 |
+
`VERIFICATION EXPIRED - This page needs review and re-verification`
|
| 11 |
+
β’ Set the calloutβs icon to β οΈ.
|
| 12 |
+
β’ Set the calloutβs colour to `red_background`.
|
| 13 |
+
4. Create a new entry in the **"IT Requests"** database with:
|
| 14 |
+
β’ Title (property **Task name**) **exactly** `Batch Verification Update Required`.
|
| 15 |
+
β’ **Priority** set to `High`.
|
| 16 |
+
β’ **Status** set to `In progress`.
|
| 17 |
+
β’ In the page body add a **bulleted list** where each bullet is a **mention** of the page processed in step 3 (i.e., use the Notion mention object linking to that page).
|
tasks/notion/standard/it_trouble_shooting_hub/verification_expired_update/meta.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"task_id": "verification_expired_update",
|
| 3 |
+
"task_name": "Verification Expired Update",
|
| 4 |
+
"category_id": "it_trouble_shooting_hub",
|
| 5 |
+
"category_name": "IT Trouble Shooting Hub",
|
| 6 |
+
"description": "Update pages with expired verification status by adding warning callouts and creating a batch update request.",
|
| 7 |
+
"author": "Zijian Wu",
|
| 8 |
+
"created_at": "2025-07-27",
|
| 9 |
+
"difficulty": "L3",
|
| 10 |
+
"tags": [
|
| 11 |
+
"conditional filtering",
|
| 12 |
+
"visual formatting",
|
| 13 |
+
"database manipulation",
|
| 14 |
+
"cross-reference linking",
|
| 15 |
+
"status tracking"
|
| 16 |
+
],
|
| 17 |
+
"mcp": [
|
| 18 |
+
"notion"
|
| 19 |
+
],
|
| 20 |
+
"meta_data": {
|
| 21 |
+
"stateType": "url",
|
| 22 |
+
"stateContent": null,
|
| 23 |
+
"stateUrl": "https://painted-tennis-ebc.notion.site/It-Trouble-Shooting-Hub-23e81626b6d78020aba7eb65ae1cc2d5",
|
| 24 |
+
"stateOriginalUrl": "https://www.notion.so/marketplace/templates/it-trouble-shooting-hub"
|
| 25 |
+
}
|
| 26 |
+
}
|
tasks/notion/standard/it_trouble_shooting_hub/verification_expired_update/verify.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
from notion_client import Client
|
| 3 |
+
from tasks.utils import notion_utils
|
| 4 |
+
|
| 5 |
+
CALL_OUT_TEXT = "VERIFICATION EXPIRED - This page needs review and re-verification"
|
| 6 |
+
CALL_OUT_ICON = "β οΈ"
|
| 7 |
+
CALL_OUT_COLOR = "red_background"
|
| 8 |
+
IT_HOMEPAGE_DB_TITLE = "IT Homepage"
|
| 9 |
+
IT_REQUESTS_DB_TITLE = "IT Requests"
|
| 10 |
+
REQUEST_TITLE = "Batch Verification Update Required"
|
| 11 |
+
PRIORITY_HIGH = "High"
|
| 12 |
+
STATUS_IN_PROGRESS = "In progress"
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _get_main_page_id(notion: Client, main_id: str | None) -> str | None:
|
| 16 |
+
"""Resolve the main page id starting from CLI arg or by title search."""
|
| 17 |
+
if main_id:
|
| 18 |
+
found_id, obj_type = notion_utils.find_page_or_database_by_id(notion, main_id)
|
| 19 |
+
if found_id and obj_type == "page":
|
| 20 |
+
return found_id
|
| 21 |
+
# Fallback to title search (case-insensitive)
|
| 22 |
+
return notion_utils.find_page(notion, "It Trouble Shooting Hub")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _fetch_database_id(
|
| 26 |
+
notion: Client, parent_page_id: str, db_title: str
|
| 27 |
+
) -> str | None:
|
| 28 |
+
"""Locate a child database by title inside a given page."""
|
| 29 |
+
return notion_utils.find_database_in_block(notion, parent_page_id, db_title)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _expired_pages(notion: Client, db_id: str) -> list[dict]:
|
| 33 |
+
"""Return list of page objects with Verification.state == 'expired'."""
|
| 34 |
+
# Query all pages (API max 100 per call). If many pages expected, iterate.
|
| 35 |
+
results = notion.databases.query(database_id=db_id).get("results", [])
|
| 36 |
+
expired = []
|
| 37 |
+
for page in results:
|
| 38 |
+
verification_prop = page.get("properties", {}).get("Verification", {})
|
| 39 |
+
state = verification_prop.get("verification", {}).get("state")
|
| 40 |
+
# Skip the IT Inventory database entry
|
| 41 |
+
title_prop = page.get("properties", {}).get("Page", {}).get("title", [])
|
| 42 |
+
title_text = title_prop[0].get("plain_text") if title_prop else ""
|
| 43 |
+
if title_text.strip().lower() == "it inventory":
|
| 44 |
+
continue
|
| 45 |
+
|
| 46 |
+
if state and "expired" in state.lower():
|
| 47 |
+
expired.append(page)
|
| 48 |
+
return expired
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _check_callout_present(notion: Client, page_id: str) -> bool:
|
| 52 |
+
"""Verify the specified callout is the first child block of the page."""
|
| 53 |
+
children = notion.blocks.children.list(block_id=page_id, page_size=1).get(
|
| 54 |
+
"results", []
|
| 55 |
+
)
|
| 56 |
+
if not children:
|
| 57 |
+
return False
|
| 58 |
+
first_block = children[0]
|
| 59 |
+
if first_block.get("type") != "callout":
|
| 60 |
+
return False
|
| 61 |
+
data = first_block.get("callout", {})
|
| 62 |
+
# Check color
|
| 63 |
+
if data.get("color") != CALL_OUT_COLOR:
|
| 64 |
+
return False
|
| 65 |
+
|
| 66 |
+
# Check icon
|
| 67 |
+
icon = data.get("icon", {})
|
| 68 |
+
if icon.get("type") != "emoji" or icon.get("emoji") != CALL_OUT_ICON:
|
| 69 |
+
return False
|
| 70 |
+
|
| 71 |
+
# Check text content (callout rich text plain text)
|
| 72 |
+
plain_text = notion_utils.get_block_plain_text(first_block)
|
| 73 |
+
return CALL_OUT_TEXT in plain_text
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _find_request_page(notion: Client, db_id: str) -> dict | None:
|
| 77 |
+
"""Find the IT Request page with the expected title."""
|
| 78 |
+
# Use a simple search inside database
|
| 79 |
+
res = notion.databases.query(
|
| 80 |
+
database_id=db_id,
|
| 81 |
+
filter={"property": "Task name", "title": {"equals": REQUEST_TITLE}},
|
| 82 |
+
).get("results", [])
|
| 83 |
+
return res[0] if res else None
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _check_request_properties(page: dict) -> bool:
|
| 87 |
+
props = page.get("properties", {})
|
| 88 |
+
priority = props.get("Priority", {}).get("select", {}).get("name")
|
| 89 |
+
status = (
|
| 90 |
+
props.get("Status", {}).get("status", {}).get("name")
|
| 91 |
+
if props.get("Status", {}).get("status")
|
| 92 |
+
else props.get("Status", {}).get("select", {}).get("name")
|
| 93 |
+
)
|
| 94 |
+
return priority == PRIORITY_HIGH and status == STATUS_IN_PROGRESS
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _request_page_contains_mentions(
|
| 98 |
+
notion: Client, request_page_id: str, expected_page_ids: list[str]
|
| 99 |
+
) -> bool:
|
| 100 |
+
children = notion.blocks.children.list(block_id=request_page_id, page_size=100).get(
|
| 101 |
+
"results", []
|
| 102 |
+
)
|
| 103 |
+
bullet_blocks = [b for b in children if b.get("type") == "bulleted_list_item"]
|
| 104 |
+
mentioned_ids: set[str] = set()
|
| 105 |
+
for block in bullet_blocks:
|
| 106 |
+
rich_text = block.get("bulleted_list_item", {}).get("rich_text", [])
|
| 107 |
+
for rt in rich_text:
|
| 108 |
+
if rt.get("type") == "mention":
|
| 109 |
+
mention = rt.get("mention", {})
|
| 110 |
+
if mention.get("type") == "page":
|
| 111 |
+
mentioned_ids.add(mention.get("page", {}).get("id"))
|
| 112 |
+
if len(mentioned_ids) < len(expected_page_ids):
|
| 113 |
+
return False
|
| 114 |
+
return all(pid in mentioned_ids for pid in expected_page_ids)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def verify(notion: Client, main_id: str | None = None) -> bool:
|
| 118 |
+
main_page_id = _get_main_page_id(notion, main_id)
|
| 119 |
+
if not main_page_id:
|
| 120 |
+
print(
|
| 121 |
+
"Error: Could not locate the main page 'It Trouble Shooting Hub'.",
|
| 122 |
+
file=sys.stderr,
|
| 123 |
+
)
|
| 124 |
+
return False
|
| 125 |
+
|
| 126 |
+
# Locate required databases
|
| 127 |
+
it_home_db_id = _fetch_database_id(notion, main_page_id, IT_HOMEPAGE_DB_TITLE)
|
| 128 |
+
it_req_db_id = _fetch_database_id(notion, main_page_id, IT_REQUESTS_DB_TITLE)
|
| 129 |
+
if not all([it_home_db_id, it_req_db_id]):
|
| 130 |
+
print(
|
| 131 |
+
"Error: Required databases not found under the main page.", file=sys.stderr
|
| 132 |
+
)
|
| 133 |
+
return False
|
| 134 |
+
|
| 135 |
+
# Identify expired pages
|
| 136 |
+
expired_pages = _expired_pages(notion, it_home_db_id)
|
| 137 |
+
if not expired_pages:
|
| 138 |
+
print(
|
| 139 |
+
"Failure: No expired pages found; expected at least one for this task.",
|
| 140 |
+
file=sys.stderr,
|
| 141 |
+
)
|
| 142 |
+
return False
|
| 143 |
+
|
| 144 |
+
# Verify callout on each expired page
|
| 145 |
+
for pg in expired_pages:
|
| 146 |
+
pid = pg["id"]
|
| 147 |
+
if not _check_callout_present(notion, pid):
|
| 148 |
+
print(
|
| 149 |
+
f"Failure: Callout missing or incorrect on page {pid}.", file=sys.stderr
|
| 150 |
+
)
|
| 151 |
+
return False
|
| 152 |
+
|
| 153 |
+
# Verify IT Request entry
|
| 154 |
+
request_page = _find_request_page(notion, it_req_db_id)
|
| 155 |
+
if not request_page:
|
| 156 |
+
print(
|
| 157 |
+
"Failure: IT Request 'Batch Verification Update Required' not found.",
|
| 158 |
+
file=sys.stderr,
|
| 159 |
+
)
|
| 160 |
+
return False
|
| 161 |
+
if not _check_request_properties(request_page):
|
| 162 |
+
print("Failure: Priority or Status incorrect on IT Request.", file=sys.stderr)
|
| 163 |
+
return False
|
| 164 |
+
|
| 165 |
+
# Verify bullet list in IT Request body
|
| 166 |
+
expired_titles = []
|
| 167 |
+
for p in expired_pages:
|
| 168 |
+
title_prop = p.get("properties", {}).get("Page", {}).get("title", [])
|
| 169 |
+
title_text = title_prop[0].get("plain_text") if title_prop else None
|
| 170 |
+
if title_text:
|
| 171 |
+
expired_titles.append(title_text)
|
| 172 |
+
expected_page_ids = [p["id"] for p in expired_pages]
|
| 173 |
+
if not _request_page_contains_mentions(
|
| 174 |
+
notion, request_page["id"], expected_page_ids
|
| 175 |
+
):
|
| 176 |
+
print(
|
| 177 |
+
"Failure: IT Request body does not contain mentions for all affected pages.",
|
| 178 |
+
file=sys.stderr,
|
| 179 |
+
)
|
| 180 |
+
return False
|
| 181 |
+
|
| 182 |
+
print("Success: All verification checks passed.")
|
| 183 |
+
return True
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def main():
|
| 187 |
+
notion = notion_utils.get_notion_client()
|
| 188 |
+
main_id = sys.argv[1] if len(sys.argv) > 1 else None
|
| 189 |
+
if verify(notion, main_id):
|
| 190 |
+
sys.exit(0)
|
| 191 |
+
sys.exit(1)
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
if __name__ == "__main__":
|
| 195 |
+
main()
|
tasks/notion/standard/japan_travel_planner/daily_itinerary_overview/description.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Create a comprehensive daily itinerary overview page to help organize my Japan travel plans. I need you to create a new page called 'Daily Itinerary Overview' as a child of the main Japan Travel Planner page.
|
| 2 |
+
|
| 3 |
+
**Task Requirements:**
|
| 4 |
+
1. Create a new page titled 'Daily Itinerary Overview' as a child page of the main Japan Travel Planner page
|
| 5 |
+
2. Query the Travel Itinerary database to retrieve all activities
|
| 6 |
+
3. Structure the page with the following specific format:
|
| 7 |
+
- Add a heading_1 block with text "π
Daily Itinerary Overview"
|
| 8 |
+
- Add a heading_2 block with text "π Trip Summary"
|
| 9 |
+
- Under Trip Summary, add a paragraph listing the total number of visited activities
|
| 10 |
+
- Create heading_2 blocks for "π
Day 1", "π Day 2", and "π Day 3"
|
| 11 |
+
- Under each day heading, list the activities scheduled for that day in to do list
|
| 12 |
+
- Each activity (use To-do list) should show: Activity Name - City (if available), for example, "Osaka Castle - Osaka". Check it if it's visited.
|
| 13 |
+
4. The summary paragraph must contain the exact text "Total activities visited (from Day 1 to Day 3): [NUMBER]" where [NUMBER] is the actual count.
|
| 14 |
+
5. Ensure all headings use the exact emoji and text format specified above
|
tasks/notion/standard/japan_travel_planner/daily_itinerary_overview/meta.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"task_id": "daily_itinerary_overview",
|
| 3 |
+
"task_name": "Daily Itinerary Overview",
|
| 4 |
+
"category_id": "japan_travel_planner",
|
| 5 |
+
"category_name": "Japan Travel Planner",
|
| 6 |
+
"description": "Create a comprehensive daily itinerary overview page to organize Japan travel plans with structured day-by-day activities.",
|
| 7 |
+
"author": "Xiangyan Liu",
|
| 8 |
+
"created_at": "2025-07-27",
|
| 9 |
+
"difficulty": "L3",
|
| 10 |
+
"tags": [
|
| 11 |
+
"database manipulation",
|
| 12 |
+
"data aggregation",
|
| 13 |
+
"report generation",
|
| 14 |
+
"visual formatting",
|
| 15 |
+
"status tracking"
|
| 16 |
+
],
|
| 17 |
+
"mcp": [
|
| 18 |
+
"notion"
|
| 19 |
+
],
|
| 20 |
+
"meta_data": {
|
| 21 |
+
"stateType": "url",
|
| 22 |
+
"stateContent": null,
|
| 23 |
+
"stateUrl": "https://painted-tennis-ebc.notion.site/Japan-Travel-Planner-23181626b6d781c4b6bedb12786b5abe"
|
| 24 |
+
}
|
| 25 |
+
}
|