feat: v3.4.0 — OpenPanel plugin public release (42 tools)

OpenPanel product analytics plugin fully reviewed, tested, and published.
Works with both self-hosted and cloud (openpanel.dev) instances.

- 42 tools: event tracking, data export, analytics, project/client management
- All tools use public REST APIs (Track, Export, Insights, Manage)
- Client modes: write (tracking), read (analytics), root (full access)
- Service page with description, setup notes, WordPress plugin download
- Dynamic URL hints in Add Site form
- 62 unit tests
- ENABLED_PLUGINS default now includes openpanel

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-31 17:48:12 +02:00
parent 68c84b3ece
commit 9905a28eb0
33 changed files with 1672 additions and 3207 deletions

View File

@@ -10,12 +10,12 @@ import json
import logging
import os
import secrets
import bcrypt
from dataclasses import asdict, dataclass
from datetime import datetime, timedelta
from pathlib import Path
import bcrypt
logger = logging.getLogger(__name__)

View File

@@ -850,12 +850,9 @@ async def get_all_projects(
is_admin = False
current_user_id = None
if user_session:
if (
hasattr(user_session, "user_type")
and user_session.user_type == "master"
or isinstance(user_session, dict)
and user_session.get("role") == "admin"
):
if hasattr(user_session, "user_type") and user_session.user_type == "master":
is_admin = True
elif isinstance(user_session, dict) and user_session.get("role") == "admin":
is_admin = True
elif isinstance(user_session, dict) and "user_id" in user_session:
current_user_id = user_session["user_id"]
@@ -3292,6 +3289,46 @@ async def get_service_page_data(plugin_type: str) -> dict | None:
from core.plugin_visibility import is_plugin_public
# Per-plugin descriptions and notes
service_descriptions = {
"openpanel": {
"en": (
"OpenPanel product analytics management. "
"Supports event tracking, data export, analytics reports, "
"and project/client management via public REST APIs. "
"Works with both self-hosted and cloud (openpanel.dev) instances."
),
"fa": (
"مدیریت آنالیتیکس محصول OpenPanel. "
"پشتیبانی از ردیابی رویداد، خروجی داده، گزارش‌های آنالیتیکس، "
"و مدیریت پروژه/کلاینت از طریق APIهای عمومی REST. "
"هم نسخه خودمیزبان و هم نسخه cloud (openpanel.dev) پشتیبانی می‌شود."
),
"notes_en": [
"<strong>Site URL must be the API URL</strong>, not the dashboard URL. "
"Cloud: <code>https://api.openpanel.dev</code> — "
"Self-hosted: your API service URL (e.g., <code>https://analytics.example.com</code>).",
"Client must have <strong>root</strong> mode for full access (tracking + export + manage). "
"Use <strong>read</strong> mode for analytics only, or <strong>write</strong> mode for tracking only.",
"If you have WordPress, install the <a href='/static/plugins/openpanel-self-hosted.zip' "
"class='text-primary-400 hover:underline'>OpenPanel WordPress plugin</a> "
"to automatically send analytics data to your OpenPanel instance.",
],
"notes_fa": [
"<strong>آدرس سایت باید آدرس API باشد</strong>، نه داشبورد. "
"نسخه Cloud: <code>https://api.openpanel.dev</code> — "
"خودمیزبان: آدرس سرویس API شما (مثلاً <code>https://analytics.example.com</code>).",
"برای دسترسی کامل، کلاینت باید حالت <strong>root</strong> داشته باشد. "
"از حالت <strong>read</strong> برای آنالیتیکس و <strong>write</strong> برای ردیابی استفاده کنید.",
"اگر وردپرس دارید، افزونه <a href='/static/plugins/openpanel-self-hosted.zip' "
"class='text-primary-400 hover:underline'>OpenPanel WordPress</a> "
"را نصب کنید تا داده‌های آنالیتیکس به‌صورت خودکار به OpenPanel ارسال شود.",
],
},
}
desc_data = service_descriptions.get(plugin_type, {})
return {
"plugin_type": plugin_type,
"display_name": display_name,
@@ -3299,6 +3336,7 @@ async def get_service_page_data(plugin_type: str) -> dict | None:
"tools_count": len(tools),
"credential_fields": credential_fields,
"is_public": is_plugin_public(plugin_type),
"description": desc_data,
}

View File

@@ -249,16 +249,15 @@ ENDPOINT_CONFIGS = {
},
max_tools=80,
),
# OpenPanel endpoint - Product Analytics (73 tools) - Phase H
# OpenPanel endpoint - Product Analytics (42 tools) - Phase H
EndpointType.OPENPANEL: EndpointConfig(
path="/openpanel",
name="OpenPanel Analytics",
description="OpenPanel product analytics management (events, export, funnels, dashboards)",
description="OpenPanel self-hosted product analytics (events, export, insights, project/client management)",
endpoint_type=EndpointType.OPENPANEL,
plugin_types=["openpanel"],
require_master_key=False,
allowed_scopes={"read", "write", "admin"},
# Blacklist system and admin tools
tool_blacklist={
"manage_api_keys_create",
"manage_api_keys_delete",
@@ -266,7 +265,7 @@ ENDPOINT_CONFIGS = {
"oauth_register_client",
"oauth_revoke_client",
},
max_tools=80,
max_tools=50,
),
# Appwrite endpoint - Backend-as-a-Service (100 tools) - Phase I
EndpointType.APPWRITE: EndpointConfig(

View File

@@ -15,7 +15,7 @@ Usage:
import os
# Default plugins available to public (OAuth) users
DEFAULT_PUBLIC_PLUGINS = {"wordpress", "woocommerce", "supabase"}
DEFAULT_PUBLIC_PLUGINS = {"wordpress", "woocommerce", "supabase", "openpanel"}
def _parse_plugins(val: str) -> set[str]:

View File

@@ -3,7 +3,7 @@
Usage:
from core.settings import get_setting
enabled = await get_setting("ENABLED_PLUGINS", "wordpress,woocommerce,supabase")
enabled = await get_setting("ENABLED_PLUGINS", "wordpress,woocommerce,supabase,openpanel")
max_sites = int(await get_setting("MAX_SITES_PER_USER", "10"))
"""
@@ -17,7 +17,7 @@ _cached_plugins: set[str] | None = None
# Default values for all managed settings
SETTING_DEFAULTS: dict[str, str] = {
"ENABLED_PLUGINS": "wordpress,woocommerce,supabase",
"ENABLED_PLUGINS": "wordpress,woocommerce,supabase,openpanel",
"MAX_SITES_PER_USER": "10",
"USER_RATE_LIMIT_PER_MIN": "30",
"USER_RATE_LIMIT_PER_HR": "500",

View File

@@ -157,7 +157,7 @@ PLUGIN_CREDENTIAL_FIELDS: dict[str, list[dict[str, Any]]] = {
"label": "Client ID",
"type": "text",
"required": True,
"hint": "OpenPanel admin panel → API section",
"hint": "OpenPanel Dashboard → Settings → Clients → Create client with 'root' mode for full access",
},
{
"name": "client_secret",
@@ -166,6 +166,21 @@ PLUGIN_CREDENTIAL_FIELDS: dict[str, list[dict[str, Any]]] = {
"required": True,
"hint": "Generated with your Client ID",
},
{
"name": "project_id",
"label": "Project ID",
"type": "text",
"required": False,
"hint": "From dashboard URL: dashboard.openpanel.dev/{org}/{project-id}/ — sets default for Export & Insights tools",
},
{
"name": "organization_id",
"label": "Organization ID",
"type": "text",
"required": False,
"hint": "From dashboard URL: dashboard.openpanel.dev/{org}/{project-id}/",
"advanced": True,
},
],
"appwrite": [
{
@@ -215,7 +230,7 @@ _HEALTH_ENDPOINTS: dict[str, dict[str, Any]] = {
"gitea": {"path": "/api/v1/user", "method": "GET"},
"n8n": {"path": "/healthz", "method": "GET"},
"supabase": {"path": "/rest/v1/", "method": "GET"},
"openpanel": {"path": "/api/v1/oauth/token", "method": "POST"},
"openpanel": {"path": "/healthcheck", "method": "GET"},
"appwrite": {"path": "/v1/health", "method": "GET"},
"directus": {"path": "/server/health", "method": "GET"},
}
@@ -342,23 +357,13 @@ async def validate_site_connection(
elif plugin_type == "directus":
headers["Authorization"] = f"Bearer {credentials.get('token', '')}"
elif plugin_type == "openpanel":
# OpenPanel uses token exchange — just check that the URL is reachable
pass
headers["openpanel-client-id"] = credentials.get("client_id", "")
headers["openpanel-client-secret"] = credentials.get("client_secret", "")
try:
timeout = aiohttp.ClientTimeout(total=15)
async with aiohttp.ClientSession(timeout=timeout) as session:
if method == "POST" and plugin_type == "openpanel":
async with session.post(
check_url,
json={
"clientId": credentials.get("client_id", ""),
"clientSecret": credentials.get("client_secret", ""),
},
) as resp:
status_code = resp.status
resp_text = await resp.text()
elif method == "POST":
if method == "POST":
async with session.post(check_url, headers=headers) as resp:
status_code = resp.status
resp_text = await resp.text()

View File

@@ -31,6 +31,31 @@
</a>
</div>
<!-- Description & Notes -->
{% if service.description %}
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
{% if lang == 'fa' and service.description.fa %}
<p class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed" dir="rtl">{{ service.description.fa }}</p>
{% elif service.description.en %}
<p class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">{{ service.description.en }}</p>
{% endif %}
{% set notes_key = 'notes_fa' if lang == 'fa' else 'notes_en' %}
{% if service.description[notes_key] %}
<ul class="mt-4 space-y-2{% if lang == 'fa' %} text-right{% endif %}" {% if lang == 'fa' %}dir="rtl"{% endif %}>
{% for note in service.description[notes_key] %}
<li class="flex items-start gap-2 text-sm text-gray-500 dark:text-gray-400">
<svg class="w-4 h-4 text-primary-400 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span>{{ note|safe }}</span>
</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endif %}
<!-- Overview Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- Tools Count -->

View File

@@ -37,6 +37,7 @@
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.site_url }}</label>
<input type="url" id="url" name="url" required placeholder="https://example.com"
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<p id="url-hint" class="text-xs text-gray-500 dark:text-gray-400 mt-1 hidden"></p>
</div>
<!-- Alias -->
@@ -73,9 +74,23 @@
<script>
const pluginFields = {{ plugin_fields_json| safe }};
const urlHints = {
'openpanel': 'OpenPanel API URL — Cloud: https://api.openpanel.dev | Self-hosted: your API service URL (e.g., https://analytics.example.com)',
'supabase': 'Your Supabase project URL (e.g., https://xxxxx.supabase.co)',
};
function updateFields() {
const ptype = document.getElementById('plugin_type').value;
const container = document.getElementById('credential-fields');
const urlHintEl = document.getElementById('url-hint');
// Update URL hint
if (urlHints[ptype]) {
urlHintEl.textContent = urlHints[ptype];
urlHintEl.classList.remove('hidden');
} else {
urlHintEl.classList.add('hidden');
}
if (!ptype || !pluginFields[ptype]) {
container.innerHTML = '<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t.credentials }}</label><p class="text-sm text-gray-500 dark:text-gray-400">{{ t.select_plugin }}</p>';