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 %} + +
+ {% csrf_token %} +
+ + + +
+
+ + {% if groups %} + + + + + + + + + + + + {% for group in groups %} + + + {% if group.cmp_group %} + + + + + {% else %} + + + + + {% endif %} + + {% endfor %} + +
Azure GroupCMP GroupCMP Group TypeCMP Parent GroupAdd to CMP
+ + {{ group.displayName }} + + + + {{ 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 }}Not in CMP +
+ +
+ +
+
+ + + + +
+ +
CMP Groups not in Azure AD
+ + + + + + + + + + + {% for g in cmp_only_groups %} + + + + + + + {% endfor %} + + +
CMP Group IDCMP Group NameCMP Group ParentCMP Group Type
{{ g.id }} + + {{ g.name }} + + {% if g.parent %}{{ g.parent.name }}{% else %}-{% endif %}{{ g.type.group_type }}
+ {% 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 }} + +
+ {% csrf_token %} + + + +
+ + + + + {% for heading in column_headings %} + + {% endfor %} + + + + {% for row in rows %} + + {% for col in row %} + + {% endfor %} + + {% endfor %} + +
{{ heading|safe }}
{{ col|safe }}
+ {% 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