#!/usr/bin/env python3 """Sets up SEOSoft's shared forum and SQLInsight Pro's category in it through the Discourse API. Safe to run again: it only changes what differs from the wanted state. DISCOURSE_URL=https://forum.seosoft.dk DISCOURSE_API_KEY=... python3 deploy/forum/configure.py Create the key inside the container: ./launcher enter app then cd /var/www/discourse && su discourse -c 'RAILS_ENV=production bundle exec rake api_key:create_master[forum-setup]' Optional: COMMUNITY_URL (SQLInsight Pro's site, linked from its category), CONTACT_EMAIL. The forum is shared by SEOSoft's products. SQLInsight Pro is one top-level category ("sqlinsight") with its own subcategories; other products get their own top-level categories and are not touched here. """ import json import os import sys import time import urllib.error import urllib.parse import urllib.request BASE = os.environ.get("DISCOURSE_URL", "").rstrip("/") KEY = os.environ.get("DISCOURSE_API_KEY", "") COMMUNITY = os.environ.get("COMMUNITY_URL", "https://sqlinsight.seosoft.dk").rstrip("/") CONTACT = os.environ.get("CONTACT_EMAIL", "") # The whole forum (shared by all products). SETTINGS = { "title": "SEOSoft Forum", "site_description": "Spørgsmål, svar og erfaringer om SEOSofts produkter.", "short_site_description": "Community for SEOSofts produkter", "company_name": "SEOSoft", "default_locale": "da", "login_required": "false", "solved_enabled": "true", "tagging_enabled": "true", "top_menu": "categories|latest|new|top", } if CONTACT: SETTINGS["contact_email"] = CONTACT # SQLInsight Pro's top-level category: name, slug, colour, description PRODUCT = ("SQLInsight Pro", "sqlinsight", "0E7490", f"Gratis SQL Server-overvågning med dyb ekspertise. Dokumentation og download: {COMMUNITY}") # Its subcategories: name, slug, colour, description, accepted answers (solved), only staff may start topics CATEGORIES = [ ("Annonceringer", "annonceringer", "0E7490", "Nye versioner, regelpakker og vigtige SQL Server-opdateringer. Kun SEOSoft opretter emner her; alle kan svare.", False, True), ("Spørgsmål og svar", "spoergsmaal", "155E75", "Spørg om fund, anbefalinger og SQL Server generelt. Markér det svar, der løste det.", True, False), ("Installation og drift", "installation", "1570EF", "MSI, rettigheder, repository-databasen, opdateringer, Linux og Managed Instance.", True, False), ("Performance og tuning", "performance", "E04F16", "Wait stats, Query Store, planer, indexes og blocking. Del gerne målingerne fra fundet.", True, False), ("Regler", "regler", "079455", "Forslag til nye regler og community-regler i SQL (se dokumentationen om egne regler).", False, False), ("Idéer og feedback", "ideer", "B54708", "Hvad mangler SQLInsight Pro? Fejl oprettes som issues på Forgejo.", False, False), ] WELCOME_TITLE = "Velkommen til SQLInsight Pro på SEOSoft Forum" WELCOME = f"""SQLInsight Pro er gratis SQL Server-overvågning med dyb ekspertise: hvert fund har en forklaring, målingerne bag og en konkret anbefaling. **Her kan du** - stille spørgsmål om installation, fund og tuning i [Spørgsmål og svar](/c/sqlinsight/spoergsmaal) - dele erfaringer og foreslå nye [regler](/c/sqlinsight/regler) - følge nye versioner og regelpakker i [Annonceringer](/c/sqlinsight/annonceringer) **Gode råd til et hurtigt svar** - Skriv SQL Server-version og CU (står på instansens side i SQLInsight Pro). - Kopiér fundets titel, målinger og regel-id (fx `SQLI-DB-007`). - Fjern servernavne, logins og forespørgselstekst, du ikke vil dele offentligt. - Markér det svar, der løste problemet, så andre kan finde det. Dokumentation og download: [{COMMUNITY.removeprefix('https://')}]({COMMUNITY}) · Betalt support med svartid: [{COMMUNITY.removeprefix('https://')}/support]({COMMUNITY}/support) """ def call(method, path, data=None): body = urllib.parse.urlencode(data, doseq=True).encode() if data is not None else None request = urllib.request.Request(BASE + path, data=body, method=method, headers={ "Api-Key": KEY, "Api-Username": "system", "Accept": "application/json", }) for _ in range(10): try: with urllib.request.urlopen(request, timeout=30) as response: text = response.read().decode() return json.loads(text) if text else {} except urllib.error.HTTPError as error: body = error.read().decode() if error.code == 429: # rate limit: wait as told, then try again # Rails answers with JSON; the nginx limiter in front (web.ratelimited) with an HTML page. try: wait = json.loads(body).get("extras", {}).get("wait_seconds", 10) except ValueError: wait = int(error.headers.get("Retry-After") or 10) time.sleep(wait + 1) continue sys.exit(f"{method} {path}: {error.code} {body[:300]}") sys.exit(f"{method} {path}: stadig begrænset efter flere forsøg") def all_categories(): """Every category including subcategories (/categories.json leaves subcategories out).""" return call("GET", "/site.json")["categories"] def find(slug, parent_id): return next((c for c in all_categories() if c["slug"] == slug and c.get("parent_category_id") == parent_id), None) def ensure_category(name, slug, colour, description, parent_id=None, solved=False, staff_only=False): fields = { "name": name, "slug": slug, "color": colour, "text_color": "FFFFFF", "custom_fields[enable_accepted_answers]": "true" if solved else "false", } if parent_id is not None: fields["parent_category_id"] = str(parent_id) if staff_only: fields.update({"permissions[everyone]": "2", "permissions[staff]": "1"}) # everyone: reply; staff: create existing = find(slug, parent_id) if existing: call("PUT", f"/categories/{existing['id']}.json", fields) category_id = existing["id"] print(f"kategori {name} (opdateret)") else: category_id = call("POST", "/categories.json", fields)["category"]["id"] print(f"kategori {name} (oprettet)") # The description is the first post of the category's "About" topic. about = call("GET", f"/c/{category_id}/show.json")["category"].get("topic_url") if about: post = call("GET", about + ".json")["post_stream"]["posts"][0] if post.get("raw") != description and description not in (post.get("cooked") or ""): call("PUT", f"/posts/{post['id']}.json", {"post[raw]": description}) return category_id def main(): if not BASE or not KEY: sys.exit(__doc__) current = {s["setting"]: s.get("value") for s in call("GET", "/admin/site_settings.json")["site_settings"]} for name, value in SETTINGS.items(): if name not in current: print(f" (indstillingen {name} findes ikke i denne Discourse-version)") elif str(current[name]) != value: call("PUT", f"/admin/site_settings/{name}.json", {name: value}) print(f"indstilling {name} = {value}") name, slug, colour, description = PRODUCT product_id = ensure_category(name, slug, colour, description) ids = {} for name, slug, colour, description, solved, staff_only in CATEGORIES: ids[slug] = ensure_category(name, slug, colour, description, product_id, solved, staff_only) announcements = call("GET", f"/c/{ids['annonceringer']}/l/latest.json")["topic_list"]["topics"] if not any(t["title"] == WELCOME_TITLE for t in announcements): topic = call("POST", "/posts.json", {"title": WELCOME_TITLE, "raw": WELCOME, "category": ids["annonceringer"]}) # Pinned in its category only: the forum's front page belongs to all products. call("PUT", f"/t/{topic['topic_id']}/status.json", {"status": "pinned", "enabled": "true"}) print("emne velkomst (oprettet og fastgjort i SQLInsight Pro)") # Discourse's own English welcome topic: no longer pinned or listed. for topic in call("GET", "/latest.json")["topic_list"]["topics"]: if topic.get("pinned_globally") and topic["title"].startswith("Welcome to"): call("PUT", f"/t/{topic['id']}/status.json", {"status": "pinned_globally", "enabled": "false"}) call("PUT", f"/t/{topic['id']}/status.json", {"status": "visible", "enabled": "false"}) print("emne Discourses engelske velkomst skjult") feedback = find("site-feedback", None) if feedback and feedback["name"] != "Om forummet": call("PUT", f"/categories/{feedback['id']}.json", {"name": "Om forummet", "slug": "site-feedback", "color": feedback["color"]}) print("kategori Site Feedback omdøbt til Om forummet") print("Forummet er sat op.") if __name__ == "__main__": main()