Some checks are pending
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2019-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2022-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2025-latest) (push) Blocked by required conditions
CI / Build og unit tests (push) Successful in 1m0s
deploy community / deploy (push) Successful in 0s
deploy hub / deploy (push) Successful in 23s
CI / MSI-installer (push) Successful in 36s
The forum is shared by SEOSoft's products, so SQLInsight Pro becomes the
top-level category "sqlinsight" with the existing categories beneath it.
- render.py/install.sh: default hostname forum.seosoft.dk
- configure.py: forum-wide settings ("SEOSoft Forum") kept apart from the
SQLInsight Pro category tree; subcategories found via /site.json; the
welcome topic is pinned in the category, not on the shared front page
- Community site: ForumCategory option. The front page lists only that
category's topics (c/<slug>/l/latest.json); forum links go to the
category, topic links stay at the forum root
- Web: default ForumUrl https://forum.seosoft.dk/c/sqlinsight. The old
forum.sqlinsight.seosoft.dk stays as a redirect for installed versions
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
173 lines
8.5 KiB
Python
Executable file
173 lines
8.5 KiB
Python
Executable file
#!/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: # Discourse rate limit: wait as told, then try again
|
|
time.sleep(json.loads(body).get("extras", {}).get("wait_seconds", 10) + 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()
|