diff --git a/.gitignore b/.gitignore
index e3ddc692..d34838d7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -60,4 +60,5 @@ target/
.idea
.DS_Store
*.swp
-.vscode
\ No newline at end of file
+.vscode
+blueprints/gcp_terraform_sample/gcp_creds.json
diff --git a/blueprints/aws_ami_update_in_place/update_ami_id_and_templatename_for_ami_and_osba.py b/blueprints/aws_ami_update_in_place/update_ami_id_and_templatename_for_ami_and_osba.py
new file mode 100644
index 00000000..6aa8e364
--- /dev/null
+++ b/blueprints/aws_ami_update_in_place/update_ami_id_and_templatename_for_ami_and_osba.py
@@ -0,0 +1,155 @@
+"""
+This plug-in updates existing Amazon Machine Image (AMI) records in CloudBolt by
+replacing an old AMI ID with a new one and updating the associated template
+name across both AmazonMachineImage and OSBuildAttribute models.
+
+Workflow:
+1. User selects an OSBuild value.
+ → NOTE: The `osbuild` parameter **must act as a control field**, and any
+ parameters depending on it (such as old_ami_id and region) must be
+ configured with CloudBolt’s *regenerate_on_change* dependency so that their
+ option lists refresh dynamically.
+
+2. Based on the selected OSBuild:
+ - `old_ami_id` list is dynamically generated.
+ - After an AMI is selected, the available AWS regions are dynamically generated.
+
+3. When executed, the script:
+ - Locates AMIs that match the selected old AMI ID.
+ - Updates each AMI's `ami_id` and `templatename`.
+ - Updates the related OSBuildAttribute template name.
+
+This ensures OSBuild-specific AMIs are updated cleanly and consistently.
+"""
+
+from common.methods import set_progress
+from externalcontent.models import OSBuild, OSBuildAttribute
+from resourcehandlers.aws.models import AmazonMachineImage
+
+import logging
+
+logger = logging.getLogger("AWS")
+
+# Initial parameter values — printed to progress for visibility
+old_ami_id = "{{old_ami_id}}"
+set_progress(f"Old AMI ID: {old_ami_id}")
+
+region = "{{region}}"
+set_progress(f"Region: {region}")
+
+osbuild = "{{osbuild}}"
+set_progress(f"OSBuild: {osbuild}")
+
+new_template_name = "{{new_template_name}}"
+set_progress(f"New template name: {new_template_name}")
+
+new_ami_id = "{{new_ami_id}}"
+set_progress(f"New AMI ID: {new_ami_id}")
+
+
+def run(job, *args, **kwargs):
+ """
+ Updates all AMIs matching old_ami_id:
+ - Replaces the AMI ID with new_ami_id
+ - Updates the template name
+ - Updates the associated OSBuildAttribute template name
+ """
+ set_progress("Fetching AMIs to update...")
+ amis = AmazonMachineImage.objects.filter(ami_id=old_ami_id)
+ set_progress(f"Found {amis.count()} AMIs with old AMI ID {old_ami_id}")
+
+ for ami in amis:
+ set_progress("########################")
+ set_progress(f"Updating AMI {ami.ami_id} template name to {new_template_name}")
+
+ # Update AMI details
+ ami.ami_id = new_ami_id
+ ami.templatename = new_template_name
+ ami.save()
+ set_progress(f"AMI {new_ami_id}, {ami.ami_id} template name updated")
+
+ set_progress("########################")
+ # Update OSBuildAttribute reference
+ osba = ami.osbuildattribute_ptr
+ set_progress(f"Updating OSBuildAttribute template name for AMI {ami.ami_id}")
+ osba.template_name = new_template_name
+ osba.save()
+ set_progress(f"OSBuildAttribute for AMI {ami.ami_id} updated")
+
+
+def generate_options_for_osbuild(field, **kwargs):
+ """
+ Generates a list of OSBuild options.
+ NOTE: This parameter should be set as a control field because
+ dependent parameters rely on its value to regenerate their options.
+ """
+ osbs = OSBuild.objects.filter(
+ environments__resource_handler__awshandler__isnull=False
+ ).distinct()
+
+ options = [(osb.id, osb.name) for osb in osbs]
+
+ return {"options": options, "override": True, "sort": True}
+
+
+def generate_options_for_old_ami_id(
+ field, control_value=None, control_value_dict=None, **kwargs
+):
+ """
+ Generates a list of old AMI IDs based on the selected OSBuild.
+ This function requires:
+ - `osbuild` to be the control_value
+ - The "old_ami_id" parameter to have regenerate_on_change pointing to "osbuild"
+ """
+ options = []
+
+ if not control_value:
+ return {"options": options}
+
+ try:
+ # control_value is OSBuild id or global_id
+ osbuild = control_value
+
+ amis = (
+ AmazonMachineImage.objects.filter(os_build=osbuild)
+ .values_list("ami_id", flat=True)
+ .distinct()
+ )
+
+ options = [(ami, ami) for ami in amis]
+
+ except OSBuild.DoesNotExist:
+ logger.warning(f"OSBuild not found for value: {control_value}")
+
+ return {"options": options, "override": True, "sort": True}
+
+
+def generate_options_for_region(
+ field, control_value=None, control_value_dict=None, **kwargs
+):
+ """
+ Generates AWS regions based on the chosen AMI ID.
+ Requires:
+ - `old_ami_id` to be configured as a control_value
+ - The "region" parameter to regenerate_on_change on "old_ami_id"
+ """
+ options = []
+
+ if not control_value:
+ return {"options": options}
+
+ try:
+ ami_id = control_value
+ logger.info(f"Control_value: {control_value}")
+
+ amis = AmazonMachineImage.objects.filter(ami_id=ami_id)
+
+ # Collect distinct regions
+ regions = {ami.region for ami in amis if ami.region}
+
+ options = [(r, r) for r in sorted(regions)]
+
+ except OSBuild.DoesNotExist:
+ logger.warning(f"OSBuild not found for value: {control_value}")
+
+ return {"options": options, "override": True, "sort": True}
diff --git a/blueprints/gcp_terraform_sample/cmp_variable_maps.json b/blueprints/gcp_terraform_sample/cmp_variable_maps.json
new file mode 100644
index 00000000..1e385547
--- /dev/null
+++ b/blueprints/gcp_terraform_sample/cmp_variable_maps.json
@@ -0,0 +1,5 @@
+{
+ "TF_VAR_gcp_role_name": "{{gcp_role_name}}",
+ "TF_VAR_gcp_user_name": "{{gcp_user_name}}",
+ "TF_VAR_gcp_project_name": "{{gcp_project_name}}"
+}
\ No newline at end of file
diff --git a/blueprints/gcp_terraform_sample/main.tf b/blueprints/gcp_terraform_sample/main.tf
new file mode 100644
index 00000000..a1c54fb0
--- /dev/null
+++ b/blueprints/gcp_terraform_sample/main.tf
@@ -0,0 +1,5 @@
+resource "google_project_iam_member" "project" {
+ project = var.gcp_project_name
+ role = var.gcp_role_name
+ member = "user:${var.gcp_user_name}"
+}
diff --git a/blueprints/gcp_terraform_sample/providers.tf b/blueprints/gcp_terraform_sample/providers.tf
new file mode 100644
index 00000000..f0903ffc
--- /dev/null
+++ b/blueprints/gcp_terraform_sample/providers.tf
@@ -0,0 +1,13 @@
+terraform {
+ required_providers {
+ google = {
+ source = "hashicorp/google"
+ version = ">=6.34.0"
+ }
+ }
+}
+
+provider "google" {
+ project = var.gcp_project_name
+ credentials = file("var./var/opt/cloudbolt/proserv/gcp_creds")
+}
\ No newline at end of file
diff --git a/blueprints/gcp_terraform_sample/variables.tf b/blueprints/gcp_terraform_sample/variables.tf
new file mode 100644
index 00000000..293a2ff6
--- /dev/null
+++ b/blueprints/gcp_terraform_sample/variables.tf
@@ -0,0 +1,12 @@
+variable "gcp_project_name" {
+ type = string
+ description = "GCP Project name"
+}
+variable "gcp_role_name" {
+ type = string
+ description = "Permission name, type roles/REQUESTEDROLE"
+}
+variable "gcp_user_name" {
+ type = string
+ description = "User email address"
+}
diff --git a/blueprints/sample_blueprint_from_remote_source/348753.png b/blueprints/sample_blueprint_from_remote_source/348753.png
new file mode 100644
index 00000000..761fc4ec
Binary files /dev/null and b/blueprints/sample_blueprint_from_remote_source/348753.png differ
diff --git a/blueprints/sample_blueprint_from_remote_source/action_added_in_sample_blueprint_from_remotesource/action_added_in_sample_blueprint_from_remotesource.json b/blueprints/sample_blueprint_from_remote_source/action_added_in_sample_blueprint_from_remotesource/action_added_in_sample_blueprint_from_remotesource.json
index 223430ed..5912e742 100644
--- a/blueprints/sample_blueprint_from_remote_source/action_added_in_sample_blueprint_from_remotesource/action_added_in_sample_blueprint_from_remotesource.json
+++ b/blueprints/sample_blueprint_from_remote_source/action_added_in_sample_blueprint_from_remotesource/action_added_in_sample_blueprint_from_remotesource.json
@@ -1,13 +1,13 @@
{
"description": "",
"id": "OHK-5a8htglt",
- "last_updated": "2024-02-02",
+ "last_updated": "2025-05-23",
"max_retries": 0,
"maximum_version_required": "",
"minimum_version_required": "8.6",
"name": "Action added in Sample Blueprint From RemoteSource",
"resource_technologies": [],
- "script_filename": "cb_plugin_1706898870553853.py",
+ "script_filename": "cb_plugin_1706898870553853_ZKrRd11.py",
"shared": false,
"target_os_families": [],
"type": "CloudBolt Plug-in"
diff --git a/blueprints/sample_blueprint_from_remote_source/action_added_in_sample_blueprint_from_remotesource/cb_plugin_1706898870553853_ZKrRd11.py b/blueprints/sample_blueprint_from_remote_source/action_added_in_sample_blueprint_from_remotesource/cb_plugin_1706898870553853_ZKrRd11.py
new file mode 100644
index 00000000..9ab1b86b
--- /dev/null
+++ b/blueprints/sample_blueprint_from_remote_source/action_added_in_sample_blueprint_from_remotesource/cb_plugin_1706898870553853_ZKrRd11.py
@@ -0,0 +1,24 @@
+"""
+UPDATED This is a working sample CloudBolt plug-in for you to start with. The run method is required,
+but you can change all the code within it. See the "CloudBolt Plug-ins" section of the docs for
+more info and the CloudBolt forge for more examples:
+https://github.com/CloudBoltSoftware/cloudbolt-forge/tree/master/actions/cloudbolt_plugins
+"""
+from common.methods import set_progress
+
+
+def run(job, *args, **kwargs):
+ set_progress("This will show up in the job details page in the CB UI, and in the job log")
+
+ # Example of how to fetch arguments passed to this plug-in ('server' will be available in
+ # some cases)
+ server = kwargs.get('server')
+ if server:
+ set_progress("This plug-in is running for server {}".format(server))
+
+ set_progress("Dictionary of keyword args passed to this plug-in: {}".format(kwargs.items()))
+
+ if True:
+ return "SUCCESS", "Sample output message", ""
+ else:
+ return "FAILURE", "Sample output message", "Sample error message, this is shown in red"
diff --git a/blueprints/sample_blueprint_from_remote_source/build_3_action_added_in_sample_blueprint_from_remotesource.zip b/blueprints/sample_blueprint_from_remote_source/build_3_action_added_in_sample_blueprint_from_remotesource.zip
index a4a74163..3b8772b7 100644
Binary files a/blueprints/sample_blueprint_from_remote_source/build_3_action_added_in_sample_blueprint_from_remotesource.zip and b/blueprints/sample_blueprint_from_remote_source/build_3_action_added_in_sample_blueprint_from_remotesource.zip differ
diff --git a/blueprints/sample_blueprint_from_remote_source/sample_blueprint_from_remote_source.json b/blueprints/sample_blueprint_from_remote_source/sample_blueprint_from_remote_source.json
index 93e0864b..2ee91357 100644
--- a/blueprints/sample_blueprint_from_remote_source/sample_blueprint_from_remote_source.json
+++ b/blueprints/sample_blueprint_from_remote_source/sample_blueprint_from_remote_source.json
@@ -1,7 +1,7 @@
{
"any_group_can_deploy": true,
"auto_historical_resources": false,
- "blueprint_image": null,
+ "blueprint_image": "/static/uploads/blueprints/348753.png",
"deployment_items": [
{
"all_environments_enabled": true,
@@ -15,6 +15,7 @@
"id": "BDI-ur30ucbi",
"name": "server",
"os_build": null,
+ "rate": null,
"restrict_applications": false,
"show_on_order_form": false,
"tier_type": "server"
@@ -24,9 +25,11 @@
"continue_on_failure": false,
"deploy_seq": 3,
"description": null,
+ "enabled": true,
"execute_in_parallel": false,
"id": "BDI-4kz9ngur",
"name": "Action added in Sample Blueprint From RemoteSource",
+ "rate": null,
"run_on_scale_up": true,
"show_on_order_form": false,
"tier_type": "plugin"
@@ -34,11 +37,12 @@
],
"description": "",
"favorited": false,
+ "icon": "348753.png",
"id": "BP-l4qt4nog",
"is_manageable": true,
"is_orderable": true,
"labels": [],
- "last_updated": "2024-02-02",
+ "last_updated": "2025-05-23",
"management_actions": [],
"maximum_version_required": "",
"minimum_version_required": "8.6",
@@ -46,7 +50,7 @@
"resource_name_template": null,
"resource_type": {
"icon": "",
- "id": "RT-at2r6e0j",
+ "id": "RT-czjq32w0",
"label": "Service",
"lifecycle": "ACTIVE",
"list_view_columns": [],
@@ -56,4 +60,4 @@
"sequence": 0,
"show_recipient_field_on_order_form": false,
"teardown_items": []
-}
+}
\ No newline at end of file
diff --git a/ui_extensions/azure_ad_group_import/__init__.py b/ui_extensions/azure_ad_group_import/__init__.py
new file mode 100755
index 00000000..3684825d
--- /dev/null
+++ b/ui_extensions/azure_ad_group_import/__init__.py
@@ -0,0 +1,3 @@
+# explicit list of extensions to be included instead of
+# the default of .py and .html only
+ALLOWED_XUI_EXTENSIONS = [".py", ".html", ".png", ".svg", ".rst"]
diff --git a/ui_extensions/azure_ad_group_import/azure_api.py b/ui_extensions/azure_ad_group_import/azure_api.py
new file mode 100755
index 00000000..2e70a6b8
--- /dev/null
+++ b/ui_extensions/azure_ad_group_import/azure_api.py
@@ -0,0 +1,41 @@
+import requests
+from msal import ConfidentialClientApplication
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+def get_access_token(client_id, client_secret, tenant_id):
+ app = ConfidentialClientApplication(
+ client_id=client_id,
+ authority=f"https://login.microsoftonline.com/{tenant_id}",
+ client_credential=client_secret
+ )
+ result = app.acquire_token_for_client(scopes=["https://graph.microsoft.com/.default"])
+ if "access_token" not in result:
+ logger.error("Token acquisition failed: %s", result)
+ raise Exception(result)
+ return result["access_token"]
+
+
+def fetch_all_groups(token):
+ headers = {"Authorization": f"Bearer {token}"}
+ url = "https://graph.microsoft.com/v1.0/groups?$top=100"
+ all_groups = []
+
+ while url:
+ response = requests.get(url, headers=headers)
+ response.raise_for_status()
+ data = response.json()
+ all_groups.extend(data.get("value", []))
+ url = data.get("@odata.nextLink")
+
+ return all_groups
+
+
+def get_group_by_id(group_id, token):
+ headers = {"Authorization": f"Bearer {token}"}
+ url = f"https://graph.microsoft.com/v1.0/groups/{group_id}"
+ response = requests.get(url, headers=headers)
+ response.raise_for_status()
+ return response.json()
diff --git a/ui_extensions/azure_ad_group_import/config.py b/ui_extensions/azure_ad_group_import/config.py
new file mode 100755
index 00000000..277cb533
--- /dev/null
+++ b/ui_extensions/azure_ad_group_import/config.py
@@ -0,0 +1,6 @@
+from utilities.logger import ThreadLogger
+
+logger = ThreadLogger(__name__)
+
+def run_config(xui_version):
+ logger.info(f"Azure AD Group s XUI version {xui_version} loaded.")
diff --git a/ui_extensions/azure_ad_group_import/templates/admin_page.html b/ui_extensions/azure_ad_group_import/templates/admin_page.html
new file mode 100755
index 00000000..6cf6f97b
--- /dev/null
+++ b/ui_extensions/azure_ad_group_import/templates/admin_page.html
@@ -0,0 +1,12 @@
+{% extends "base.html" %}
+{% load static %}
+{% load helper_tags %}
+{% load tab_tags %}
+{% block topnav %}Azure AD Groups{% endblock %}
+{% block title %}Azure AD Groups{% endblock %}
+
+{% block content %}
+ Admin
+
Azure AD Groups V2
+ {% include "azure_ad_group_import/templates/tab-groups.html" %}
+{% endblock %}
diff --git a/ui_extensions/azure_ad_group_import/templates/group-detail.html b/ui_extensions/azure_ad_group_import/templates/group-detail.html
new file mode 100755
index 00000000..b4487d59
--- /dev/null
+++ b/ui_extensions/azure_ad_group_import/templates/group-detail.html
@@ -0,0 +1,19 @@
+{% extends "base.html" %}
+{% load static %}
+{% load helper_tags %}
+{% load tab_tags %}
+{% block topnav %}Azure AD Group Detail{% endblock %}
+{% block title %}Azure AD Group Detail{% endblock %}
+{% block content %}
+
+
Azure AD Group Detail
+
+ {% if error %}
+
{{ error }}
+ {% else %}
+
{{ group|safe }}
+ {% endif %}
+
+
← Back to Group List
+
+{% endblock %}
diff --git a/ui_extensions/azure_ad_group_import/templates/tab-groups.html b/ui_extensions/azure_ad_group_import/templates/tab-groups.html
new file mode 100755
index 00000000..76163bda
--- /dev/null
+++ b/ui_extensions/azure_ad_group_import/templates/tab-groups.html
@@ -0,0 +1,150 @@
+{% extends "base.html" %}
+{% load static %}
+{% load helper_tags %}
+{% load tab_tags %}
+{% block topnav %}Azure AD Groups{% endblock %}
+{% block title %}Azure AD Groups{% endblock %}
+{% block content %}
+
+
Azure AD Groups
+
+ {% if error %}
+
{{ error }}
+ {% endif %}
+
+
+
+ {% if groups %}
+
+
+
+ | Azure Group |
+ CMP Group |
+ CMP Group Type |
+ CMP Parent Group |
+ Add to CMP |
+
+
+
+ {% for group in groups %}
+
+ |
+
+ {{ group.displayName }}
+
+ |
+ {% if group.cmp_group %}
+
+
+ {{ group.cmp_group.name }}
+
+ |
+ {{ group.cmp_group.type.group_type }} |
+ {% if group.cmp_group.parent %}{{ group.cmp_group.parent.name }}{% else %}-{% endif %} |
+ ✔ {{ group.cmp_group.id }} |
+ {% else %}
+ Not in CMP |
+
+ |
+
+
+ |
+
+
+
+ |
+ {% endif %}
+
+ {% endfor %}
+
+
+
+
CMP Groups not in Azure AD
+
+
+
+ | CMP Group ID |
+ CMP Group Name |
+ CMP Group Parent |
+ CMP Group Type |
+
+
+
+ {% for g in cmp_only_groups %}
+
+ | {{ g.id }} |
+
+
+ {{ g.name }}
+
+ |
+ {% if g.parent %}{{ g.parent.name }}{% else %}-{% endif %} |
+ {{ g.type.group_type }} |
+
+ {% endfor %}
+
+
+
+ {% endif %}
+
+
+
+{% endblock %}
+
+oparlak
+
diff --git a/ui_extensions/azure_ad_group_import/urls.py b/ui_extensions/azure_ad_group_import/urls.py
new file mode 100755
index 00000000..a15227ac
--- /dev/null
+++ b/ui_extensions/azure_ad_group_import/urls.py
@@ -0,0 +1,10 @@
+from django.conf.urls import url
+from xui.azure_ad_group_import import views
+
+
+
+xui_urlpatterns = [
+ url(r'^azure_ad_group_import/$', views.ad_group_list, name='azure_ad_groups_list'),
+ url(r'^azure_ad_group_import/create_cmp_group/$', views.create_cmp_group, name='create_cmp_group'),
+ url(r'^azure_ad_group_import/(?P[a-zA-Z0-9-]+)/$', views.group_detail, name='azure_group_detail'),
+]
diff --git a/ui_extensions/azure_ad_group_import/utils.py b/ui_extensions/azure_ad_group_import/utils.py
new file mode 100755
index 00000000..b4c21fae
--- /dev/null
+++ b/ui_extensions/azure_ad_group_import/utils.py
@@ -0,0 +1,13 @@
+from accounts.models import Group
+
+
+def get_cmp_group_map():
+ return {g.name: g for g in Group.objects.all()}
+
+
+def get_unmatched_cmp_groups(cmp_group_map, azure_groups):
+ azure_names = [g.get("displayName") for g in azure_groups]
+ return [
+ g for name, g in cmp_group_map.items()
+ if name not in azure_names
+ ]
\ No newline at end of file
diff --git a/ui_extensions/azure_ad_group_import/views.py b/ui_extensions/azure_ad_group_import/views.py
new file mode 100755
index 00000000..d8e57653
--- /dev/null
+++ b/ui_extensions/azure_ad_group_import/views.py
@@ -0,0 +1,120 @@
+import json
+import logging
+from datetime import datetime
+from django.shortcuts import render
+from django.http import JsonResponse
+from django.views.decorators.csrf import csrf_exempt
+from extensions.views import admin_extension
+from utilities.permissions import cbadmin_required
+from accounts.models import Group, GroupType
+from resourcehandlers.azure_arm.models import AzureARMHandler
+
+from .azure_api import get_access_token, fetch_all_groups, get_group_by_id
+from .utils import get_cmp_group_map, get_unmatched_cmp_groups
+
+logger = logging.getLogger(__name__)
+
+
+@admin_extension(
+ title="Azure AD Groups V2",
+ description="View Azure AD Groups from a selected Azure Resource Handler and map them to CMP Groups"
+)
+@cbadmin_required
+def ad_group_list(request):
+ selected_rh_id = request.POST.get("resource_handler")
+ azure_groups = []
+ error = None
+ selected_rh_id_int = None
+
+ all_rhs = AzureARMHandler.objects.all().order_by("name")
+ group_types = GroupType.objects.all().order_by("group_type")
+ parent_groups = Group.objects.all().order_by("name")
+
+ if selected_rh_id:
+ try:
+ selected_rh_id_int = int(selected_rh_id)
+ rh = AzureARMHandler.objects.get(id=selected_rh_id_int)
+ token = get_access_token(rh.client_id, rh.secret, rh.azure_tenant_id)
+ azure_groups = fetch_all_groups(token)
+ except Exception as e:
+ error = str(e)
+ logger.exception("Failed to retrieve Azure AD groups")
+
+ cmp_groups = get_cmp_group_map()
+ groups = []
+
+ for g in azure_groups:
+ g["cmp_group"] = cmp_groups.get(g.get("displayName"))
+ groups.append(g)
+
+ unmatched_cmp_groups = get_unmatched_cmp_groups(cmp_groups, azure_groups)
+
+ return render(request, "azure_ad_group_import/templates/tab-groups.html", {
+ "groups": groups,
+ "cmp_only_groups": unmatched_cmp_groups,
+ "error": error,
+ "resource_handlers": all_rhs,
+ "selected_rh_id": selected_rh_id_int,
+ "group_types": group_types,
+ "parent_groups": parent_groups,
+ })
+
+
+@cbadmin_required
+def group_detail(request, group_id):
+ try:
+ rh = AzureARMHandler.objects.first()
+ if not rh:
+ raise Exception("No Azure Resource Handler configured.")
+
+ token = get_access_token(rh.client_id, rh.secret, rh.azure_tenant_id)
+ group_data = json.dumps(get_group_by_id(group_id, token), indent=2)
+
+ return render(request, "azure_ad_group_import/templates/group-detail.html", {
+ "group": group_data
+ })
+
+ except Exception as e:
+ logger.exception("Failed to fetch group details")
+ return render(request, "azure_ad_group_import/templates/group-detail.html", {
+ "error": str(e),
+ "group": {}
+ })
+
+
+@csrf_exempt
+@cbadmin_required
+def create_cmp_group(request):
+ if request.method == "POST":
+ try:
+ data = json.loads(request.body)
+ name = data.get("name")
+ group_type_id = data.get("group_type_id")
+ parent_group_id = data.get("parent_group_id")
+
+ if not name:
+ return JsonResponse({"error": "Missing group name"}, status=400)
+
+ group_type = GroupType.objects.get(id=group_type_id)
+ parent_group = Group.objects.get(id=parent_group_id) if parent_group_id else None
+
+ user = request.user
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
+ description = f"Imported from Azure AD on {timestamp} by {user}"
+
+ group, created = Group.objects.get_or_create(
+ name=name,
+ defaults={
+ "type": group_type,
+ "parent": parent_group,
+ "description": description
+ }
+ )
+
+ return JsonResponse({"status": "created" if created else "exists"})
+
+ except Exception as e:
+ logger.exception("Failed to create CMP group")
+ return JsonResponse({"error": str(e)}, status=500)
+
+ return JsonResponse({"error": "Invalid method"}, status=405)
diff --git a/ui_extensions/order_report_xui/__init__.py b/ui_extensions/order_report_xui/__init__.py
new file mode 100755
index 00000000..e69de29b
diff --git a/ui_extensions/order_report_xui/forms.py b/ui_extensions/order_report_xui/forms.py
new file mode 100755
index 00000000..41c9fd5d
--- /dev/null
+++ b/ui_extensions/order_report_xui/forms.py
@@ -0,0 +1,74 @@
+from django import forms
+from common.forms import C2Form
+from common.methods import mkDateTime
+from datetime import datetime, timedelta
+
+class SummaryRangeForm(forms.Form):
+ def __init__(self, *args, **kwargs):
+ super(SummaryRangeForm, self).__init__(*args, **kwargs)
+
+ now = datetime.now()
+ options = [
+ ("30d", "Last 30 Days"),
+ ("12m", "Last 12 Months"),
+ ("all", "All Time"),
+ ]
+
+ for i in range(12):
+ month = (now - timedelta(days=i*30)).replace(day=1)
+ label = month.strftime("%B %Y") # e.g., "April 2025"
+ value = month.strftime("month-%Y-%m") # e.g., "month-2025-04"
+ options.append((value, label))
+
+ self.fields['range'] = forms.ChoiceField(
+ choices=options,
+ required=False,
+ label="Time Range",
+ widget=forms.Select(attrs={'onchange': 'this.form.submit();'})
+ )
+
+class OrderRangeForm(C2Form):
+ STATUS_CHOICES = [
+ ("SUCCESS", "SUCCESS"),
+ ("FAILURE", "FAILURE"),
+ ("DENIED", "DENIED")
+ ]
+
+ start_date = forms.CharField(
+ label="Start Date",
+ required=True,
+ widget=forms.TextInput(attrs={'class': 'render_as_datepicker'}),
+ )
+
+ end_date = forms.CharField(
+ label="End Date",
+ required=True,
+ widget=forms.TextInput(attrs={'class': 'render_as_datepicker'}),
+ )
+
+ status = forms.MultipleChoiceField(
+ label="Status Filter",
+ required=True,
+ widget=forms.CheckboxSelectMultiple,
+ choices=STATUS_CHOICES,
+ )
+
+ def clean_start_date(self):
+ raw = self.cleaned_data['start_date']
+ return mkDateTime(str(raw).split(" ")[0])
+
+ def clean_end_date(self):
+ raw = self.cleaned_data['end_date']
+ return mkDateTime(str(raw).split(" ")[0])
+
+ def clean_status(self):
+ return self.cleaned_data['status']
+
+ def clean(self):
+ start_date = self.cleaned_data['start_date']
+ end_date = self.cleaned_data['end_date']
+
+ if (end_date - start_date).total_seconds() < 0:
+ raise forms.ValidationError("Start date can't be after end date")
+
+ return self.cleaned_data
diff --git a/ui_extensions/order_report_xui/order_report_xui.json b/ui_extensions/order_report_xui/order_report_xui.json
new file mode 100644
index 00000000..d067c2a9
--- /dev/null
+++ b/ui_extensions/order_report_xui/order_report_xui.json
@@ -0,0 +1,21 @@
+{
+ "description": null,
+ "enabled": true,
+ "icon_url": "",
+ "id": "XUI-jy50b4dz",
+ "label": "Order Report",
+ "last_updated": "2025-04-18",
+ "maximum_version_required": "",
+ "minimum_version_required": "8.6",
+ "name": "order_report_xui",
+ "package_contents": [
+ "order_report_xui/views.py",
+ "order_report_xui/__init__.py",
+ "order_report_xui/forms.py",
+ "order_report_xui/urls.py",
+ "order_report_xui/templates/table.html",
+ "order_report_xui/static/order_status.png",
+ "order_report_xui/static/order_summary.png"
+ ],
+ "version": ""
+}
\ No newline at end of file
diff --git a/ui_extensions/order_report_xui/static/order_status.png b/ui_extensions/order_report_xui/static/order_status.png
new file mode 100644
index 00000000..887c7990
Binary files /dev/null and b/ui_extensions/order_report_xui/static/order_status.png differ
diff --git a/ui_extensions/order_report_xui/static/order_summary.png b/ui_extensions/order_report_xui/static/order_summary.png
new file mode 100644
index 00000000..a83e9895
Binary files /dev/null and b/ui_extensions/order_report_xui/static/order_summary.png differ
diff --git a/ui_extensions/order_report_xui/templates/table.html b/ui_extensions/order_report_xui/templates/table.html
new file mode 100755
index 00000000..41f7634f
--- /dev/null
+++ b/ui_extensions/order_report_xui/templates/table.html
@@ -0,0 +1,39 @@
+
+{% extends "reports/simple_base.html" %}
+{% load helper_tags %}
+
+{% block report_content %}
+ {% if show_table %}
+ {{ table_caption }}
+
+
+
+
+
+
+ {% for heading in column_headings %}
+ | {{ heading|safe }} |
+ {% endfor %}
+
+
+
+ {% for row in rows %}
+
+ {% for col in row %}
+ | {{ col|safe }} |
+ {% endfor %}
+
+ {% endfor %}
+
+
+ {% endif %}
+{% endblock %}
diff --git a/ui_extensions/order_report_xui/urls.py b/ui_extensions/order_report_xui/urls.py
new file mode 100644
index 00000000..7f7b602c
--- /dev/null
+++ b/ui_extensions/order_report_xui/urls.py
@@ -0,0 +1,7 @@
+from django.conf.urls import url
+from xui.order_report_xui import views
+
+xui_urlpatterns = [
+ url(r'^order_report_xui/order-summary/$', views.order_summary_status_report, name='order_summary_status_report'),
+ url(r'^order_report_xui/order-summary/export/$', views.order_summary_export, name='order_summary_export'),
+]
\ No newline at end of file
diff --git a/ui_extensions/order_report_xui/views.py b/ui_extensions/order_report_xui/views.py
new file mode 100755
index 00000000..25b82bea
--- /dev/null
+++ b/ui_extensions/order_report_xui/views.py
@@ -0,0 +1,208 @@
+import json, os
+
+from datetime import datetime, timedelta
+from django.core.exceptions import PermissionDenied
+from django.shortcuts import render
+from orders.models import Order
+from common.methods import last_month_day_info
+from extensions.views import report_extension
+from .forms import OrderRangeForm, SummaryRangeForm
+from utilities.logger import ThreadLogger
+from django.db.models import Count
+from dateutil.relativedelta import relativedelta
+from django.http import HttpResponse
+
+
+from tempfile import NamedTemporaryFile
+from reportengines.internal.export_utils import CSVWrapper
+from wsgiref.util import FileWrapper
+
+logger = ThreadLogger("__name__")
+
+@report_extension(title='Order Status', thumbnail='order_status.png')
+def order_information_table_report(request):
+ profile = request.get_user_profile()
+ if not profile.super_admin:
+ raise PermissionDenied('Only super admins can view this report.')
+
+ # Default date range from 1st to last day of last month, as datetime (not .date())
+ start, end = last_month_day_info()
+
+ show_table = False
+ column_headings = ['Date', 'Order Id', 'Blueprint Name', 'Status', 'Owner']
+ rows = []
+
+ if request.method == 'GET':
+ form = OrderRangeForm(initial=dict(start_date=start, end_date=end, status=["SUCCESS"]))
+ else:
+ show_table = True
+ form = OrderRangeForm(request.POST)
+ if form.is_valid():
+ start = form.cleaned_data['start_date']
+ end = form.cleaned_data['end_date']
+ sel_status = form.cleaned_data['status']
+ logger.info(f"selected status = {sel_status}")
+
+ for o in Order.objects.all():
+ if start <= o.create_date <= end and o.status in sel_status:
+ bp_name = getattr(o.blueprint, 'name', o.name)
+ rows.append((
+ o.create_date.date(), # This stays as date for display
+ o.id,
+ bp_name,
+ o.status,
+ o.owner.user.username
+ ))
+
+ return render(request, 'order_report_xui/templates/table.html', dict(
+ pagetitle='Order Status',
+ report_slug='Order Status',
+ intro="Displays order summary within selected date range and status.",
+ show_table=show_table,
+ table_caption='Orders created between {} and {}'.format(start.date(), end.date()),
+ form=form,
+ column_headings=column_headings,
+ rows=rows,
+ sort_by_column=0,
+ unsortable_column_indices=[],
+ ))
+
+@report_extension(title='Order Status Summary',thumbnail='order_summary.png')
+def order_summary_status_report(request):
+ today = datetime.now()
+ start_date = None
+ end_date = None
+
+ form = SummaryRangeForm(request.GET or request.POST)
+ selected_range = "30d"
+
+ if form.is_valid():
+ selected_range = form.cleaned_data.get("range", "30d")
+
+ logger.info(f"Request.GET = {request.GET}")
+ logger.info(f"Request.POST = {request.POST}")
+
+
+
+ if selected_range == "30d":
+ start_date = today - timedelta(days=30)
+ elif selected_range == "12m":
+ start_date = today - relativedelta(months=12)
+ elif selected_range.startswith("month-"):
+ try:
+ _, year, month = selected_range.split("-")
+ start_date = datetime(int(year), int(month), 1)
+ end_date = (start_date + relativedelta(months=1)) - timedelta(seconds=1)
+ except:
+ pass # fallback to None
+ # No filtering for 'all'
+
+ queryset = Order.objects.all()
+ if start_date:
+ queryset = queryset.filter(create_date__gte=start_date)
+ if end_date:
+ queryset = queryset.filter(create_date__lt=end_date)
+
+ items = (
+ queryset.values('name', 'status')
+ .annotate(count=Count('id'))
+ .order_by('-count')
+ )
+
+ column_headings = ["Name", "Status", "Count"]
+ rows = [
+ [item["name"] or "", item["status"], item["count"]]
+ for item in items
+ ]
+
+ context = {
+ "report_slug": "order_summary_status_report",
+ "pagetitle": "Order Status Summary",
+ "report_name": "Order Summary Information",
+ "form": form,
+ "column_headings": column_headings,
+ "rows": rows,
+ "show_table": True,
+ "sort_by_column": 2,
+ "unsortable_column_indices": [],
+ }
+ logger.info(f"Filtering by: {selected_range}, Start: {start_date}, End: {end_date}")
+
+
+ return render(request, "order_report_xui/templates/table.html", context)
+def order_summary_export(request):
+ today = datetime.now()
+ start_date = None
+ end_date = None
+
+ form = SummaryRangeForm(request.GET or request.POST)
+ selected_range = "30d"
+
+ if form.is_valid():
+ selected_range = form.cleaned_data.get("range", "30d")
+
+ logger.info(f"Request.GET = {request.GET}")
+ logger.info(f"Request.POST = {request.POST}")
+
+
+
+ if selected_range == "30d":
+ start_date = today - timedelta(days=30)
+ elif selected_range == "12m":
+ start_date = today - relativedelta(months=12)
+ elif selected_range.startswith("month-"):
+ try:
+ _, year, month = selected_range.split("-")
+ start_date = datetime(int(year), int(month), 1)
+ end_date = (start_date + relativedelta(months=1)) - timedelta(seconds=1)
+ except:
+ pass # fallback to None
+ # No filtering for 'all'
+
+ queryset = Order.objects.all()
+ if start_date:
+ queryset = queryset.filter(create_date__gte=start_date)
+ if end_date:
+ queryset = queryset.filter(create_date__lt=end_date)
+
+ items = (
+ queryset.values('name', 'status')
+ .annotate(count=Count('id'))
+ .order_by('-count')
+ )
+
+ column_headings = ["Name", "Status", "Count"]
+ rows = [
+ [item["name"] or "", item["status"], item["count"]]
+ for item in items
+ ]
+
+ context = {
+ "report_slug": "order_summary_export",
+ "report_name": "Order Summary Export",
+ "form": form,
+ "column_headings": column_headings,
+ "rows": rows,
+ "show_table": True,
+ "sort_by_column": 2,
+ "unsortable_column_indices": [],
+ }
+ logger.info(f"Filtering by: {selected_range}, Start: {start_date}, End: {end_date}")
+
+ if 'export-data' in request.POST.get('action'):
+ writer = CSVWrapper()
+ writer.writerow(column_headings)
+ for row in items:
+ writer.writerow([row['name'], row['status'], row['count']])
+
+ f = NamedTemporaryFile('w', delete=False)
+ f.write(writer.close_and_return_as_string())
+ f.close()
+ filename = f.name
+ wrapper = FileWrapper(open(filename,'rb'))
+ export_filename = f'order_summary_{selected_range}.csv'
+ response = HttpResponse(wrapper, content_type='text/plain')
+ response['Content-Length'] = os.path.getsize(filename)
+ response['Content-Disposition'] = f'attachment; filename={export_filename}'
+ os.remove(f.name)
+ return response
\ No newline at end of file