appmanager-server 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- appmanager/__init__.py +52 -0
- appmanager/admin/__init__.py +3 -0
- appmanager/admin/app_installer.py +739 -0
- appmanager/admin/members.py +95 -0
- appmanager/admin/registry.py +372 -0
- appmanager/admin/routes.py +900 -0
- appmanager/api/__init__.py +5 -0
- appmanager/api/routes.py +373 -0
- appmanager/app_config.py +101 -0
- appmanager/auth/__init__.py +3 -0
- appmanager/auth/oauth.py +77 -0
- appmanager/auth/routes.py +224 -0
- appmanager/auth/utils.py +175 -0
- appmanager/bridge.py +90 -0
- appmanager/cli.py +1171 -0
- appmanager/config.py +58 -0
- appmanager/database.py +120 -0
- appmanager/extension.py +198 -0
- appmanager/extensions.py +225 -0
- appmanager/health.py +133 -0
- appmanager/hooks.py +187 -0
- appmanager/middleware.py +256 -0
- appmanager/models.py +307 -0
- appmanager/py.typed +1 -0
- appmanager/sdk.py +30 -0
- appmanager/security.py +227 -0
- appmanager/security_scanner.py +582 -0
- appmanager/signals.py +21 -0
- appmanager/static/css/app.css +808 -0
- appmanager/static/vendor/fonts/fonts.css +288 -0
- appmanager/static/vendor/fonts/jetbrainsmono-0.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-1.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-10.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-11.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-12.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-13.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-14.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-15.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-16.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-17.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-18.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-19.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-2.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-20.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-21.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-22.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-23.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-3.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-4.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-5.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-6.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-7.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-8.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-9.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-0.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-1.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-10.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-11.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-2.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-3.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-4.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-5.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-6.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-7.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-8.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-9.woff2 +0 -0
- appmanager/static/vendor/htmx/htmx.min.js +1 -0
- appmanager/templates/admin/_member_drawer.html +98 -0
- appmanager/templates/admin/_members_table.html +108 -0
- appmanager/templates/admin/app_detail.html +136 -0
- appmanager/templates/admin/dashboard.html +902 -0
- appmanager/templates/admin/flairs.html +90 -0
- appmanager/templates/admin/permissions.html +88 -0
- appmanager/templates/admin/roles.html +119 -0
- appmanager/templates/admin/users.html +156 -0
- appmanager/templates/auth/login.html +58 -0
- appmanager/templates/auth/profile.html +44 -0
- appmanager/templates/base.html +76 -0
- appmanager/templates/errors/401.html +19 -0
- appmanager/templates/errors/403.html +19 -0
- appmanager/templates/errors/404.html +18 -0
- appmanager/templates/errors/500.html +19 -0
- appmanager/templates/index.html +54 -0
- appmanager_server-0.2.0.dist-info/METADATA +320 -0
- appmanager_server-0.2.0.dist-info/RECORD +89 -0
- appmanager_server-0.2.0.dist-info/WHEEL +5 -0
- appmanager_server-0.2.0.dist-info/entry_points.txt +5 -0
- appmanager_server-0.2.0.dist-info/licenses/LICENSE +21 -0
- appmanager_server-0.2.0.dist-info/top_level.txt +1 -0
appmanager/config.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from datetime import timedelta
|
|
3
|
+
|
|
4
|
+
from dotenv import load_dotenv
|
|
5
|
+
|
|
6
|
+
# Automatically load .env if present in current working directory or base directory
|
|
7
|
+
load_dotenv()
|
|
8
|
+
|
|
9
|
+
DEFAULT_BASE_DIR = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
|
|
10
|
+
BASE_DIR = os.getenv("APPMANAGER_BASE_DIR", DEFAULT_BASE_DIR)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Config:
|
|
14
|
+
BASE_DIR = BASE_DIR
|
|
15
|
+
TEMPLATES_AUTO_RELOAD = os.getenv("TEMPLATES_AUTO_RELOAD", "true").lower() in (
|
|
16
|
+
"true",
|
|
17
|
+
"1",
|
|
18
|
+
"yes",
|
|
19
|
+
)
|
|
20
|
+
SECRET_KEY = os.getenv(
|
|
21
|
+
"SECRET_KEY", "appmanager-super-secret-key-change-in-production-min-32-chars"
|
|
22
|
+
)
|
|
23
|
+
JWT_SECRET = os.getenv("JWT_SECRET", "jwt-secret-key-change-in-production-min-32-chars")
|
|
24
|
+
JWT_ACCESS_TOKEN_EXPIRES = timedelta(days=int(os.getenv("JWT_ACCESS_TOKEN_EXPIRES_DAYS", 7)))
|
|
25
|
+
MAGIC_LINK_EXPIRES_MINUTES = int(os.getenv("MAGIC_LINK_EXPIRES_MINUTES", 15))
|
|
26
|
+
|
|
27
|
+
SQLALCHEMY_DATABASE_URI = os.getenv(
|
|
28
|
+
"DATABASE_URL", f"sqlite:///{os.path.join(BASE_DIR, 'instance', 'appmanager.db')}"
|
|
29
|
+
)
|
|
30
|
+
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
|
31
|
+
|
|
32
|
+
# Storage paths for sub-apps
|
|
33
|
+
INSTALLED_APPS_DIR = os.getenv("INSTALLED_APPS_DIR", os.path.join(BASE_DIR, "installed_apps"))
|
|
34
|
+
TEMP_UPLOAD_DIR = os.getenv("TEMP_UPLOAD_DIR", os.path.join(BASE_DIR, "instance", "uploads"))
|
|
35
|
+
|
|
36
|
+
# OAuth Settings
|
|
37
|
+
GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID", "")
|
|
38
|
+
GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET", "")
|
|
39
|
+
GOOGLE_DISCOVERY_URL = os.getenv(
|
|
40
|
+
"GOOGLE_DISCOVERY_URL", "https://accounts.google.com/.well-known/openid-configuration"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# SMTP Settings (Optional, logs to console if empty)
|
|
44
|
+
SMTP_SERVER = os.getenv("SMTP_SERVER", "")
|
|
45
|
+
SMTP_PORT = int(os.getenv("SMTP_PORT", 587))
|
|
46
|
+
SMTP_USER = os.getenv("SMTP_USER", "")
|
|
47
|
+
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
|
|
48
|
+
MAIL_DEFAULT_SENDER = os.getenv("MAIL_DEFAULT_SENDER", "noreply@appmanager.local")
|
|
49
|
+
APP_BASE_URL = os.getenv("APP_BASE_URL", "http://localhost:5000")
|
|
50
|
+
|
|
51
|
+
# Security settings
|
|
52
|
+
SESSION_COOKIE_HTTPONLY = True
|
|
53
|
+
SESSION_COOKIE_SAMESITE = os.getenv("SESSION_COOKIE_SAMESITE", "Lax")
|
|
54
|
+
SESSION_COOKIE_SECURE = os.getenv("SESSION_COOKIE_SECURE", "false").lower() in (
|
|
55
|
+
"true",
|
|
56
|
+
"1",
|
|
57
|
+
"yes",
|
|
58
|
+
)
|
appmanager/database.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from flask_sqlalchemy import SQLAlchemy
|
|
2
|
+
|
|
3
|
+
db = SQLAlchemy()
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def init_db(app):
|
|
7
|
+
db.init_app(app)
|
|
8
|
+
with app.app_context():
|
|
9
|
+
db.create_all()
|
|
10
|
+
# Auto-migrate missing columns for SQLite
|
|
11
|
+
try:
|
|
12
|
+
from sqlalchemy import inspect, text
|
|
13
|
+
|
|
14
|
+
engine = db.engine
|
|
15
|
+
inspector = inspect(engine)
|
|
16
|
+
tables = inspector.get_table_names()
|
|
17
|
+
with engine.connect() as conn:
|
|
18
|
+
if "installed_apps" in tables:
|
|
19
|
+
columns = [c["name"] for c in inspector.get_columns("installed_apps")]
|
|
20
|
+
if "requires_auth" not in columns:
|
|
21
|
+
conn.execute(
|
|
22
|
+
text(
|
|
23
|
+
"ALTER TABLE installed_apps ADD COLUMN requires_auth BOOLEAN DEFAULT 1 NOT NULL"
|
|
24
|
+
)
|
|
25
|
+
)
|
|
26
|
+
conn.commit()
|
|
27
|
+
if "is_default" not in columns:
|
|
28
|
+
conn.execute(
|
|
29
|
+
text(
|
|
30
|
+
"ALTER TABLE installed_apps ADD COLUMN is_default BOOLEAN DEFAULT 0 NOT NULL"
|
|
31
|
+
)
|
|
32
|
+
)
|
|
33
|
+
conn.commit()
|
|
34
|
+
if "app_type" not in columns:
|
|
35
|
+
conn.execute(
|
|
36
|
+
text(
|
|
37
|
+
"ALTER TABLE installed_apps ADD COLUMN app_type VARCHAR(50) DEFAULT 'standalone' NOT NULL"
|
|
38
|
+
)
|
|
39
|
+
)
|
|
40
|
+
conn.commit()
|
|
41
|
+
if "target_app" not in columns:
|
|
42
|
+
conn.execute(
|
|
43
|
+
text("ALTER TABLE installed_apps ADD COLUMN target_app VARCHAR(100)")
|
|
44
|
+
)
|
|
45
|
+
conn.commit()
|
|
46
|
+
if "has_web_ui" not in columns:
|
|
47
|
+
conn.execute(
|
|
48
|
+
text(
|
|
49
|
+
"ALTER TABLE installed_apps ADD COLUMN has_web_ui BOOLEAN DEFAULT 1 NOT NULL"
|
|
50
|
+
)
|
|
51
|
+
)
|
|
52
|
+
conn.commit()
|
|
53
|
+
if "settings_json" not in columns:
|
|
54
|
+
conn.execute(
|
|
55
|
+
text("ALTER TABLE installed_apps ADD COLUMN settings_json TEXT")
|
|
56
|
+
)
|
|
57
|
+
conn.commit()
|
|
58
|
+
|
|
59
|
+
if "users" in tables:
|
|
60
|
+
user_cols = [c["name"] for c in inspector.get_columns("users")]
|
|
61
|
+
if "last_login_at" not in user_cols:
|
|
62
|
+
conn.execute(text("ALTER TABLE users ADD COLUMN last_login_at DATETIME"))
|
|
63
|
+
conn.commit()
|
|
64
|
+
if "last_active_at" not in user_cols:
|
|
65
|
+
conn.execute(text("ALTER TABLE users ADD COLUMN last_active_at DATETIME"))
|
|
66
|
+
conn.commit()
|
|
67
|
+
if "login_count" not in user_cols:
|
|
68
|
+
conn.execute(
|
|
69
|
+
text(
|
|
70
|
+
"ALTER TABLE users ADD COLUMN login_count INTEGER DEFAULT 0 NOT NULL"
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
conn.commit()
|
|
74
|
+
if "last_ip" not in user_cols:
|
|
75
|
+
conn.execute(text("ALTER TABLE users ADD COLUMN last_ip VARCHAR(100)"))
|
|
76
|
+
conn.commit()
|
|
77
|
+
except Exception as e:
|
|
78
|
+
print(f"[DB MIGRATION WARNING] Failed auto-migration check: {e}")
|
|
79
|
+
|
|
80
|
+
# Seed default system roles
|
|
81
|
+
try:
|
|
82
|
+
from appmanager.models import Role
|
|
83
|
+
|
|
84
|
+
if Role.query.count() == 0:
|
|
85
|
+
default_roles = [
|
|
86
|
+
Role(
|
|
87
|
+
name="Admin",
|
|
88
|
+
slug="admin",
|
|
89
|
+
description="System administrator with full permissions.",
|
|
90
|
+
is_system=True,
|
|
91
|
+
),
|
|
92
|
+
Role(
|
|
93
|
+
name="User",
|
|
94
|
+
slug="user",
|
|
95
|
+
description="Standard member with assigned app permissions.",
|
|
96
|
+
is_system=True,
|
|
97
|
+
),
|
|
98
|
+
Role(
|
|
99
|
+
name="Developer",
|
|
100
|
+
slug="developer",
|
|
101
|
+
description="Application developer with telemetry and API access.",
|
|
102
|
+
is_system=False,
|
|
103
|
+
),
|
|
104
|
+
Role(
|
|
105
|
+
name="Manager",
|
|
106
|
+
slug="manager",
|
|
107
|
+
description="Team manager with supervisory access.",
|
|
108
|
+
is_system=False,
|
|
109
|
+
),
|
|
110
|
+
Role(
|
|
111
|
+
name="Viewer",
|
|
112
|
+
slug="viewer",
|
|
113
|
+
description="Read-only access to authorized tools.",
|
|
114
|
+
is_system=False,
|
|
115
|
+
),
|
|
116
|
+
]
|
|
117
|
+
db.session.add_all(default_roles)
|
|
118
|
+
db.session.commit()
|
|
119
|
+
except Exception as e:
|
|
120
|
+
print(f"[DB INIT WARNING] Role seeding check: {e}")
|
appmanager/extension.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import Any, List, Optional, Union
|
|
3
|
+
|
|
4
|
+
from flask import Flask, redirect, render_template, url_for
|
|
5
|
+
|
|
6
|
+
from appmanager.admin import admin_bp
|
|
7
|
+
from appmanager.auth import auth_bp
|
|
8
|
+
from appmanager.auth.utils import get_current_user
|
|
9
|
+
from appmanager.config import Config
|
|
10
|
+
from appmanager.database import db as default_db
|
|
11
|
+
from appmanager.database import init_db
|
|
12
|
+
from appmanager.extensions import init_extensions
|
|
13
|
+
from appmanager.health import check_all_apps_health, check_app_health
|
|
14
|
+
from appmanager.middleware import DynamicAppDispatcherMiddleware
|
|
15
|
+
from appmanager.models import InstalledApp, UserAppPermission
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AppManager:
|
|
19
|
+
"""
|
|
20
|
+
Standard Flask extension class for AppManager.
|
|
21
|
+
Allows integrating AppManager into existing Flask applications or factory patterns.
|
|
22
|
+
|
|
23
|
+
Usage:
|
|
24
|
+
app = Flask(__name__)
|
|
25
|
+
manager = AppManager(app)
|
|
26
|
+
# or
|
|
27
|
+
manager = AppManager()
|
|
28
|
+
manager.init_app(app)
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
app: Optional[Flask] = None,
|
|
34
|
+
db: Optional[Any] = None,
|
|
35
|
+
register_routes: bool = True,
|
|
36
|
+
register_blueprints: bool = True,
|
|
37
|
+
) -> None:
|
|
38
|
+
self.app = app
|
|
39
|
+
self.db = db
|
|
40
|
+
self.register_routes = register_routes
|
|
41
|
+
self.register_blueprints = register_blueprints
|
|
42
|
+
self.dispatcher: Optional[DynamicAppDispatcherMiddleware] = None
|
|
43
|
+
|
|
44
|
+
if app is not None:
|
|
45
|
+
self.init_app(app, db=db)
|
|
46
|
+
|
|
47
|
+
def init_app(self, app: Flask, db: Optional[Any] = None) -> None:
|
|
48
|
+
"""
|
|
49
|
+
Initializes the application with AppManager extension.
|
|
50
|
+
"""
|
|
51
|
+
# Set default configs if not already set
|
|
52
|
+
for key in dir(Config):
|
|
53
|
+
if key.isupper() and key not in app.config:
|
|
54
|
+
app.config[key] = getattr(Config, key)
|
|
55
|
+
|
|
56
|
+
# Ensure base directories exist
|
|
57
|
+
base_dir = app.config.get("BASE_DIR", Config.BASE_DIR)
|
|
58
|
+
os.makedirs(app.config["INSTALLED_APPS_DIR"], exist_ok=True)
|
|
59
|
+
os.makedirs(app.config["TEMP_UPLOAD_DIR"], exist_ok=True)
|
|
60
|
+
os.makedirs(os.path.join(base_dir, "instance"), exist_ok=True)
|
|
61
|
+
|
|
62
|
+
# Initialize Database & Extension Helpers
|
|
63
|
+
init_db(app)
|
|
64
|
+
init_extensions(app)
|
|
65
|
+
|
|
66
|
+
# One-time migration: legacy settings_json → typed app_configs rows
|
|
67
|
+
try:
|
|
68
|
+
from appmanager.app_config import migrate_from_settings_json
|
|
69
|
+
|
|
70
|
+
migrate_from_settings_json()
|
|
71
|
+
except Exception:
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
from appmanager.security import generate_csrf_token
|
|
75
|
+
|
|
76
|
+
app.jinja_env.globals["csrf_token"] = generate_csrf_token
|
|
77
|
+
|
|
78
|
+
# Register Blueprints
|
|
79
|
+
if self.register_blueprints:
|
|
80
|
+
from appmanager.api import api_bp
|
|
81
|
+
|
|
82
|
+
app.register_blueprint(auth_bp)
|
|
83
|
+
|
|
84
|
+
# Mount extension-declared admin blueprints onto admin_bp BEFORE it
|
|
85
|
+
# is registered on the app (admin auth enforced at mount time).
|
|
86
|
+
# Needs an app context for DB/manifest lookups.
|
|
87
|
+
try:
|
|
88
|
+
from appmanager.admin.registry import mount_all_app_admin_blueprints
|
|
89
|
+
|
|
90
|
+
with app.app_context():
|
|
91
|
+
mount_all_app_admin_blueprints(admin_bp)
|
|
92
|
+
except Exception:
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
app.register_blueprint(admin_bp)
|
|
96
|
+
app.register_blueprint(api_bp)
|
|
97
|
+
|
|
98
|
+
# Register Default Landing & Error Handlers if requested
|
|
99
|
+
if self.register_routes:
|
|
100
|
+
self._register_default_routes(app)
|
|
101
|
+
|
|
102
|
+
# Store extension instance on the Flask app
|
|
103
|
+
if not hasattr(app, "extensions"):
|
|
104
|
+
app.extensions = {}
|
|
105
|
+
app.extensions["appmanager"] = self
|
|
106
|
+
|
|
107
|
+
def _register_default_routes(self, app: Flask) -> None:
|
|
108
|
+
@app.route("/")
|
|
109
|
+
def index():
|
|
110
|
+
default_app = InstalledApp.query.filter_by(is_default=True, is_active=True).first()
|
|
111
|
+
if default_app:
|
|
112
|
+
if not default_app.requires_auth:
|
|
113
|
+
return redirect(f"/apps/{default_app.slug}/")
|
|
114
|
+
user = get_current_user()
|
|
115
|
+
if not user:
|
|
116
|
+
return redirect(url_for("auth.login", next=f"/apps/{default_app.slug}/"))
|
|
117
|
+
return redirect(f"/apps/{default_app.slug}/")
|
|
118
|
+
return redirect(url_for("dashboard"))
|
|
119
|
+
|
|
120
|
+
@app.route("/dashboard")
|
|
121
|
+
def dashboard():
|
|
122
|
+
user = get_current_user()
|
|
123
|
+
if not user:
|
|
124
|
+
return redirect(url_for("auth.login"))
|
|
125
|
+
|
|
126
|
+
if user.is_admin():
|
|
127
|
+
accessible_apps = InstalledApp.query.filter_by(is_active=True).all()
|
|
128
|
+
else:
|
|
129
|
+
accessible_apps = (
|
|
130
|
+
default_db.session.query(InstalledApp)
|
|
131
|
+
.join(UserAppPermission, UserAppPermission.app_id == InstalledApp.id)
|
|
132
|
+
.filter(
|
|
133
|
+
UserAppPermission.user_id == user.id,
|
|
134
|
+
UserAppPermission.can_access.is_(True),
|
|
135
|
+
InstalledApp.is_active.is_(True),
|
|
136
|
+
)
|
|
137
|
+
.all()
|
|
138
|
+
)
|
|
139
|
+
return render_template("index.html", user=user, apps=accessible_apps)
|
|
140
|
+
|
|
141
|
+
@app.errorhandler(404)
|
|
142
|
+
def not_found_error(error):
|
|
143
|
+
return render_template("errors/404.html", user=get_current_user()), 404
|
|
144
|
+
|
|
145
|
+
@app.errorhandler(500)
|
|
146
|
+
def internal_error(error):
|
|
147
|
+
return render_template("errors/500.html", user=get_current_user()), 500
|
|
148
|
+
|
|
149
|
+
@app.errorhandler(403)
|
|
150
|
+
def forbidden_error(error):
|
|
151
|
+
return render_template("errors/403.html", user=get_current_user()), 403
|
|
152
|
+
|
|
153
|
+
@app.errorhandler(401)
|
|
154
|
+
def unauthorized_error(error):
|
|
155
|
+
return render_template("errors/401.html", user=get_current_user()), 401
|
|
156
|
+
|
|
157
|
+
def create_dispatcher(self, app: Optional[Flask] = None) -> DynamicAppDispatcherMiddleware:
|
|
158
|
+
"""
|
|
159
|
+
Wraps the Flask app with DynamicAppDispatcherMiddleware for sub-app routing.
|
|
160
|
+
"""
|
|
161
|
+
target_app = app or self.app
|
|
162
|
+
if target_app is None:
|
|
163
|
+
raise ValueError("Flask app instance must be provided to create_dispatcher.")
|
|
164
|
+
self.dispatcher = DynamicAppDispatcherMiddleware(target_app)
|
|
165
|
+
return self.dispatcher
|
|
166
|
+
|
|
167
|
+
def clear_cache(self, slug: Optional[str] = None) -> None:
|
|
168
|
+
"""
|
|
169
|
+
Clears the in-memory WSGI cache for a specific sub-app or all sub-apps.
|
|
170
|
+
"""
|
|
171
|
+
if self.dispatcher:
|
|
172
|
+
self.dispatcher.clear_cache(slug=slug)
|
|
173
|
+
|
|
174
|
+
def get_app(self, slug: str) -> Optional[InstalledApp]:
|
|
175
|
+
"""
|
|
176
|
+
Retrieve InstalledApp model instance by slug.
|
|
177
|
+
"""
|
|
178
|
+
return InstalledApp.query.filter_by(slug=slug).first()
|
|
179
|
+
|
|
180
|
+
def get_apps(self, active_only: bool = True) -> List[InstalledApp]:
|
|
181
|
+
"""
|
|
182
|
+
List installed sub-apps.
|
|
183
|
+
"""
|
|
184
|
+
query = InstalledApp.query
|
|
185
|
+
if active_only:
|
|
186
|
+
query = query.filter_by(is_active=True)
|
|
187
|
+
return query.all()
|
|
188
|
+
|
|
189
|
+
def check_health(self, slug: Optional[str] = None) -> Union[List[Any], Any]:
|
|
190
|
+
"""
|
|
191
|
+
Runs health check for a single sub-app by slug, or all active sub-apps if slug is None.
|
|
192
|
+
"""
|
|
193
|
+
if slug:
|
|
194
|
+
app_record = self.get_app(slug)
|
|
195
|
+
if not app_record:
|
|
196
|
+
raise ValueError(f"App with slug '{slug}' not found.")
|
|
197
|
+
return check_app_health(app_record)
|
|
198
|
+
return check_all_apps_health()
|
appmanager/extensions.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
|
|
6
|
+
from markupsafe import Markup
|
|
7
|
+
|
|
8
|
+
from appmanager.database import db
|
|
9
|
+
from appmanager.hooks import hooks, render_slot
|
|
10
|
+
from appmanager.models import AppExtensionData, InstalledApp
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_active_extensions(target_app_slug="appmanager"):
|
|
14
|
+
"""
|
|
15
|
+
Returns active extension apps extending a specific target app.
|
|
16
|
+
"""
|
|
17
|
+
return InstalledApp.query.filter_by(
|
|
18
|
+
app_type="extension", target_app=target_app_slug, is_active=True
|
|
19
|
+
).all()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_extension_data(extension_slug, entity_type, entity_id):
|
|
23
|
+
"""
|
|
24
|
+
Retrieves stored JSON data dictionary for a specific extension & entity.
|
|
25
|
+
"""
|
|
26
|
+
record = AppExtensionData.query.filter_by(
|
|
27
|
+
extension_slug=extension_slug, entity_type=entity_type, entity_id=entity_id
|
|
28
|
+
).first()
|
|
29
|
+
if record and record.data_json:
|
|
30
|
+
try:
|
|
31
|
+
return json.loads(record.data_json)
|
|
32
|
+
except Exception:
|
|
33
|
+
return None
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def set_extension_data(extension_slug, entity_type, entity_id, data_dict):
|
|
38
|
+
"""
|
|
39
|
+
Stores or updates JSON data for a specific extension & entity.
|
|
40
|
+
"""
|
|
41
|
+
record = AppExtensionData.query.filter_by(
|
|
42
|
+
extension_slug=extension_slug, entity_type=entity_type, entity_id=entity_id
|
|
43
|
+
).first()
|
|
44
|
+
payload = json.dumps(data_dict) if data_dict is not None else None
|
|
45
|
+
if not record:
|
|
46
|
+
record = AppExtensionData(
|
|
47
|
+
extension_slug=extension_slug,
|
|
48
|
+
entity_type=entity_type,
|
|
49
|
+
entity_id=entity_id,
|
|
50
|
+
data_json=payload,
|
|
51
|
+
)
|
|
52
|
+
db.session.add(record)
|
|
53
|
+
else:
|
|
54
|
+
record.data_json = payload
|
|
55
|
+
record.updated_at = datetime.now(timezone.utc)
|
|
56
|
+
db.session.commit()
|
|
57
|
+
return record
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
DEFAULT_PRESET_FLAIRS = [
|
|
61
|
+
{"title": "🚀 Lead Developer", "color": "#38bdf8"},
|
|
62
|
+
{"title": "⭐ VIP Member", "color": "#facc15"},
|
|
63
|
+
{"title": "🛡️ Moderator", "color": "#4ade80"},
|
|
64
|
+
{"title": "🔥 Core Contributor", "color": "#f87171"},
|
|
65
|
+
{"title": "💎 Sponsor", "color": "#c084fc"},
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _flairs_extension():
|
|
70
|
+
"""
|
|
71
|
+
Import the User Flairs extension module if it is installed and active.
|
|
72
|
+
Returns None (graceful fallback) if unavailable.
|
|
73
|
+
"""
|
|
74
|
+
try:
|
|
75
|
+
from appmanager.models import InstalledApp
|
|
76
|
+
|
|
77
|
+
rec = InstalledApp.query.filter_by(slug="extension-flairs", is_active=True).first()
|
|
78
|
+
if not rec:
|
|
79
|
+
return None
|
|
80
|
+
return _load_extension_module("extension-flairs", "extension")
|
|
81
|
+
except Exception:
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _load_extension_module(slug, module_name, installed_apps_dir=None):
|
|
86
|
+
"""
|
|
87
|
+
Load an extension's module from its installed_apps directory.
|
|
88
|
+
Adds the extension dir to sys.path (idempotent) so top-level imports
|
|
89
|
+
like `import extension` / `import flairs_admin` resolve.
|
|
90
|
+
Returns the module, or None on failure.
|
|
91
|
+
"""
|
|
92
|
+
try:
|
|
93
|
+
base = installed_apps_dir or os.path.join(
|
|
94
|
+
os.path.dirname(os.path.dirname(__file__)), "installed_apps"
|
|
95
|
+
)
|
|
96
|
+
ext_dir = os.path.join(base, slug)
|
|
97
|
+
if ext_dir not in sys.path:
|
|
98
|
+
sys.path.insert(0, ext_dir)
|
|
99
|
+
import importlib
|
|
100
|
+
|
|
101
|
+
return importlib.import_module(module_name)
|
|
102
|
+
except Exception:
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _register_extension_templates(app, slug):
|
|
107
|
+
"""
|
|
108
|
+
Add an extension's `templates/` dir to the app's Jinja search path so its
|
|
109
|
+
blueprints can render their own templates. Idempotent; graceful on failure.
|
|
110
|
+
"""
|
|
111
|
+
try:
|
|
112
|
+
base = app.config.get("INSTALLED_APPS_DIR")
|
|
113
|
+
tdir = os.path.join(base, slug, "templates")
|
|
114
|
+
if not os.path.isdir(tdir):
|
|
115
|
+
return
|
|
116
|
+
from jinja2 import ChoiceLoader, FileSystemLoader
|
|
117
|
+
|
|
118
|
+
loader = app.jinja_env.loader
|
|
119
|
+
if isinstance(loader, ChoiceLoader):
|
|
120
|
+
paths = [ldr.searchpath for ldr in loader.loaders if isinstance(ldr, FileSystemLoader)]
|
|
121
|
+
if tdir not in paths:
|
|
122
|
+
loader.loaders.append(FileSystemLoader(tdir))
|
|
123
|
+
else:
|
|
124
|
+
app.jinja_env.loader = ChoiceLoader([loader, FileSystemLoader(tdir)])
|
|
125
|
+
except Exception:
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def get_user_flair(user_id):
|
|
130
|
+
"""
|
|
131
|
+
Retrieves custom flair title and color for a user. Delegates to the
|
|
132
|
+
User Flairs extension if present; falls back to AppExtensionData.
|
|
133
|
+
"""
|
|
134
|
+
mod = _flairs_extension()
|
|
135
|
+
if mod is not None and hasattr(mod, "get_user_flair"):
|
|
136
|
+
try:
|
|
137
|
+
return mod.get_user_flair(user_id)
|
|
138
|
+
except Exception:
|
|
139
|
+
pass
|
|
140
|
+
return get_extension_data("extension-flairs", "user", user_id)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def set_user_flair(user_id, title, color="#a855f7"):
|
|
144
|
+
"""
|
|
145
|
+
Sets or updates custom flair for a user. Delegates to the extension if present;
|
|
146
|
+
falls back to AppExtensionData.
|
|
147
|
+
"""
|
|
148
|
+
mod = _flairs_extension()
|
|
149
|
+
if mod is not None and hasattr(mod, "set_user_flair"):
|
|
150
|
+
try:
|
|
151
|
+
return mod.set_user_flair(user_id, title, color)
|
|
152
|
+
except Exception:
|
|
153
|
+
pass
|
|
154
|
+
return set_extension_data("extension-flairs", "user", user_id, {"title": title, "color": color})
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def render_user_flair_badge(user_id):
|
|
158
|
+
"""
|
|
159
|
+
Jinja helper rendering styled HTML flair badge for a user. Delegates to
|
|
160
|
+
the extension if present; falls back to AppExtensionData rendering.
|
|
161
|
+
"""
|
|
162
|
+
mod = _flairs_extension()
|
|
163
|
+
if mod is not None and hasattr(mod, "render_user_flair_badge"):
|
|
164
|
+
try:
|
|
165
|
+
return mod.render_user_flair_badge(user_id)
|
|
166
|
+
except Exception:
|
|
167
|
+
pass
|
|
168
|
+
flair = get_user_flair(user_id)
|
|
169
|
+
if flair and flair.get("title"):
|
|
170
|
+
title = flair.get("title")
|
|
171
|
+
color = flair.get("color", "#38bdf8")
|
|
172
|
+
return Markup(
|
|
173
|
+
f'<span class="badge" style="background-color: {color}22; color: {color}; border: 1px solid {color}44; font-size: 0.75rem; padding: 2px 6px; border-radius: 4px; font-weight: 500; display: inline-flex; align-items: center; gap: 4px; margin-left: 6px;">'
|
|
174
|
+
f"{title}"
|
|
175
|
+
f"</span>"
|
|
176
|
+
)
|
|
177
|
+
return Markup("")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def render_sparkline(health_logs):
|
|
181
|
+
"""
|
|
182
|
+
Jinja helper rendering a braille sparkline from a list of AppHealthLog rows.
|
|
183
|
+
Each log maps to a braille char: healthy=high, degraded=mid, down=low.
|
|
184
|
+
"""
|
|
185
|
+
if not health_logs:
|
|
186
|
+
return Markup("")
|
|
187
|
+
# Braille chars: low / mid / high
|
|
188
|
+
chars = {"healthy": "⣿", "degraded": "⣤", "down": "⣀"}
|
|
189
|
+
out = []
|
|
190
|
+
for log in health_logs:
|
|
191
|
+
status = getattr(log, "status", "") or ""
|
|
192
|
+
out.append(chars.get(status, "·"))
|
|
193
|
+
return Markup(
|
|
194
|
+
'<span class="sparkline" title="Health history (oldest → newest)">'
|
|
195
|
+
+ "".join(out)
|
|
196
|
+
+ "</span>"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def init_extensions(app):
|
|
201
|
+
"""
|
|
202
|
+
Registers Jinja helper functions and hook/slot integrations for extensions.
|
|
203
|
+
"""
|
|
204
|
+
# Ensure the User Flairs extension dir is importable, then load it and
|
|
205
|
+
# let it register its own slots (user_badge). Graceful if unavailable.
|
|
206
|
+
mod = _load_extension_module(
|
|
207
|
+
"extension-flairs", "extension", app.config.get("INSTALLED_APPS_DIR")
|
|
208
|
+
)
|
|
209
|
+
if mod is not None and hasattr(mod, "register_slots"):
|
|
210
|
+
try:
|
|
211
|
+
mod.register_slots()
|
|
212
|
+
except Exception as e:
|
|
213
|
+
print(f"[EXTENSION] Failed to register slots for extension-flairs: {e}")
|
|
214
|
+
|
|
215
|
+
# Add the extension's templates dir to the app's template search path so
|
|
216
|
+
# its admin blueprint can render its own templates.
|
|
217
|
+
_register_extension_templates(app, "extension-flairs")
|
|
218
|
+
|
|
219
|
+
app.jinja_env.globals["get_user_flair"] = get_user_flair
|
|
220
|
+
app.jinja_env.globals["render_user_flair_badge"] = render_user_flair_badge
|
|
221
|
+
app.jinja_env.globals["get_active_extensions"] = get_active_extensions
|
|
222
|
+
app.jinja_env.globals["DEFAULT_PRESET_FLAIRS"] = DEFAULT_PRESET_FLAIRS
|
|
223
|
+
app.jinja_env.globals["render_slot"] = render_slot
|
|
224
|
+
app.jinja_env.globals["render_sparkline"] = render_sparkline
|
|
225
|
+
app.jinja_env.globals["hooks"] = hooks
|