Port NomadCNA city_details/content/routes seeding into PocketBase and surface full destination pages with related meetups and discussions. Co-authored-by: Cursor <cursoragent@cursor.com>
456 lines
13 KiB
Python
456 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""Bootstrap PocketBase collections for nomadweb (run on server after deploy)."""
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
from app.data import community_data # noqa: E402
|
|
from app.services.pb_client import PocketBaseError, pb # noqa: E402
|
|
|
|
|
|
def text(name: str, *, required: bool = False, max: int = 0) -> dict[str, Any]:
|
|
return {"name": name, "type": "text", "required": required, "max": max}
|
|
|
|
|
|
def number(name: str, *, only_int: bool = False) -> dict[str, Any]:
|
|
return {"name": name, "type": "number", "onlyInt": only_int}
|
|
|
|
|
|
def boolean(name: str) -> dict[str, Any]:
|
|
return {"name": name, "type": "bool"}
|
|
|
|
|
|
def json_field(name: str) -> dict[str, Any]:
|
|
return {"name": name, "type": "json"}
|
|
|
|
|
|
def date(name: str) -> dict[str, Any]:
|
|
return {"name": name, "type": "date"}
|
|
|
|
|
|
COLLECTIONS: dict[str, list[dict[str, Any]]] = {
|
|
"destinations": [
|
|
text("slug", required=True, max=80),
|
|
text("name", required=True, max=120),
|
|
text("country", max=80),
|
|
text("emoji", max=16),
|
|
text("region", max=80),
|
|
number("cost", only_int=True),
|
|
number("speed", only_int=True),
|
|
number("temperature", only_int=True),
|
|
number("rating"),
|
|
number("hue", only_int=True),
|
|
text("tag", max=80),
|
|
text("description", max=500),
|
|
text("nomads_count", max=40),
|
|
json_field("highlights"),
|
|
number("map_x"),
|
|
number("map_y"),
|
|
],
|
|
"discussions": [
|
|
text("title", required=True, max=255),
|
|
text("author", max=120),
|
|
text("authorUserId", max=80),
|
|
text("authorEmoji", max=16),
|
|
text("category", max=80),
|
|
json_field("tags"),
|
|
text("excerpt", max=1000),
|
|
number("replies", only_int=True),
|
|
number("views", only_int=True),
|
|
number("likes", only_int=True),
|
|
boolean("pinned"),
|
|
text("legacyId", max=120),
|
|
],
|
|
"discussion_replies": [
|
|
text("discussionId", required=True, max=80),
|
|
text("userId", max=80),
|
|
text("author", max=120),
|
|
text("authorEmoji", max=16),
|
|
text("body", required=True, max=2000),
|
|
],
|
|
"discussion_likes": [
|
|
text("discussionId", required=True, max=80),
|
|
text("userId", required=True, max=80),
|
|
],
|
|
"meetups": [
|
|
text("legacyId", max=120),
|
|
text("title", required=True, max=255),
|
|
text("city", max=120),
|
|
text("destinationSlug", max=80),
|
|
text("emoji", max=16),
|
|
date("date"),
|
|
text("time", max=20),
|
|
text("venue", max=255),
|
|
text("description", max=1000),
|
|
number("rsvpCount", only_int=True),
|
|
number("maxAttendees", only_int=True),
|
|
text("organizer", max=120),
|
|
text("organizerId", max=80),
|
|
text("mode", max=40),
|
|
text("accessLevel", max=40),
|
|
text("mirotalkRoom", max=160),
|
|
text("loungeChannel", max=120),
|
|
json_field("tags"),
|
|
boolean("isUpcoming"),
|
|
text("status", max=40),
|
|
],
|
|
"meetup_rsvps": [
|
|
text("meetupId", required=True, max=80),
|
|
text("userId", required=True, max=80),
|
|
text("status", max=40),
|
|
],
|
|
"gigs": [
|
|
text("legacyId", max=120),
|
|
text("title", required=True, max=255),
|
|
text("category", max=120),
|
|
number("budget", only_int=True),
|
|
text("location", max=120),
|
|
text("description", max=1000),
|
|
text("status", max=40),
|
|
text("posterId", max=80),
|
|
text("posterName", max=120),
|
|
],
|
|
"gig_applications": [
|
|
text("gigId", required=True, max=80),
|
|
text("userId", max=80),
|
|
text("applicant", max=255),
|
|
text("message", max=1000),
|
|
text("status", max=40),
|
|
],
|
|
"profiles": [
|
|
text("userId", max=80),
|
|
text("email", max=255),
|
|
text("name", max=120),
|
|
number("age", only_int=True),
|
|
text("location", max=120),
|
|
json_field("tags"),
|
|
text("bio", max=1000),
|
|
text("photo", max=500),
|
|
text("gender", max=40),
|
|
json_field("lookingFor"),
|
|
text("citySlug", max=80),
|
|
],
|
|
"swipes": [
|
|
text("userId", max=80),
|
|
text("profileId", max=80),
|
|
text("action", max=40),
|
|
text("intent", max=40),
|
|
text("swipeDate", max=20),
|
|
],
|
|
"match_connections": [
|
|
text("pairKey", max=160),
|
|
text("userAId", max=80),
|
|
text("userBId", max=80),
|
|
text("intent", max=40),
|
|
text("conversationId", max=80),
|
|
date("matchedAt"),
|
|
],
|
|
"conversations": [
|
|
text("pairKey", max=160),
|
|
text("userAId", max=80),
|
|
text("userBId", max=80),
|
|
text("intent", max=40),
|
|
text("lastMessagePreview", max=200),
|
|
date("lastMessageAt"),
|
|
json_field("readState"),
|
|
],
|
|
"messages": [
|
|
text("conversationId", required=True, max=80),
|
|
text("senderId", required=True, max=80),
|
|
text("body", required=True, max=2000),
|
|
],
|
|
"notifications": [
|
|
text("title", required=True, max=255),
|
|
text("body", max=2000),
|
|
text("category", max=80),
|
|
text("targetUserId", max=80),
|
|
text("actionUrl", max=500),
|
|
text("status", max=40),
|
|
boolean("read"),
|
|
boolean("archived"),
|
|
boolean("pinned"),
|
|
],
|
|
"nomad_accounts": [
|
|
text("email", required=True, max=255),
|
|
text("passwordHash", max=128),
|
|
text("name", max=120),
|
|
text("avatar", max=16),
|
|
text("legacyUserId", max=80),
|
|
json_field("favorites"),
|
|
json_field("plan"),
|
|
],
|
|
"nomad_sessions": [
|
|
text("token", required=True, max=128),
|
|
text("userId", required=True, max=80),
|
|
number("expiresAt", only_int=True),
|
|
],
|
|
"media_assets": [
|
|
text("userId", max=80),
|
|
text("url", required=True, max=500),
|
|
text("filename", max=255),
|
|
text("contentType", max=120),
|
|
number("size", only_int=True),
|
|
text("objectKey", max=500),
|
|
],
|
|
"subscriptions": [
|
|
text("email", required=True, max=255),
|
|
],
|
|
"visas": [text("country", max=80), text("type", max=80), number("difficulty", only_int=True)],
|
|
"faqs": [text("question", max=500), text("answer", max=2000), number("order", only_int=True)],
|
|
"blog_posts": [
|
|
text("slug", max=120),
|
|
text("title", max=255),
|
|
text("excerpt", max=500),
|
|
date("published_at"),
|
|
],
|
|
"tools": [text("name", max=120), text("url", max=500), text("description", max=500)],
|
|
"memberships": [
|
|
text("userId", required=True, max=80),
|
|
text("plan", max=40),
|
|
number("expiresAt", only_int=True),
|
|
],
|
|
"orders": [
|
|
text("orderId", required=True, max=120),
|
|
text("userId", max=120),
|
|
text("payType", max=60),
|
|
number("amount", only_int=True),
|
|
text("status", max=40),
|
|
],
|
|
"feedback": [
|
|
text("type", max=40),
|
|
text("email", max=255),
|
|
text("title", max=255),
|
|
text("content", max=2000),
|
|
text("status", max=40),
|
|
],
|
|
"service_leads": [
|
|
text("serviceSlug", required=True, max=120),
|
|
text("userId", max=80),
|
|
text("email", max=255),
|
|
text("note", max=1000),
|
|
text("status", max=40),
|
|
],
|
|
"content_submissions": [
|
|
text("title", required=True, max=255),
|
|
text("type", max=40),
|
|
text("mediaUrl", max=500),
|
|
text("submittedBy", max=80),
|
|
text("authorName", max=120),
|
|
text("description", max=2000),
|
|
text("reviewStatus", max=40),
|
|
],
|
|
"volunteer_applications": [
|
|
text("citySlug", required=True, max=120),
|
|
text("userId", max=80),
|
|
text("applicantName", max=140),
|
|
text("email", max=255),
|
|
text("motivation", max=2000),
|
|
text("status", max=40),
|
|
],
|
|
"reports": [
|
|
text("reporterId", max=80),
|
|
text("targetType", max=80),
|
|
text("targetId", max=120),
|
|
text("reason", max=2000),
|
|
text("status", max=40),
|
|
],
|
|
"notification_preferences": [
|
|
text("userId", required=True, max=80),
|
|
json_field("channels"),
|
|
],
|
|
"city_details": [
|
|
text("citySlug", required=True, max=120),
|
|
json_field("guide"),
|
|
json_field("pros"),
|
|
json_field("reviews"),
|
|
json_field("cost"),
|
|
json_field("people"),
|
|
json_field("chat"),
|
|
json_field("photos"),
|
|
json_field("weather"),
|
|
json_field("trends"),
|
|
json_field("demographics"),
|
|
json_field("relatedContent"),
|
|
],
|
|
"routes": [
|
|
text("slug", required=True, max=120),
|
|
text("title", required=True, max=255),
|
|
text("titleEn", max=255),
|
|
json_field("citySlugs"),
|
|
number("durationDays", only_int=True),
|
|
number("budget", only_int=True),
|
|
text("description", max=1000),
|
|
text("descriptionEn", max=1000),
|
|
json_field("stops"),
|
|
text("status", max=40),
|
|
text("emoji", max=16),
|
|
],
|
|
"content_items": [
|
|
text("slug", required=True, max=120),
|
|
text("type", required=True, max=40),
|
|
text("title", required=True, max=255),
|
|
text("titleEn", max=255),
|
|
text("subtitle", max=500),
|
|
text("description", max=2000),
|
|
text("coverImage", max=500),
|
|
text("mediaUrl", max=500),
|
|
text("targetUrl", max=500),
|
|
text("ctaLabel", max=120),
|
|
json_field("citySlugs"),
|
|
number("sortOrder", only_int=True),
|
|
text("status", max=40),
|
|
text("authorName", max=120),
|
|
],
|
|
"recommendation_logs": [
|
|
text("userId", max=80),
|
|
number("budget", only_int=True),
|
|
number("internet", only_int=True),
|
|
text("climate", max=80),
|
|
json_field("tags"),
|
|
json_field("resultSlugs"),
|
|
],
|
|
}
|
|
|
|
|
|
def collection_exists(name: str) -> bool:
|
|
try:
|
|
pb.request("GET", f"/api/collections/{name}", admin=True)
|
|
return True
|
|
except PocketBaseError:
|
|
return False
|
|
|
|
|
|
def ensure_collection(name: str, fields: list[dict[str, Any]]) -> None:
|
|
if collection_exists(name):
|
|
print(f"exists: {name}")
|
|
return
|
|
payload = {
|
|
"name": name,
|
|
"type": "base",
|
|
"listRule": "",
|
|
"viewRule": "",
|
|
"createRule": "",
|
|
"updateRule": "",
|
|
"deleteRule": "",
|
|
"fields": fields,
|
|
}
|
|
pb.request("POST", "/api/collections", admin=True, json=payload)
|
|
print(f"created: {name}")
|
|
|
|
|
|
def seed_if_empty(collection: str, records: list[dict[str, Any]]) -> None:
|
|
data = pb.list_records(collection, per_page=1)
|
|
if (data.get("totalItems") or 0) > 0:
|
|
print(f"seed skip: {collection}")
|
|
return
|
|
for record in records:
|
|
pb.create_record(collection, record)
|
|
print(f"seeded: {collection} ({len(records)})")
|
|
|
|
|
|
def meetup_payload(m: dict[str, Any]) -> dict[str, Any]:
|
|
date_val = m.get("date")
|
|
if date_val and "T" not in str(date_val):
|
|
date_val = f"{date_val} 00:00:00.000Z"
|
|
return {
|
|
"legacyId": m.get("id", ""),
|
|
"title": m.get("title", ""),
|
|
"city": m.get("city", ""),
|
|
"destinationSlug": m.get("destination_slug", ""),
|
|
"emoji": m.get("emoji", ""),
|
|
"date": date_val,
|
|
"time": m.get("time", ""),
|
|
"venue": m.get("venue", ""),
|
|
"description": m.get("description", ""),
|
|
"rsvpCount": m.get("rsvp_count", 0),
|
|
"maxAttendees": m.get("max_attendees", 30),
|
|
"organizer": m.get("organizer", ""),
|
|
"mode": m.get("mode", "offline"),
|
|
"accessLevel": m.get("access_level", "public"),
|
|
"mirotalkRoom": m.get("mirotalkRoom", ""),
|
|
"loungeChannel": m.get("loungeChannel", ""),
|
|
"tags": m.get("tags", []),
|
|
"isUpcoming": m.get("is_upcoming", True),
|
|
"status": "published",
|
|
}
|
|
|
|
|
|
def discussion_payload(d: dict[str, Any]) -> dict[str, Any]:
|
|
return {
|
|
"legacyId": d.get("id", ""),
|
|
"title": d.get("title", ""),
|
|
"author": d.get("author", ""),
|
|
"authorUserId": d.get("author_id", ""),
|
|
"authorEmoji": d.get("author_emoji", "🧑💻"),
|
|
"category": d.get("category", "社区"),
|
|
"tags": d.get("tags", []),
|
|
"excerpt": d.get("excerpt", ""),
|
|
"replies": d.get("reply_count", 0),
|
|
"views": d.get("view_count", 0),
|
|
"likes": d.get("like_count", 0),
|
|
"pinned": d.get("is_pinned", False),
|
|
}
|
|
|
|
|
|
def main() -> None:
|
|
if not pb.health_ok():
|
|
print("PocketBase not reachable")
|
|
sys.exit(1)
|
|
pb.admin_token()
|
|
for name, fields in COLLECTIONS.items():
|
|
ensure_collection(name, fields)
|
|
|
|
seed_if_empty("meetups", [meetup_payload(m) for m in community_data.MEETUPS])
|
|
seed_if_empty("discussions", [discussion_payload(d) for d in community_data.DISCUSSIONS])
|
|
gigs = getattr(community_data, "GIGS", [])
|
|
if gigs:
|
|
seed_if_empty(
|
|
"gigs",
|
|
[
|
|
{
|
|
"legacyId": g.get("id", ""),
|
|
"title": g.get("title", ""),
|
|
"category": g.get("category", ""),
|
|
"budget": g.get("budget", 0) if isinstance(g.get("budget"), int) else 0,
|
|
"location": g.get("location", ""),
|
|
"description": g.get("description", ""),
|
|
"status": g.get("status", "open"),
|
|
}
|
|
for g in gigs
|
|
],
|
|
)
|
|
|
|
from app.data.city_details_data import CITY_DETAILS, CONTENT_ITEMS, EXTRA_ROUTES
|
|
|
|
seed_if_empty("city_details", CITY_DETAILS)
|
|
seed_if_empty(
|
|
"routes",
|
|
[
|
|
{
|
|
"slug": r["slug"],
|
|
"title": r["title"],
|
|
"titleEn": r.get("titleEn", ""),
|
|
"citySlugs": r.get("citySlugs") or [],
|
|
"durationDays": r.get("durationDays", 0),
|
|
"budget": r.get("budget", 0),
|
|
"description": r.get("description", ""),
|
|
"descriptionEn": r.get("descriptionEn", ""),
|
|
"stops": r.get("stops") or [],
|
|
"status": r.get("status", "published"),
|
|
"emoji": r.get("emoji", "🗺️"),
|
|
}
|
|
for r in EXTRA_ROUTES
|
|
],
|
|
)
|
|
seed_if_empty("content_items", CONTENT_ITEMS)
|
|
print("done")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|