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:
@@ -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__)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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>';
|
||||
|
||||
BIN
core/templates/static/plugins/openpanel-self-hosted.zip
Normal file
BIN
core/templates/static/plugins/openpanel-self-hosted.zip
Normal file
Binary file not shown.
Reference in New Issue
Block a user