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.
Files changed (89) hide show
  1. appmanager/__init__.py +52 -0
  2. appmanager/admin/__init__.py +3 -0
  3. appmanager/admin/app_installer.py +739 -0
  4. appmanager/admin/members.py +95 -0
  5. appmanager/admin/registry.py +372 -0
  6. appmanager/admin/routes.py +900 -0
  7. appmanager/api/__init__.py +5 -0
  8. appmanager/api/routes.py +373 -0
  9. appmanager/app_config.py +101 -0
  10. appmanager/auth/__init__.py +3 -0
  11. appmanager/auth/oauth.py +77 -0
  12. appmanager/auth/routes.py +224 -0
  13. appmanager/auth/utils.py +175 -0
  14. appmanager/bridge.py +90 -0
  15. appmanager/cli.py +1171 -0
  16. appmanager/config.py +58 -0
  17. appmanager/database.py +120 -0
  18. appmanager/extension.py +198 -0
  19. appmanager/extensions.py +225 -0
  20. appmanager/health.py +133 -0
  21. appmanager/hooks.py +187 -0
  22. appmanager/middleware.py +256 -0
  23. appmanager/models.py +307 -0
  24. appmanager/py.typed +1 -0
  25. appmanager/sdk.py +30 -0
  26. appmanager/security.py +227 -0
  27. appmanager/security_scanner.py +582 -0
  28. appmanager/signals.py +21 -0
  29. appmanager/static/css/app.css +808 -0
  30. appmanager/static/vendor/fonts/fonts.css +288 -0
  31. appmanager/static/vendor/fonts/jetbrainsmono-0.woff2 +0 -0
  32. appmanager/static/vendor/fonts/jetbrainsmono-1.woff2 +0 -0
  33. appmanager/static/vendor/fonts/jetbrainsmono-10.woff2 +0 -0
  34. appmanager/static/vendor/fonts/jetbrainsmono-11.woff2 +0 -0
  35. appmanager/static/vendor/fonts/jetbrainsmono-12.woff2 +0 -0
  36. appmanager/static/vendor/fonts/jetbrainsmono-13.woff2 +0 -0
  37. appmanager/static/vendor/fonts/jetbrainsmono-14.woff2 +0 -0
  38. appmanager/static/vendor/fonts/jetbrainsmono-15.woff2 +0 -0
  39. appmanager/static/vendor/fonts/jetbrainsmono-16.woff2 +0 -0
  40. appmanager/static/vendor/fonts/jetbrainsmono-17.woff2 +0 -0
  41. appmanager/static/vendor/fonts/jetbrainsmono-18.woff2 +0 -0
  42. appmanager/static/vendor/fonts/jetbrainsmono-19.woff2 +0 -0
  43. appmanager/static/vendor/fonts/jetbrainsmono-2.woff2 +0 -0
  44. appmanager/static/vendor/fonts/jetbrainsmono-20.woff2 +0 -0
  45. appmanager/static/vendor/fonts/jetbrainsmono-21.woff2 +0 -0
  46. appmanager/static/vendor/fonts/jetbrainsmono-22.woff2 +0 -0
  47. appmanager/static/vendor/fonts/jetbrainsmono-23.woff2 +0 -0
  48. appmanager/static/vendor/fonts/jetbrainsmono-3.woff2 +0 -0
  49. appmanager/static/vendor/fonts/jetbrainsmono-4.woff2 +0 -0
  50. appmanager/static/vendor/fonts/jetbrainsmono-5.woff2 +0 -0
  51. appmanager/static/vendor/fonts/jetbrainsmono-6.woff2 +0 -0
  52. appmanager/static/vendor/fonts/jetbrainsmono-7.woff2 +0 -0
  53. appmanager/static/vendor/fonts/jetbrainsmono-8.woff2 +0 -0
  54. appmanager/static/vendor/fonts/jetbrainsmono-9.woff2 +0 -0
  55. appmanager/static/vendor/fonts/spacegrotesk-0.woff2 +0 -0
  56. appmanager/static/vendor/fonts/spacegrotesk-1.woff2 +0 -0
  57. appmanager/static/vendor/fonts/spacegrotesk-10.woff2 +0 -0
  58. appmanager/static/vendor/fonts/spacegrotesk-11.woff2 +0 -0
  59. appmanager/static/vendor/fonts/spacegrotesk-2.woff2 +0 -0
  60. appmanager/static/vendor/fonts/spacegrotesk-3.woff2 +0 -0
  61. appmanager/static/vendor/fonts/spacegrotesk-4.woff2 +0 -0
  62. appmanager/static/vendor/fonts/spacegrotesk-5.woff2 +0 -0
  63. appmanager/static/vendor/fonts/spacegrotesk-6.woff2 +0 -0
  64. appmanager/static/vendor/fonts/spacegrotesk-7.woff2 +0 -0
  65. appmanager/static/vendor/fonts/spacegrotesk-8.woff2 +0 -0
  66. appmanager/static/vendor/fonts/spacegrotesk-9.woff2 +0 -0
  67. appmanager/static/vendor/htmx/htmx.min.js +1 -0
  68. appmanager/templates/admin/_member_drawer.html +98 -0
  69. appmanager/templates/admin/_members_table.html +108 -0
  70. appmanager/templates/admin/app_detail.html +136 -0
  71. appmanager/templates/admin/dashboard.html +902 -0
  72. appmanager/templates/admin/flairs.html +90 -0
  73. appmanager/templates/admin/permissions.html +88 -0
  74. appmanager/templates/admin/roles.html +119 -0
  75. appmanager/templates/admin/users.html +156 -0
  76. appmanager/templates/auth/login.html +58 -0
  77. appmanager/templates/auth/profile.html +44 -0
  78. appmanager/templates/base.html +76 -0
  79. appmanager/templates/errors/401.html +19 -0
  80. appmanager/templates/errors/403.html +19 -0
  81. appmanager/templates/errors/404.html +18 -0
  82. appmanager/templates/errors/500.html +19 -0
  83. appmanager/templates/index.html +54 -0
  84. appmanager_server-0.2.0.dist-info/METADATA +320 -0
  85. appmanager_server-0.2.0.dist-info/RECORD +89 -0
  86. appmanager_server-0.2.0.dist-info/WHEEL +5 -0
  87. appmanager_server-0.2.0.dist-info/entry_points.txt +5 -0
  88. appmanager_server-0.2.0.dist-info/licenses/LICENSE +21 -0
  89. 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}")
@@ -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()
@@ -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