|
|
|
|
|
import os |
|
|
import json |
|
|
from datetime import datetime |
|
|
|
|
|
import httpx |
|
|
from fastapi import FastAPI, Request |
|
|
|
|
|
from google.oauth2 import service_account |
|
|
from googleapiclient.discovery import build |
|
|
|
|
|
app = FastAPI() |
|
|
|
|
|
|
|
|
NOTION_SECRET = os.getenv("NOTION_SECRET", "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") |
|
|
NOTION_VERSION = "2025-09-03" |
|
|
NOTION_DRIVE_PROP_ID = os.getenv("NOTION_DRIVE_PROP_ID", "EFoh") |
|
|
NOTION_TARGET_DATABASE_ID = os.getenv( |
|
|
"NOTION_TARGET_DATABASE_ID", |
|
|
"8ad05d66-3b83-4102-ac37-1e3e35dd10a7" |
|
|
) |
|
|
|
|
|
|
|
|
GOOGLE_CREDENTIALS = os.getenv("GOOGLE_CREDENTIALS") |
|
|
PARENT_FOLDER_ID = os.getenv( |
|
|
"PARENT_FOLDER_ID", |
|
|
"1RUpaOeDzxOiatGvFd1dvKebVdKOZv0VT" |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
def build_content_template_children(): |
|
|
"""نفس التمبلت بالملّي لكن كـ children فقط.""" |
|
|
return [ |
|
|
|
|
|
{ |
|
|
"object": "block", |
|
|
"type": "callout", |
|
|
"callout": { |
|
|
"rich_text": [ |
|
|
{ |
|
|
"type": "text", |
|
|
"text": {"content": "Assets link - روابط اللقطات الخام"}, |
|
|
"annotations": {"bold": True} |
|
|
}, |
|
|
{ |
|
|
"type": "text", |
|
|
"text": { |
|
|
"content": "\nهنا هنتم وضع روابط لقطات خام يمكن استخدامها في التصميم أو الفيديو" |
|
|
} |
|
|
} |
|
|
], |
|
|
"icon": {"emoji": "📁"}, |
|
|
"color": "brown_background" |
|
|
} |
|
|
}, |
|
|
|
|
|
|
|
|
{ |
|
|
"object": "block", |
|
|
"type": "paragraph", |
|
|
"paragraph": {"rich_text": []} |
|
|
}, |
|
|
|
|
|
|
|
|
{ |
|
|
"object": "block", |
|
|
"type": "callout", |
|
|
"callout": { |
|
|
"rich_text": [ |
|
|
{ |
|
|
"type": "text", |
|
|
"text": {"content": "Media Buyer"}, |
|
|
"annotations": {"bold": True} |
|
|
}, |
|
|
{ |
|
|
"type": "text", |
|
|
"text": { |
|
|
"content": "\nهنا الجزء الخاص بالميديا باير (يتضمن طريقة تسمية الإعلان - الكانڤسر الخاصة بكل منصة - نسخ الكوبي المختلفة للإعلان)" |
|
|
} |
|
|
} |
|
|
], |
|
|
"icon": {"emoji": "🎯"}, |
|
|
"color": "brown_background" |
|
|
} |
|
|
}, |
|
|
|
|
|
|
|
|
{ |
|
|
"object": "block", |
|
|
"type": "callout", |
|
|
"callout": { |
|
|
"rich_text": [ |
|
|
{ |
|
|
"type": "text", |
|
|
"text": { |
|
|
"content": "Net Name = Name-(CreatorName/Vo)-(Offer)" |
|
|
}, |
|
|
"annotations": { |
|
|
"code": True, |
|
|
"bold": True |
|
|
} |
|
|
} |
|
|
], |
|
|
"icon": {"emoji": "🔤"}, |
|
|
"color": "yellow_background" |
|
|
} |
|
|
}, |
|
|
|
|
|
|
|
|
{ |
|
|
"object": "block", |
|
|
"type": "paragraph", |
|
|
"paragraph": {"rich_text": []} |
|
|
}, |
|
|
|
|
|
|
|
|
{ |
|
|
"object": "block", |
|
|
"type": "callout", |
|
|
"callout": { |
|
|
"rich_text": [ |
|
|
{ |
|
|
"type": "text", |
|
|
"text": {"content": "Ad Captions"}, |
|
|
"annotations": {"bold": True} |
|
|
}, |
|
|
{ |
|
|
"type": "text", |
|
|
"text": { |
|
|
"content": "\nهنا كابشنز المنصات اللي هيستخدمها الميديا باير أثناء إطلاق الإعلان" |
|
|
} |
|
|
} |
|
|
], |
|
|
"icon": {"emoji": "✍️"}, |
|
|
"color": "purple_background" |
|
|
} |
|
|
}, |
|
|
|
|
|
|
|
|
{ |
|
|
"object": "block", |
|
|
"type": "paragraph", |
|
|
"paragraph": {"rich_text": []} |
|
|
}, |
|
|
|
|
|
|
|
|
{ |
|
|
"object": "block", |
|
|
"type": "heading_3", |
|
|
"heading_3": { |
|
|
"rich_text": [ |
|
|
{"type": "text", "text": {"content": "SNAPCHAT"}} |
|
|
], |
|
|
"is_toggleable": True |
|
|
} |
|
|
}, |
|
|
{ |
|
|
"object": "block", |
|
|
"type": "heading_3", |
|
|
"heading_3": { |
|
|
"rich_text": [ |
|
|
{"type": "text", "text": {"content": "INSTAGRAM"}} |
|
|
], |
|
|
"is_toggleable": True |
|
|
} |
|
|
}, |
|
|
{ |
|
|
"object": "block", |
|
|
"type": "heading_3", |
|
|
"heading_3": { |
|
|
"rich_text": [ |
|
|
{"type": "text", "text": {"content": "TIKTOK"}} |
|
|
], |
|
|
"is_toggleable": True |
|
|
} |
|
|
}, |
|
|
{ |
|
|
"object": "block", |
|
|
"type": "heading_3", |
|
|
"heading_3": { |
|
|
"rich_text": [ |
|
|
{"type": "text", "text": {"content": "Google"}} |
|
|
], |
|
|
"is_toggleable": True |
|
|
} |
|
|
}, |
|
|
|
|
|
|
|
|
{ |
|
|
"object": "block", |
|
|
"type": "paragraph", |
|
|
"paragraph": {"rich_text": []} |
|
|
}, |
|
|
|
|
|
|
|
|
{ |
|
|
"object": "block", |
|
|
"type": "callout", |
|
|
"callout": { |
|
|
"rich_text": [ |
|
|
{ |
|
|
"type": "text", |
|
|
"text": {"content": "Copy Variations"}, |
|
|
"annotations": {"bold": True} |
|
|
}, |
|
|
{ |
|
|
"type": "text", |
|
|
"text": { |
|
|
"content": "\nهنا نسخ الكوبي اللي هنتم وضعها أثناء إطلاق الإعلان" |
|
|
} |
|
|
} |
|
|
], |
|
|
"icon": {"emoji": "📄"}, |
|
|
"color": "gray_background" |
|
|
} |
|
|
}, |
|
|
|
|
|
|
|
|
{ |
|
|
"object": "block", |
|
|
"type": "paragraph", |
|
|
"paragraph": {"rich_text": []} |
|
|
}, |
|
|
|
|
|
|
|
|
{ |
|
|
"object": "block", |
|
|
"type": "table", |
|
|
"table": { |
|
|
"table_width": 2, |
|
|
"has_column_header": True, |
|
|
"has_row_header": False, |
|
|
"children": [ |
|
|
{ |
|
|
"object": "block", |
|
|
"type": "table_row", |
|
|
"table_row": { |
|
|
"cells": [ |
|
|
[ |
|
|
{ |
|
|
"type": "text", |
|
|
"text": {"content": "Version"} |
|
|
} |
|
|
], |
|
|
[ |
|
|
{ |
|
|
"type": "text", |
|
|
"text": {"content": "Copy"} |
|
|
} |
|
|
] |
|
|
] |
|
|
} |
|
|
}, |
|
|
{ |
|
|
"object": "block", |
|
|
"type": "table_row", |
|
|
"table_row": { |
|
|
"cells": [ |
|
|
[ |
|
|
{ |
|
|
"type": "text", |
|
|
"text": {"content": "a"} |
|
|
} |
|
|
], |
|
|
[ |
|
|
{ |
|
|
"type": "text", |
|
|
"text": {"content": ""} |
|
|
} |
|
|
] |
|
|
] |
|
|
} |
|
|
}, |
|
|
{ |
|
|
"object": "block", |
|
|
"type": "table_row", |
|
|
"table_row": { |
|
|
"cells": [ |
|
|
[ |
|
|
{ |
|
|
"type": "text", |
|
|
"text": {"content": "b"} |
|
|
} |
|
|
], |
|
|
[ |
|
|
{ |
|
|
"type": "text", |
|
|
"text": {"content": ""} |
|
|
} |
|
|
] |
|
|
] |
|
|
} |
|
|
} |
|
|
] |
|
|
} |
|
|
}, |
|
|
|
|
|
|
|
|
{ |
|
|
"object": "block", |
|
|
"type": "paragraph", |
|
|
"paragraph": {"rich_text": []} |
|
|
}, |
|
|
|
|
|
|
|
|
{ |
|
|
"object": "block", |
|
|
"type": "callout", |
|
|
"callout": { |
|
|
"rich_text": [ |
|
|
{ |
|
|
"type": "text", |
|
|
"text": {"content": "NOTES (References if needed)"}, |
|
|
"annotations": {"bold": True} |
|
|
}, |
|
|
{ |
|
|
"type": "text", |
|
|
"text": { |
|
|
"content": "\nهنا لو في نوتس مهم نشتغل عليها جميعًا سواء كاتب المحتوى، المصمم أو الميديا باير" |
|
|
} |
|
|
} |
|
|
], |
|
|
"icon": {"emoji": "📌"}, |
|
|
"color": "brown_background" |
|
|
} |
|
|
} |
|
|
] |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def get_page_title(page_id: str) -> str: |
|
|
"""Fetch page title from Notion.""" |
|
|
async with httpx.AsyncClient() as client: |
|
|
res = await client.get( |
|
|
f"https://api.notion.com/v1/pages/{page_id}", |
|
|
headers={ |
|
|
"Authorization": f"Bearer {NOTION_SECRET}", |
|
|
"Notion-Version": NOTION_VERSION, |
|
|
}, |
|
|
) |
|
|
|
|
|
data = res.json() |
|
|
|
|
|
props = data.get("properties", {}) |
|
|
for prop in props.values(): |
|
|
if prop.get("type") == "title": |
|
|
title_items = prop.get("title", []) |
|
|
if len(title_items) > 0: |
|
|
return title_items[0].get("plain_text", "") |
|
|
|
|
|
return "(No Title)" |
|
|
|
|
|
|
|
|
async def is_block_archived(page_id: str) -> bool: |
|
|
"""يرجع True لو البلوك/الصفحة archived.""" |
|
|
async with httpx.AsyncClient() as client: |
|
|
res = await client.get( |
|
|
f"https://api.notion.com/v1/blocks/{page_id}", |
|
|
headers={ |
|
|
"Authorization": f"Bearer {NOTION_SECRET}", |
|
|
"Notion-Version": NOTION_VERSION, |
|
|
}, |
|
|
) |
|
|
|
|
|
if res.status_code != 200: |
|
|
print(f"[ARCHIVE CHECK] Failed to fetch block {page_id}, status: {res.status_code}") |
|
|
try: |
|
|
print("[ARCHIVE CHECK] Response:", res.json()) |
|
|
except Exception: |
|
|
print("[ARCHIVE CHECK] Raw response:", res.text) |
|
|
return False |
|
|
|
|
|
data = res.json() |
|
|
archived = data.get("archived", False) |
|
|
print(f"[ARCHIVE CHECK] Block {page_id} archived = {archived}") |
|
|
return archived |
|
|
|
|
|
|
|
|
async def apply_template_to_page(page_id: str): |
|
|
"""Append التمبلت دي لجسم الصفحة (الـ body) باستخدام blocks API.""" |
|
|
children = build_content_template_children() |
|
|
|
|
|
print(f"[TEMPLATE] Applying template to page: {page_id}") |
|
|
|
|
|
async with httpx.AsyncClient() as client: |
|
|
res = await client.patch( |
|
|
f"https://api.notion.com/v1/blocks/{page_id}/children", |
|
|
headers={ |
|
|
"Authorization": f"Bearer {NOTION_SECRET}", |
|
|
"Notion-Version": NOTION_VERSION, |
|
|
"Content-Type": "application/json", |
|
|
}, |
|
|
json={"children": children}, |
|
|
) |
|
|
|
|
|
print(f"[TEMPLATE] Status: {res.status_code}") |
|
|
try: |
|
|
print("[TEMPLATE] Response:", res.json()) |
|
|
except Exception: |
|
|
print("[TEMPLATE] Raw Response:", res.text) |
|
|
|
|
|
|
|
|
async def update_page_drive_link(page_id: str, drive_link: str): |
|
|
"""يحدث حقل URL في الصفحة برابط Google Drive.""" |
|
|
print(f"[NOTION] Updating drive link for page {page_id} -> {drive_link}") |
|
|
|
|
|
async with httpx.AsyncClient() as client: |
|
|
res = await client.patch( |
|
|
f"https://api.notion.com/v1/pages/{page_id}", |
|
|
headers={ |
|
|
"Authorization": f"Bearer {NOTION_SECRET}", |
|
|
"Notion-Version": NOTION_VERSION, |
|
|
"Content-Type": "application/json", |
|
|
}, |
|
|
json={ |
|
|
"properties": { |
|
|
NOTION_DRIVE_PROP_ID: { |
|
|
"url": drive_link |
|
|
} |
|
|
} |
|
|
}, |
|
|
) |
|
|
|
|
|
print(f"[NOTION] Drive link update status: {res.status_code}") |
|
|
try: |
|
|
print("[NOTION] Drive link response:", res.json()) |
|
|
except Exception: |
|
|
print("[NOTION] Drive link raw response:", res.text) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_drive_service(): |
|
|
if not GOOGLE_CREDENTIALS: |
|
|
raise RuntimeError("GOOGLE_CREDENTIALS env var is missing") |
|
|
|
|
|
creds_info = json.loads(GOOGLE_CREDENTIALS) |
|
|
creds = service_account.Credentials.from_service_account_info( |
|
|
creds_info, |
|
|
scopes=["https://www.googleapis.com/auth/drive"] |
|
|
) |
|
|
service = build("drive", "v3", credentials=creds) |
|
|
return service |
|
|
|
|
|
|
|
|
def search_folder(name: str, parent_id: str, drive_service): |
|
|
query = ( |
|
|
f"name = '{name}' and " |
|
|
f"'{parent_id}' in parents and " |
|
|
"mimeType = 'application/vnd.google-apps.folder' and trashed = false" |
|
|
) |
|
|
results = drive_service.files().list( |
|
|
q=query, |
|
|
fields="files(id, name)" |
|
|
).execute() |
|
|
files = results.get("files", []) |
|
|
return files[0] if files else None |
|
|
|
|
|
|
|
|
def create_folder(name: str, parent_id: str, drive_service): |
|
|
metadata = { |
|
|
"name": name, |
|
|
"mimeType": "application/vnd.google-apps.folder", |
|
|
"parents": [parent_id], |
|
|
} |
|
|
folder = drive_service.files().create( |
|
|
body=metadata, |
|
|
fields="id, name" |
|
|
).execute() |
|
|
return folder |
|
|
|
|
|
|
|
|
def ensure_drive_folder_for_page(page_title: str) -> str: |
|
|
""" |
|
|
- جوه PARENT_FOLDER_ID |
|
|
- فولدر شهر (Dec 2025 مثلاً) |
|
|
- جوه فولدر باسم الـ page_title |
|
|
وترجع Google Drive folder link. |
|
|
""" |
|
|
drive_service = get_drive_service() |
|
|
|
|
|
|
|
|
safe_title = page_title.strip() if page_title and page_title.strip() else "Untitled Task" |
|
|
|
|
|
|
|
|
safe_title = safe_title.replace("/", "-") |
|
|
|
|
|
month_name = datetime.now().strftime("%b %Y") |
|
|
|
|
|
print(f"[DRIVE] Ensuring month folder '{month_name}' under parent {PARENT_FOLDER_ID}") |
|
|
month_folder = search_folder(month_name, PARENT_FOLDER_ID, drive_service) |
|
|
if not month_folder: |
|
|
month_folder = create_folder(month_name, PARENT_FOLDER_ID, drive_service) |
|
|
print(f"[DRIVE] Created month folder: {month_folder['id']} - {month_folder['name']}") |
|
|
else: |
|
|
print(f"[DRIVE] Found month folder: {month_folder['id']} - {month_folder['name']}") |
|
|
|
|
|
month_folder_id = month_folder["id"] |
|
|
|
|
|
print(f"[DRIVE] Ensuring page folder '{safe_title}' under month folder {month_folder_id}") |
|
|
page_folder = search_folder(safe_title, month_folder_id, drive_service) |
|
|
if not page_folder: |
|
|
page_folder = create_folder(safe_title, month_folder_id, drive_service) |
|
|
print(f"[DRIVE] Created page folder: {page_folder['id']} - {page_folder['name']}") |
|
|
else: |
|
|
print(f"[DRIVE] Found page folder: {page_folder['id']} - {page_folder['name']}") |
|
|
|
|
|
folder_id = page_folder["id"] |
|
|
drive_link = f"https://drive.google.com/drive/folders/{folder_id}" |
|
|
print(f"[DRIVE] Final folder link: {drive_link}") |
|
|
return drive_link |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/webhook") |
|
|
async def webhook(request: Request): |
|
|
body = await request.json() |
|
|
|
|
|
event_type = body.get("type") |
|
|
page_id = body.get("entity", {}).get("id") |
|
|
timestamp = body.get("timestamp") |
|
|
parent = body.get("data", {}).get("parent", {}) |
|
|
parent_db_id = None |
|
|
if parent.get("type") == "database": |
|
|
parent_db_id = parent.get("id") |
|
|
|
|
|
print(f"[DEBUG] Parent database id: {parent_db_id}") |
|
|
|
|
|
print("\n===== NEW WEBHOOK EVENT =====") |
|
|
print("Raw body:", body) |
|
|
|
|
|
if event_type == "page.created": |
|
|
|
|
|
if parent_db_id != NOTION_TARGET_DATABASE_ID: |
|
|
print( |
|
|
f"[SKIP] Page {page_id} is in database {parent_db_id}, " |
|
|
f"not target {NOTION_TARGET_DATABASE_ID}. Skipping template & drive." |
|
|
) |
|
|
print("================================\n") |
|
|
return {"status": "ignored"} |
|
|
|
|
|
title = await get_page_title(page_id) |
|
|
|
|
|
print(f"Event Type : {event_type}") |
|
|
print(f"Page Title : {title}") |
|
|
print(f"Page ID : {page_id}") |
|
|
print(f"Timestamp : {timestamp}") |
|
|
|
|
|
|
|
|
archived = await is_block_archived(page_id) |
|
|
if archived: |
|
|
print(f"[TEMPLATE] Page {page_id} is archived right after creation. Skipping template & drive.") |
|
|
else: |
|
|
|
|
|
try: |
|
|
await apply_template_to_page(page_id) |
|
|
except Exception as e: |
|
|
print("[ERROR] While applying template:", e) |
|
|
|
|
|
|
|
|
try: |
|
|
drive_link = ensure_drive_folder_for_page(title or page_id) |
|
|
await update_page_drive_link(page_id, drive_link) |
|
|
except Exception as e: |
|
|
print("[ERROR] While creating drive folder or updating Notion URL:", e) |
|
|
|
|
|
elif event_type == "page.deleted": |
|
|
print(f"Page deleted. ID: {page_id}, Timestamp: {timestamp}") |
|
|
|
|
|
else: |
|
|
print("Event received but ignored:", event_type) |
|
|
|
|
|
print("================================\n") |
|
|
|
|
|
return {"status": "ok"} |
|
|
|