from __future__ import annotations import asyncio from typing import Any from .pb_client import PocketBaseError, pb from .seed_data import ( CITIES, CITY_DETAILS, CONTENT_ITEMS, DISCUSSIONS, GIGS, MEETUPS, PROFILES, ) from .enhanced_seed import ENHANCED_DISCUSSIONS, ENHANCED_GIGS, ENHANCED_MEETUPS, ENHANCED_PROFILES, ROUTES, SERVICES NOTIFICATION_SEEDS: list[dict[str, Any]] = [ { "title": "欢迎来到游牧中国消息中心", "body": "这里会集中显示活动提醒、赏金任务、服务咨询、匹配动态、城市更新和系统公告。你可以筛选、已读、归档,也可以从消息直接跳转到对应功能。", "category": "system", "priority": "high", "targetUserId": "", "audience": "all", "actionLabel": "查看仪表盘", "actionUrl": "/dashboard", "entityType": "onboarding", "entityId": "message-center", "icon": "🔔", "status": "published", "channels": ["in_app"], "metadata": {"source": "seed", "tone": "welcome"}, }, { "title": "本周同城活动已更新", "body": "大理、成都、深圳等城市新增了线下聚会,你可以在活动页查看时间、地点和报名人数。", "category": "meetup", "priority": "normal", "targetUserId": "", "audience": "all", "actionLabel": "查看活动", "actionUrl": "/meetups", "entityType": "meetup", "entityId": "weekly", "icon": "🍹", "status": "published", "channels": ["in_app"], "metadata": {"source": "seed"}, }, { "title": "赏金墙有新任务可接", "body": "城市资料补充、共享办公调研、活动摄影等任务已经开放,接单后会生成站内通知记录。", "category": "gig", "priority": "normal", "targetUserId": "", "audience": "all", "actionLabel": "去接任务", "actionUrl": "/gigs", "entityType": "gig", "entityId": "open-board", "icon": "💰", "status": "published", "channels": ["in_app"], "metadata": {"source": "seed"}, }, ] 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, *, required: bool = False, only_int: bool = False) -> dict[str, Any]: return {"name": name, "type": "number", "required": required, "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]]] = { "cities": [ text("slug", required=True, max=80), number("idNumber", only_int=True), text("name", required=True, max=120), text("nameEn", max=120), text("country", max=80), text("countryEmoji", max=16), number("overallScore"), number("costPerMonth", only_int=True), number("internetSpeed", only_int=True), text("safety", max=40), text("liked", max=40), number("temperature", only_int=True), number("humidity", only_int=True), number("nomadsNow", only_int=True), text("description", max=500), text("descriptionEn", max=500), text("gradientFrom", max=32), text("gradientTo", max=32), text("icon", max=16), json_field("tags"), text("coverImage", max=500), number("reviewsCount", only_int=True), number("likedCount", only_int=True), number("dislikedCount", only_int=True), text("qualityOfLife", max=40), text("familyScore", max=40), text("communityScore", max=40), number("nomadPercent"), text("funLabel", max=40), number("feelsLikeTemp", only_int=True), number("acPercent", only_int=True), number("lat"), number("lng"), text("region", max=80), ], "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), ], "member_applications": [ text("userId", max=80), text("email", max=255), text("nickname", max=140), text("gender", max=40), text("single", max=40), text("profession", max=140), text("intro", max=1000), text("wechat", max=140), text("education", max=80), text("graduationYear", max=20), text("phone", max=40), text("media", max=500), boolean("isComplete"), ], "memberships": [ text("userId", required=True, max=80), text("email", max=255), text("plan", max=40), text("status", max=40), date("expiresAt"), ], "site_vip": [ text("user_id", required=True, max=80), text("site_id", required=True, max=80), text("order_id", max=120), date("expires_at"), ], "orders": [ text("orderId", required=True, max=80), text("userId", max=120), text("email", max=255), number("totalFee", only_int=True), text("type", max=60), text("channel", max=40), text("status", max=40), text("returnUrl", max=500), boolean("isNew"), ], "payments": [ text("user_id", max=120), text("type", max=60), text("order_id", required=True, max=120), number("amount", only_int=True), number("total_fee", only_int=True), text("channel", max=40), ], "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), ], "meetups": [ text("city", required=True, max=120), text("emoji", max=16), date("date"), text("time", max=20), text("venue", max=255), text("address", max=255), text("description", max=1000), number("rsvpCount", only_int=True), text("gradientFrom", max=32), text("gradientTo", max=32), json_field("attendees"), boolean("isUpcoming"), text("status", max=40), ], "discussions": [ text("title", required=True, max=255), text("author", max=120), text("city", max=120), json_field("tags"), text("excerpt", max=1000), number("replies", only_int=True), number("views", only_int=True), ], "gigs": [ 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), ], "gig_applications": [ text("gigId", required=True, max=80), text("userId", max=80), text("applicant", max=255), text("message", max=1000), text("status", max=40), ], "services": [ text("slug", required=True, max=120), text("title", required=True, max=255), text("titleEn", max=255), text("icon", max=16), text("description", max=1000), text("descriptionEn", max=1000), json_field("features"), number("price", only_int=True), text("priceLabel", max=120), text("category", max=80), text("status", max=40), text("gradientFrom", max=32), text("gradientTo", max=32), ], "service_leads": [ text("serviceSlug", required=True, max=120), text("userId", max=80), text("email", max=255), text("note", max=1000), text("status", max=40), ], "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), ], "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"), ], "notifications": [ text("title", required=True, max=255), text("body", max=2000), text("category", max=80), text("priority", max=40), text("targetUserId", max=80), text("audience", max=40), text("actionLabel", max=120), text("actionUrl", max=500), text("entityType", max=80), text("entityId", max=120), text("icon", max=16), text("status", max=40), json_field("channels"), json_field("metadata"), date("expiresAt"), ], "notification_receipts": [ text("notificationId", required=True, max=80), text("userId", required=True, max=80), date("readAt"), date("archivedAt"), date("dismissedAt"), boolean("pinned"), ], "notification_preferences": [ text("userId", required=True, max=80), text("email", max=255), json_field("channels"), json_field("categories"), text("quietHoursStart", max=10), text("quietHoursEnd", max=10), text("digest", max=40), boolean("allowMarketing"), ], "feedback": [ text("type", max=40), text("email", max=255), text("title", max=255), text("content", max=2000), text("status", max=40), ], "swipes": [text("userId", max=80), text("profileId", max=80), text("action", max=40)], "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("subtitleEn", max=500), text("description", max=2000), text("descriptionEn", max=2000), text("coverImage", max=500), text("mediaUrl", max=500), text("targetUrl", max=500), text("ctaLabel", max=120), text("ctaLabelEn", max=120), json_field("citySlugs"), json_field("relatedProfiles"), json_field("chapters"), number("durationMinutes", only_int=True), number("sortOrder", only_int=True), text("status", max=40), text("authorName", max=120), text("authorEmail", max=255), text("submittedBy", max=80), text("reviewStatus", max=40), text("reviewNote", max=1000), date("reviewedAt"), json_field("submissionMeta"), ], "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"), ], } async def collection_exists(name: str) -> bool: try: await pb.request("GET", f"/api/collections/{name}", admin=True) return True except PocketBaseError: return False async def ensure_collection_fields(name: str, fields: list[dict[str, Any]]) -> None: collection = await pb.request("GET", f"/api/collections/{name}", admin=True) field_key = "fields" if "fields" in collection else "schema" current_fields = collection.get(field_key) or [] current_names = {field.get("name") for field in current_fields} missing_fields = [field for field in fields if field.get("name") not in current_names] if not missing_fields: return await pb.request( "PATCH", f"/api/collections/{collection.get('id') or name}", admin=True, json={field_key: [*current_fields, *missing_fields]}, ) print(f"collection updated: {name} +{len(missing_fields)} fields") async def create_collection(name: str, fields: list[dict[str, Any]]) -> None: if await collection_exists(name): await ensure_collection_fields(name, fields) print(f"collection exists: {name}") return payload = { "name": name, "type": "base", "listRule": "", "viewRule": "", "createRule": "", "updateRule": "", "deleteRule": "", "fields": fields, } await pb.request("POST", "/api/collections", admin=True, json=payload) print(f"collection created: {name}") async def seed_if_empty(collection: str, records: list[dict[str, Any]]) -> None: data = await pb.list_records(collection, per_page=1) if data.get("totalItems", 0) > 0: print(f"seed skipped: {collection}") return for record in records: payload = dict(record) if collection == "meetups" and payload.get("date"): payload["date"] = f"{payload['date']} 00:00:00.000Z" payload.setdefault("status", "published") await pb.create_record(collection, payload) print(f"seeded {collection}: {len(records)} records") async def upsert_seed(collection: str, key: str, records: list[dict[str, Any]]) -> None: count = 0 for record in records: payload = dict(record) if collection == "meetups" and payload.get("date") and "T" not in str(payload["date"]): payload["date"] = f"{payload['date']} 00:00:00.000Z" payload.setdefault("status", "published") await pb.upsert_by_field(collection, key, str(payload[key]), payload) count += 1 print(f"upserted {collection}: {count} records") async def main() -> None: for name, fields in COLLECTIONS.items(): await create_collection(name, fields) await upsert_seed("cities", "slug", CITIES) await seed_if_empty("meetups", MEETUPS) await seed_if_empty("discussions", DISCUSSIONS) await seed_if_empty("gigs", GIGS) await seed_if_empty("profiles", PROFILES) await upsert_seed("meetups", "venue", ENHANCED_MEETUPS) await upsert_seed("discussions", "title", ENHANCED_DISCUSSIONS) await upsert_seed("gigs", "title", ENHANCED_GIGS) await upsert_seed("profiles", "name", ENHANCED_PROFILES) await upsert_seed("content_items", "slug", CONTENT_ITEMS) await upsert_seed("city_details", "citySlug", CITY_DETAILS) await upsert_seed("services", "slug", SERVICES) await upsert_seed("routes", "slug", ROUTES) await upsert_seed("notifications", "title", NOTIFICATION_SEEDS) if __name__ == "__main__": asyncio.run(main())