From 5d918eac98db6796cc7c9a448440becb0dbf5ebb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Tue, 28 Oct 2025 12:24:25 +0100 Subject: [PATCH] feature/CRUD for Rate Limiting --- .../static/consumers/css/consumers.css | 166 ++++++ .../static/consumers/js/consumers.js | 88 ++- .../consumers/templates/consumers/detail.html | 348 +++++++++--- apimanager/consumers/templatetags/__init__.py | 1 + .../consumers/templatetags/consumer_extras.py | 96 ++++ apimanager/consumers/views.py | 530 +++++++++++++----- 6 files changed, 1003 insertions(+), 226 deletions(-) create mode 100644 apimanager/consumers/templatetags/__init__.py create mode 100644 apimanager/consumers/templatetags/consumer_extras.py diff --git a/apimanager/consumers/static/consumers/css/consumers.css b/apimanager/consumers/static/consumers/css/consumers.css index 5d47940..eedabbd 100644 --- a/apimanager/consumers/static/consumers/css/consumers.css +++ b/apimanager/consumers/static/consumers/css/consumers.css @@ -182,3 +182,169 @@ font-size: 12px; padding: 6px 12px; } + +/* Rate Limiting CRUD Interface Styles */ +#rateLimitForm { + margin-bottom: 30px; +} + +#rateLimitForm .panel-heading { + background-color: #f8f9fa; + border-bottom: 1px solid #dee2e6; +} + +#rateLimitForm .panel-title { + color: #495057; + font-weight: bold; + font-size: 16px; +} + +#addRateLimitBtn { + background-color: #28a745; + border-color: #28a745; + font-weight: bold; +} + +#addRateLimitBtn:hover { + background-color: #218838; + border-color: #1e7e34; +} + +/* Rate limits table styling */ +#rateLimitsList .table { + margin-bottom: 0; +} + +#rateLimitsList .table th { + background-color: #f8f9fa; + color: #495057; + font-weight: bold; + font-size: 12px; + text-transform: uppercase; + border-top: none; +} + +#rateLimitsList .table td { + font-size: 13px; + vertical-align: middle; +} + +/* Action buttons styling */ +#rateLimitsList .btn-sm { + font-size: 11px; + padding: 4px 8px; + margin-right: 5px; +} + +#rateLimitsList .btn-primary { + background-color: #007bff; + border-color: #007bff; +} + +#rateLimitsList .btn-primary:hover { + background-color: #0056b3; + border-color: #0056b3; +} + +#rateLimitsList .btn-danger { + background-color: #dc3545; + border-color: #dc3545; +} + +#rateLimitsList .btn-danger:hover { + background-color: #c82333; + border-color: #bd2130; +} + +/* Form styling */ +#rateLimitFormElement .form-group label { + font-weight: bold; + color: #495057; + font-size: 13px; +} + +#rateLimitFormElement .form-control { + font-size: 13px; + border-radius: 3px; +} + +#rateLimitFormElement .btn { + margin-right: 10px; +} + +/* Empty state styling */ +.alert-info { + background-color: #d1ecf1; + border-color: #bee5eb; + color: #0c5460; +} + +.alert-info .glyphicon { + margin-right: 8px; +} + +/* Responsive table */ +@media (max-width: 1200px) { + #rateLimitsList .table-responsive { + font-size: 12px; + } + + #rateLimitsList .table th, + #rateLimitsList .table td { + padding: 8px 4px; + } + + #rateLimitsList .btn-sm { + font-size: 10px; + padding: 3px 6px; + margin: 1px; + display: block; + width: 100%; + margin-bottom: 2px; + } +} + +@media (max-width: 768px) { + #rateLimitForm .col-xs-6 { + margin-bottom: 10px; + } + + #rateLimitsList .table { + font-size: 11px; + } + + #addRateLimitBtn { + width: 100%; + margin-bottom: 15px; + } +} + +/* Animation for form show/hide */ +#rateLimitForm { + transition: all 0.3s ease-in-out; +} + +/* Highlight new/updated rows */ +.table tr.highlight { + background-color: #d4edda; + transition: background-color 2s ease-out; +} + +/* Loading states */ +.btn[disabled] { + opacity: 0.6; + cursor: not-allowed; +} + +/* Panel improvements */ +.panel-default > .panel-heading { + background-image: none; + background-color: #f8f9fa; +} + +.panel-default { + border-color: #dee2e6; + box-shadow: + 0 1px 3px rgba(0, 0, 0, 0.12), + 0 1px 2px rgba(0, 0, 0, 0.24); +} diff --git a/apimanager/consumers/static/consumers/js/consumers.js b/apimanager/consumers/static/consumers/js/consumers.js index cf25624..7d83d47 100644 --- a/apimanager/consumers/static/consumers/js/consumers.js +++ b/apimanager/consumers/static/consumers/js/consumers.js @@ -2,8 +2,8 @@ $(document).ready(function ($) { // Handle datetime-local inputs for rate limiting function initializeDateTimeFields() { // Set default values for datetime fields if they're empty - var fromDateField = $("#id_from_date"); - var toDateField = $("#id_to_date"); + var fromDateField = $("#from_date"); + var toDateField = $("#to_date"); // If fields are empty, set default values if (!fromDateField.val()) { @@ -27,33 +27,35 @@ $(document).ready(function ($) { var toDate = $("[data-to-date]").data("to-date"); if (fromDate && fromDate !== "1099-12-31T23:00:00Z") { - $("#id_from_date").val(convertISOToLocalDateTime(fromDate)); + $("#from_date").val(convertISOToLocalDateTime(fromDate)); } if (toDate && toDate !== "1099-12-31T23:00:00Z") { - $("#id_to_date").val(convertISOToLocalDateTime(toDate)); + $("#to_date").val(convertISOToLocalDateTime(toDate)); } } // Form validation function validateRateLimitingForm() { - $("form").on("submit", function (e) { + $("#rateLimitFormElement").on("submit", function (e) { var hasError = false; var errorMessage = ""; // Check if any limit values are negative (except -1 which means unlimited) - $('input[type="number"]').each(function () { - var value = parseInt($(this).val()); - if (value < -1) { - hasError = true; - errorMessage += - "Rate limit values must be -1 (unlimited) or positive numbers.\n"; - return false; - } - }); + $(this) + .find('input[type="number"]') + .each(function () { + var value = parseInt($(this).val()); + if (isNaN(value) || value < -1) { + hasError = true; + errorMessage += + "Rate limit values must be -1 (unlimited) or positive numbers.\n"; + return false; + } + }); // Check date range - var fromDate = new Date($("#id_from_date").val()); - var toDate = new Date($("#id_to_date").val()); + var fromDate = new Date($("#from_date").val()); + var toDate = new Date($("#to_date").val()); if (fromDate && toDate && fromDate > toDate) { hasError = true; @@ -65,6 +67,52 @@ $(document).ready(function ($) { e.preventDefault(); return false; } + + // Handle form submission via AJAX + e.preventDefault(); + submitRateLimitForm(); + }); + } + + // Submit rate limit form via AJAX + function submitRateLimitForm() { + var form = $("#rateLimitFormElement"); + var formData = new FormData(form[0]); + var submitBtn = $("#submitBtn"); + var originalText = submitBtn.text(); + + // Disable submit button and show loading + submitBtn.prop("disabled", true).text("Saving..."); + + $.ajax({ + url: window.location.pathname, + type: "POST", + data: formData, + processData: false, + contentType: false, + headers: { + "X-CSRFToken": $("[name=csrfmiddlewaretoken]").val(), + }, + success: function (response) { + if (response.success) { + // Hide form and reload page to show updated data + hideRateLimitForm(); + window.location.reload(); + } else { + alert("Error: " + (response.error || "Unknown error occurred")); + } + }, + error: function (xhr, status, error) { + var errorMessage = "Error saving rate limit"; + if (xhr.responseJSON && xhr.responseJSON.error) { + errorMessage = xhr.responseJSON.error; + } + alert(errorMessage); + }, + complete: function () { + // Re-enable submit button + submitBtn.prop("disabled", false).text(originalText); + }, }); } @@ -98,3 +146,11 @@ $(document).ready(function ($) { ); }); }); + +// Global functions are now defined inline in the template +// This file now only contains form validation and initialization + +// Refresh rate limits list +function refreshRateLimits() { + window.location.reload(); +} diff --git a/apimanager/consumers/templates/consumers/detail.html b/apimanager/consumers/templates/consumers/detail.html index 23ccfea..375abd6 100644 --- a/apimanager/consumers/templates/consumers/detail.html +++ b/apimanager/consumers/templates/consumers/detail.html @@ -1,6 +1,7 @@ {% extends 'base.html' %} {% load humanize static %} {% load i18n %} +{% load consumer_extras %} {% block page_title %}{{ block.super }} / Consumer {{ consumer.app_name }}{% endblock page_title %} @@ -13,96 +14,169 @@

{% trans "Rate Limiting Configuration" %}

-
- {% csrf_token %} - {{ form.consumer_id }} - {% if form.non_field_errors %} -
- {{ form.non_field_errors }} -
- {% endif %} -
-
- {% if form.from_date.errors %}
{{ form.from_date.errors }}
{% endif %} -
- {{ form.from_date.label_tag }} - {{ form.from_date }} -
-
-
- {% if form.to_date.errors %}
{{ form.to_date.errors }}
{% endif %} -
- {{ form.to_date.label_tag }} - {{ form.to_date }} -
-
+ +
+
+
+
-
-
- {% if form.per_second_call_limit.errors %}
{{ form.per_second_call_limit.errors }}
{% endif %} -
- {{ form.per_second_call_limit.label_tag }} - {{ form.per_second_call_limit }} -
+ + @@ -278,6 +352,118 @@ {% endif %} }); + // Global functions for CRUD operations - attached to window for global access + window.showAddRateLimitForm = function() { + // Reset form for new entry + $("#rateLimitForm").show(); + $("#formTitle").text("Add New Rate Limit"); + $("#formAction").val("create"); + $("#rateLimitId").val(""); + + // Set default values + $("#from_date").val("2024-01-01T00:00"); + $("#to_date").val("2026-01-01T00:00"); + $("#per_second_call_limit").val("-1"); + $("#per_minute_call_limit").val("-1"); + $("#per_hour_call_limit").val("-1"); + $("#per_day_call_limit").val("-1"); + $("#per_week_call_limit").val("-1"); + $("#per_month_call_limit").val("-1"); + + $("#submitBtn").text("Create Rate Limit"); + + // Scroll to form + $("html, body").animate({ + scrollTop: $("#rateLimitForm").offset().top - 20 + }, 500); + }; + + window.hideRateLimitForm = function() { + $("#rateLimitForm").hide(); + }; + + window.editRateLimit = function(rateLimitingId, fromDate, toDate, perSecond, perMinute, perHour, perDay, perWeek, perMonth) { + // Show form for editing + $("#rateLimitForm").show(); + $("#formTitle").text("Edit Rate Limit"); + $("#formAction").val("update"); + $("#rateLimitId").val(rateLimitingId); + + // Convert ISO dates to datetime-local format + function convertISOToLocal(isoString) { + if (!isoString || isoString === "1099-12-31T23:00:00Z") return "2024-01-01T00:00"; + try { + var date = new Date(isoString); + var year = date.getFullYear(); + var month = String(date.getMonth() + 1).padStart(2, "0"); + var day = String(date.getDate()).padStart(2, "0"); + var hours = String(date.getHours()).padStart(2, "0"); + var minutes = String(date.getMinutes()).padStart(2, "0"); + return year + "-" + month + "-" + day + "T" + hours + ":" + minutes; + } catch (e) { + return "2024-01-01T00:00"; + } + } + + // Populate form fields + $("#from_date").val(convertISOToLocal(fromDate)); + $("#to_date").val(convertISOToLocal(toDate)); + $("#per_second_call_limit").val(perSecond); + $("#per_minute_call_limit").val(perMinute); + $("#per_hour_call_limit").val(perHour); + $("#per_day_call_limit").val(perDay); + $("#per_week_call_limit").val(perWeek); + $("#per_month_call_limit").val(perMonth); + + $("#submitBtn").text("Update Rate Limit"); + + // Scroll to form + $("html, body").animate({ + scrollTop: $("#rateLimitForm").offset().top - 20 + }, 500); + }; + + window.deleteRateLimit = function(rateLimitingId) { + console.log("Deleting rate limit with ID:", rateLimitingId); + if (!confirm("Are you sure you want to delete this rate limit? This action cannot be undone.")) { + return; + } + + // Create form data for delete request + var formData = new FormData(); + formData.append("action", "delete"); + formData.append("rate_limiting_id", rateLimitingId); + formData.append("csrfmiddlewaretoken", $("[name=csrfmiddlewaretoken]").val()); + + $.ajax({ + url: window.location.pathname, + type: "POST", + data: formData, + processData: false, + contentType: false, + headers: { + 'X-Requested-With': 'XMLHttpRequest' + }, + success: function(response) { + console.log("Delete response:", response); + if (response.success) { + // Reload page to show updated data + window.location.reload(); + } else { + alert("Error: " + (response.error || "Unknown error occurred")); + } + }, + error: function(xhr, status, error) { + console.error("Delete error:", xhr, status, error); + var errorMessage = "Error deleting rate limit"; + if (xhr.responseJSON && xhr.responseJSON.error) { + errorMessage = xhr.responseJSON.error; + } + alert(errorMessage); + } + }); + }; + // Global variables for refresh functionality let refreshInterval = null; let refreshCount = 0; diff --git a/apimanager/consumers/templatetags/__init__.py b/apimanager/consumers/templatetags/__init__.py new file mode 100644 index 0000000..58654ee --- /dev/null +++ b/apimanager/consumers/templatetags/__init__.py @@ -0,0 +1 @@ +# Template tags module for consumers app diff --git a/apimanager/consumers/templatetags/consumer_extras.py b/apimanager/consumers/templatetags/consumer_extras.py new file mode 100644 index 0000000..9e53104 --- /dev/null +++ b/apimanager/consumers/templatetags/consumer_extras.py @@ -0,0 +1,96 @@ +""" +Custom template filters for consumers app +""" + +from django import template +from django.conf import settings +from datetime import datetime +import logging + +register = template.Library() +logger = logging.getLogger(__name__) + + +@register.filter +def parse_iso_date(date_str, format_str="Y-m-d H:i"): + """ + Parse ISO date string and format it for display + Usage: {{ date_string|parse_iso_date:"Y-m-d H:i" }} + """ + if not date_str or date_str in ["", "null", "None", None]: + return "N/A" + + # Convert to string if it's not already + if not isinstance(date_str, str): + date_str = str(date_str) + + # List of common date formats to try + formats_to_try = [ + "%Y-%m-%dT%H:%M:%SZ", # 2024-01-01T12:00:00Z + "%Y-%m-%dT%H:%M:%S", # 2024-01-01T12:00:00 + "%Y-%m-%dT%H:%M:%S.%fZ", # 2024-01-01T12:00:00.000Z + "%Y-%m-%dT%H:%M:%S.%f", # 2024-01-01T12:00:00.000 + "%Y-%m-%d %H:%M:%S", # 2024-01-01 12:00:00 + settings.API_DATE_FORMAT_WITH_SECONDS, # From settings + ] + + # Try to parse with different formats + for fmt in formats_to_try: + try: + parsed_date = datetime.strptime(date_str, fmt) + # Convert Django date format to Python strftime format + django_to_python = { + "Y": "%Y", + "m": "%m", + "d": "%d", + "H": "%H", + "i": "%M", + "s": "%S", + } + + # Simple format conversion for common cases + python_format = format_str + for django_fmt, python_fmt in django_to_python.items(): + python_format = python_format.replace(django_fmt, python_fmt) + + return parsed_date.strftime(python_format) + except (ValueError, TypeError): + continue + + # Try using fromisoformat for Python 3.7+ + try: + # Handle timezone indicator + clean_date_str = date_str.replace("Z", "+00:00") + parsed_date = datetime.fromisoformat(clean_date_str.replace("Z", "")) + + # Convert format and return + python_format = format_str + django_to_python = { + "Y": "%Y", + "m": "%m", + "d": "%d", + "H": "%H", + "i": "%M", + "s": "%S", + } + for django_fmt, python_fmt in django_to_python.items(): + python_format = python_format.replace(django_fmt, python_fmt) + + return parsed_date.strftime(python_format) + except (ValueError, AttributeError): + pass + + # Last resort - return the original string or N/A + logger.warning(f"Could not parse date string: {date_str}") + return "Invalid Date" + + +@register.filter +def smart_default(value, default_value="N/A"): + """ + Smart default filter that handles various empty/null cases + Usage: {{ value|smart_default:"Default Value" }} + """ + if value is None or value == "" or value == "null" or value == "None": + return default_value + return value diff --git a/apimanager/consumers/views.py b/apimanager/consumers/views.py index dcadb7f..197a43d 100644 --- a/apimanager/consumers/views.py +++ b/apimanager/consumers/views.py @@ -12,7 +12,7 @@ from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin from django.urls import reverse from django.views.generic import TemplateView, RedirectView, FormView -from django.http import JsonResponse +from django.http import JsonResponse, HttpResponseRedirect from obp.api import API, APIError from base.filters import BaseFilter, FilterTime @@ -22,32 +22,36 @@ from .forms import ApiConsumersForm class FilterAppType(BaseFilter): """Filter consumers by application type""" - filter_type = 'app_type' + + filter_type = "app_type" def _apply(self, data, filter_value): - filtered = [x for x in data if x['app_type'] == filter_value] + filtered = [x for x in data if x["app_type"] == filter_value] return filtered class FilterEnabled(BaseFilter): """Filter consumers by enabled state""" - filter_type = 'enabled' + + filter_type = "enabled" def _apply(self, data, filter_value): - enabled = filter_value in ['true'] - filtered = [x for x in data if x['enabled'] == enabled] + enabled = filter_value in ["true"] + filtered = [x for x in data if x["enabled"] == enabled] return filtered class IndexView(LoginRequiredMixin, TemplateView): """Index view for consumers""" + template_name = "consumers/index.html" def scrub(self, consumers): """Scrubs data in the given consumers to adher to certain formats""" for consumer in consumers: - consumer['created'] = datetime.strptime( - consumer['created'], settings.API_DATE_FORMAT_WITH_SECONDS ) + consumer["created"] = datetime.strptime( + consumer["created"], settings.API_DATE_FORMAT_WITH_SECONDS + ) return consumers def compile_statistics(self, consumers): @@ -55,46 +59,50 @@ class IndexView(LoginRequiredMixin, TemplateView): unique_developer_email = {} unique_name = {} for consumer in consumers: - unique_developer_email[consumer['developer_email']] = True - unique_name[consumer['app_name']] = True + unique_developer_email[consumer["developer_email"]] = True + unique_name[consumer["app_name"]] = True unique_developer_email = unique_developer_email.keys() unique_name = unique_name.keys() statistics = { - 'consumers_num': len(consumers), - 'unique_developer_email_num': len(unique_developer_email), - 'unique_name_num': len(unique_name), + "consumers_num": len(consumers), + "unique_developer_email_num": len(unique_developer_email), + "unique_name_num": len(unique_name), } return statistics def get_context_data(self, **kwargs): context = super(IndexView, self).get_context_data(**kwargs) consumers = [] - sorted_consumers=[] - api = API(self.request.session.get('obp')) + sorted_consumers = [] + api = API(self.request.session.get("obp")) try: - limit = self.request.GET.get('limit', 50) - offset = self.request.GET.get('offset', 0) - urlpath = '/management/consumers?limit={}&offset={}'.format(limit, offset) + limit = self.request.GET.get("limit", 50) + offset = self.request.GET.get("offset", 0) + urlpath = "/management/consumers?limit={}&offset={}".format(limit, offset) consumers = api.get(urlpath) - if 'code' in consumers and consumers['code'] >= 400: - messages.error(self.request, consumers['message']) + if "code" in consumers and consumers["code"] >= 400: + messages.error(self.request, consumers["message"]) else: - consumers = FilterEnabled(context, self.request.GET)\ - .apply(consumers['consumers']) - consumers = FilterAppType(context, self.request.GET)\ - .apply(consumers) - consumers = FilterTime(context, self.request.GET, 'created')\ - .apply(consumers) + consumers = FilterEnabled(context, self.request.GET).apply( + consumers["consumers"] + ) + consumers = FilterAppType(context, self.request.GET).apply(consumers) + consumers = FilterTime(context, self.request.GET, "created").apply( + consumers + ) consumers = self.scrub(consumers) sorted_consumers = sorted( - consumers, key=lambda consumer: consumer['created'], reverse=True) + consumers, key=lambda consumer: consumer["created"], reverse=True + ) - context.update({ - 'consumers': sorted_consumers, - 'limit': limit, - 'offset': offset, - 'statistics': self.compile_statistics(consumers), - }) + context.update( + { + "consumers": sorted_consumers, + "limit": limit, + "offset": offset, + "statistics": self.compile_statistics(consumers), + } + ) except APIError as err: messages.error(self.request, err) @@ -103,66 +111,256 @@ class IndexView(LoginRequiredMixin, TemplateView): class DetailView(LoginRequiredMixin, FormView): """Detail view for a consumer""" + form_class = ApiConsumersForm template_name = "consumers/detail.html" def dispatch(self, request, *args, **kwargs): - self.api = API(request.session.get('obp')) + self.api = API(request.session.get("obp")) return super(DetailView, self).dispatch(request, *args, **kwargs) def get_form(self, *args, **kwargs): form = super(DetailView, self).get_form(*args, **kwargs) - form.fields['consumer_id'].initial = self.kwargs['consumer_id'] + form.fields["consumer_id"].initial = self.kwargs["consumer_id"] # Get call limits data to populate form - api = API(self.request.session.get('obp')) + api = API(self.request.session.get("obp")) try: - call_limits_urlpath = '/management/consumers/{}/consumer/call-limits'.format(self.kwargs['consumer_id']) + call_limits_urlpath = ( + "/management/consumers/{}/consumer/call-limits".format( + self.kwargs["consumer_id"] + ) + ) call_limits = api.get(call_limits_urlpath) - if not ('code' in call_limits and call_limits['code'] >= 400): + if not ("code" in call_limits and call_limits["code"] >= 400): # Populate form with existing rate limiting data - if 'from_date' in call_limits and call_limits['from_date']: + if "from_date" in call_limits and call_limits["from_date"]: try: - from_date_str = call_limits['from_date'].replace('Z', '') + from_date_str = call_limits["from_date"].replace("Z", "") # Parse and ensure no timezone info for form field dt = datetime.fromisoformat(from_date_str) if dt.tzinfo: dt = dt.replace(tzinfo=None) - form.fields['from_date'].initial = dt + form.fields["from_date"].initial = dt except: pass - if 'to_date' in call_limits and call_limits['to_date']: + if "to_date" in call_limits and call_limits["to_date"]: try: - to_date_str = call_limits['to_date'].replace('Z', '') + to_date_str = call_limits["to_date"].replace("Z", "") # Parse and ensure no timezone info for form field dt = datetime.fromisoformat(to_date_str) if dt.tzinfo: dt = dt.replace(tzinfo=None) - form.fields['to_date'].initial = dt + form.fields["to_date"].initial = dt except: pass - form.fields['per_second_call_limit'].initial = call_limits.get('per_second_call_limit', '-1') - form.fields['per_minute_call_limit'].initial = call_limits.get('per_minute_call_limit', '-1') - form.fields['per_hour_call_limit'].initial = call_limits.get('per_hour_call_limit', '-1') - form.fields['per_day_call_limit'].initial = call_limits.get('per_day_call_limit', '-1') - form.fields['per_week_call_limit'].initial = call_limits.get('per_week_call_limit', '-1') - form.fields['per_month_call_limit'].initial = call_limits.get('per_month_call_limit', '-1') + form.fields["per_second_call_limit"].initial = call_limits.get( + "per_second_call_limit", "-1" + ) + form.fields["per_minute_call_limit"].initial = call_limits.get( + "per_minute_call_limit", "-1" + ) + form.fields["per_hour_call_limit"].initial = call_limits.get( + "per_hour_call_limit", "-1" + ) + form.fields["per_day_call_limit"].initial = call_limits.get( + "per_day_call_limit", "-1" + ) + form.fields["per_week_call_limit"].initial = call_limits.get( + "per_week_call_limit", "-1" + ) + form.fields["per_month_call_limit"].initial = call_limits.get( + "per_month_call_limit", "-1" + ) except: pass return form - def form_valid(self, form): + def post(self, request, *args, **kwargs): + """Handle POST requests for rate limit CRUD operations""" + action = request.POST.get("action") - """Put limits data to API""" + # Check if this is an AJAX request + if request.headers.get("X-Requested-With") == "XMLHttpRequest" or action in [ + "create", + "update", + "delete", + ]: + if action == "create": + return self.create_rate_limit(request) + elif action == "update": + return self.update_rate_limit(request) + elif action == "delete": + return self.delete_rate_limit(request) + + # Fallback to original form handling for compatibility + form = self.get_form() + if form.is_valid(): + return self.form_valid_legacy(request, form) + else: + return self.form_invalid(form) + + def create_rate_limit(self, request): + """Create a new rate limit using v6.0.0 POST API""" try: - data = '' - api_consumers_form = ApiConsumersForm(self.request.POST) - if api_consumers_form.is_valid(): - data = api_consumers_form.cleaned_data + consumer_id = self.kwargs["consumer_id"] - urlpath = '/management/consumers/{}/consumer/call-limits'.format(data['consumer_id']) + # Helper function to format datetime to UTC + def format_datetime_utc(dt_str): + if not dt_str: + return "2024-01-01T00:00:00Z" + try: + dt = datetime.fromisoformat(dt_str) + return dt.strftime("%Y-%m-%dT%H:%M:%SZ") + except: + return "2024-01-01T00:00:00Z" + + payload = { + "from_date": format_datetime_utc(request.POST.get("from_date")), + "to_date": format_datetime_utc(request.POST.get("to_date")), + "per_second_call_limit": str( + request.POST.get("per_second_call_limit", "-1") + ), + "per_minute_call_limit": str( + request.POST.get("per_minute_call_limit", "-1") + ), + "per_hour_call_limit": str( + request.POST.get("per_hour_call_limit", "-1") + ), + "per_day_call_limit": str(request.POST.get("per_day_call_limit", "-1")), + "per_week_call_limit": str( + request.POST.get("per_week_call_limit", "-1") + ), + "per_month_call_limit": str( + request.POST.get("per_month_call_limit", "-1") + ), + } + + # Use v6.0.0 API for creating rate limits + urlpath = "/management/consumers/{}/consumer/call-limits".format( + consumer_id + ) + response = self.api.post( + urlpath, payload, version=settings.API_VERSION["v600"] + ) + + if "code" in response and response["code"] >= 400: + messages.error(request, response["message"]) + else: + messages.success(request, "Rate limit created successfully.") + + except APIError as err: + messages.error(request, str(err)) + except Exception as err: + messages.error(request, "Error creating rate limit: {}".format(err)) + + if request.headers.get("X-Requested-With") == "XMLHttpRequest": + return JsonResponse({"success": True, "redirect": request.path}) + else: + return HttpResponseRedirect(request.path) + + def update_rate_limit(self, request): + """Update existing rate limit using v5.1.0 PUT API""" + try: + consumer_id = self.kwargs["consumer_id"] + + # Helper function to format datetime to UTC + def format_datetime_utc(dt_str): + if not dt_str: + return "2024-01-01T00:00:00Z" + try: + dt = datetime.fromisoformat(dt_str) + return dt.strftime("%Y-%m-%dT%H:%M:%SZ") + except: + return "2024-01-01T00:00:00Z" + + payload = { + "from_date": format_datetime_utc(request.POST.get("from_date")), + "to_date": format_datetime_utc(request.POST.get("to_date")), + "per_second_call_limit": str( + request.POST.get("per_second_call_limit", "-1") + ), + "per_minute_call_limit": str( + request.POST.get("per_minute_call_limit", "-1") + ), + "per_hour_call_limit": str( + request.POST.get("per_hour_call_limit", "-1") + ), + "per_day_call_limit": str(request.POST.get("per_day_call_limit", "-1")), + "per_week_call_limit": str( + request.POST.get("per_week_call_limit", "-1") + ), + "per_month_call_limit": str( + request.POST.get("per_month_call_limit", "-1") + ), + } + + # Use v5.1.0 API for updating rate limits + urlpath = "/management/consumers/{}/consumer/call-limits".format( + consumer_id + ) + response = self.api.put( + urlpath, payload, version=settings.API_VERSION["v510"] + ) + + if "code" in response and response["code"] >= 400: + messages.error(request, response["message"]) + else: + messages.success(request, "Rate limit updated successfully.") + + except APIError as err: + messages.error(request, str(err)) + except Exception as err: + messages.error(request, "Error updating rate limit: {}".format(err)) + + if request.headers.get("X-Requested-With") == "XMLHttpRequest": + return JsonResponse({"success": True, "redirect": request.path}) + else: + return HttpResponseRedirect(request.path) + + def delete_rate_limit(self, request): + """Delete a rate limit using v6.0.0 DELETE API""" + try: + consumer_id = self.kwargs["consumer_id"] + rate_limiting_id = request.POST.get("rate_limiting_id") + + if not rate_limiting_id: + messages.error(request, "Rate limiting ID is required for deletion.") + return JsonResponse( + {"success": False, "error": "Missing rate limiting ID"} + ) + + # Use v6.0.0 API for deleting rate limits + urlpath = "/management/consumers/{}/consumer/call-limits/{}".format( + consumer_id, rate_limiting_id + ) + response = self.api.delete(urlpath, version=settings.API_VERSION["v600"]) + + if "code" in response and response["code"] >= 400: + messages.error(request, response["message"]) + else: + messages.success(request, "Rate limit deleted successfully.") + + except APIError as err: + messages.error(request, str(err)) + except Exception as err: + messages.error(request, "Error deleting rate limit: {}".format(err)) + + if request.headers.get("X-Requested-With") == "XMLHttpRequest": + return JsonResponse({"success": True, "redirect": request.path}) + else: + return HttpResponseRedirect(request.path) + + def form_valid_legacy(self, request, form): + """Legacy form handling for backwards compatibility""" + try: + data = form.cleaned_data + + urlpath = "/management/consumers/{}/consumer/call-limits".format( + data["consumer_id"] + ) # Helper function to format datetime to UTC def format_datetime_utc(dt): @@ -171,107 +369,172 @@ class DetailView(LoginRequiredMixin, FormView): # Convert to UTC and format as required by API if dt.tzinfo: dt = dt.astimezone(dt_module.timezone.utc).replace(tzinfo=None) - return dt.strftime('%Y-%m-%dT%H:%M:%SZ') + return dt.strftime("%Y-%m-%dT%H:%M:%SZ") payload = { - 'from_date': format_datetime_utc(data['from_date']), - 'to_date': format_datetime_utc(data['to_date']), - 'per_second_call_limit': str(data['per_second_call_limit']) if data['per_second_call_limit'] is not None else "-1", - 'per_minute_call_limit': str(data['per_minute_call_limit']) if data['per_minute_call_limit'] is not None else "-1", - 'per_hour_call_limit': str(data['per_hour_call_limit']) if data['per_hour_call_limit'] is not None else "-1", - 'per_day_call_limit': str(data['per_day_call_limit']) if data['per_day_call_limit'] is not None else "-1", - 'per_week_call_limit': str(data['per_week_call_limit']) if data['per_week_call_limit'] is not None else "-1", - 'per_month_call_limit': str(data['per_month_call_limit']) if data['per_month_call_limit'] is not None else "-1" + "from_date": format_datetime_utc(data["from_date"]), + "to_date": format_datetime_utc(data["to_date"]), + "per_second_call_limit": str(data["per_second_call_limit"]) + if data["per_second_call_limit"] is not None + else "-1", + "per_minute_call_limit": str(data["per_minute_call_limit"]) + if data["per_minute_call_limit"] is not None + else "-1", + "per_hour_call_limit": str(data["per_hour_call_limit"]) + if data["per_hour_call_limit"] is not None + else "-1", + "per_day_call_limit": str(data["per_day_call_limit"]) + if data["per_day_call_limit"] is not None + else "-1", + "per_week_call_limit": str(data["per_week_call_limit"]) + if data["per_week_call_limit"] is not None + else "-1", + "per_month_call_limit": str(data["per_month_call_limit"]) + if data["per_month_call_limit"] is not None + else "-1", } - response = self.api.put(urlpath, payload) - if 'code' in response and response['code'] >= 400: - messages.error(self.request, response['message']) - return super(DetailView, self).form_invalid(api_consumers_form) + response = self.api.put( + urlpath, payload, version=settings.API_VERSION["v510"] + ) + if "code" in response and response["code"] >= 400: + messages.error(request, response["message"]) + return super(DetailView, self).form_invalid(form) except APIError as err: - messages.error(self.request, err) - return super(DetailView, self).form_invalid(api_consumers_form) + messages.error(request, err) + return super(DetailView, self).form_invalid(form) except Exception as err: - messages.error(self.request, "{}".format(err)) - return super(DetailView, self).form_invalid(api_consumers_form) + messages.error(request, "{}".format(err)) + return super(DetailView, self).form_invalid(form) - msg = 'Rate limits for consumer {} have been updated successfully.'.format( - data['consumer_id']) - messages.success(self.request, msg) - self.success_url = self.request.path - return super(DetailView, self).form_valid(api_consumers_form) + msg = "Rate limits for consumer {} have been updated successfully.".format( + data["consumer_id"] + ) + messages.success(request, msg) + if request.headers.get("X-Requested-With") == "XMLHttpRequest": + return JsonResponse({"success": True, "redirect": request.path}) + else: + return HttpResponseRedirect(request.path) def get(self, request, *args, **kwargs): # Check if this is an AJAX request for usage data - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + if request.headers.get("X-Requested-With") == "XMLHttpRequest": return self.get_usage_data_ajax() return super(DetailView, self).get(request, *args, **kwargs) def get_usage_data_ajax(self): """Return usage data as JSON for AJAX refresh""" - api = API(self.request.session.get('obp')) + api = API(self.request.session.get("obp")) try: - call_limits_urlpath = '/management/consumers/{}/consumer/call-limits'.format(self.kwargs['consumer_id']) + call_limits_urlpath = ( + "/management/consumers/{}/consumer/call-limits".format( + self.kwargs["consumer_id"] + ) + ) call_limits = api.get(call_limits_urlpath) - if 'code' in call_limits and call_limits['code'] >= 400: - return JsonResponse({'error': call_limits['message']}, status=400) + if "code" in call_limits and call_limits["code"] >= 400: + return JsonResponse({"error": call_limits["message"]}, status=400) return JsonResponse(call_limits) except APIError as err: - return JsonResponse({'error': str(err)}, status=500) + return JsonResponse({"error": str(err)}, status=500) except Exception as err: - return JsonResponse({'error': str(err)}, status=500) + return JsonResponse({"error": str(err)}, status=500) def get_context_data(self, **kwargs): context = super(DetailView, self).get_context_data(**kwargs) - api = API(self.request.session.get('obp')) + api = API(self.request.session.get("obp")) consumer = {} call_limits = {} try: - urlpath = '/management/consumers/{}'.format(self.kwargs['consumer_id']) + urlpath = "/management/consumers/{}".format(self.kwargs["consumer_id"]) consumer = api.get(urlpath) - if 'code' in consumer and consumer['code'] >= 400: - messages.error(self.request, consumer['message']) + if "code" in consumer and consumer["code"] >= 400: + messages.error(self.request, consumer["message"]) consumer = {} else: - consumer['created'] = datetime.strptime( - consumer['created'], settings.API_DATE_FORMAT_WITH_SECONDS ) + consumer["created"] = datetime.strptime( + consumer["created"], settings.API_DATE_FORMAT_WITH_SECONDS + ) # Get call limits using the correct API endpoint - call_limits_urlpath = '/management/consumers/{}/consumer/call-limits'.format(self.kwargs['consumer_id']) - call_limits = api.get(call_limits_urlpath) + call_limits_urlpath = ( + "/management/consumers/{}/consumer/call-limits".format( + self.kwargs["consumer_id"] + ) + ) + call_limits = api.get( + call_limits_urlpath, version=settings.API_VERSION["v510"] + ) - if 'code' in call_limits and call_limits['code'] >= 400: - messages.error(self.request, "{}".format(call_limits['message'])) - call_limits = {} + if "code" in call_limits and call_limits["code"] >= 400: + messages.error(self.request, "{}".format(call_limits["message"])) + call_limits = {"limits": []} else: - # Merge call limits data into consumer object - consumer.update({ - 'from_date': call_limits.get('from_date', ''), - 'to_date': call_limits.get('to_date', ''), - 'per_second_call_limit': call_limits.get('per_second_call_limit', '-1'), - 'per_minute_call_limit': call_limits.get('per_minute_call_limit', '-1'), - 'per_hour_call_limit': call_limits.get('per_hour_call_limit', '-1'), - 'per_day_call_limit': call_limits.get('per_day_call_limit', '-1'), - 'per_week_call_limit': call_limits.get('per_week_call_limit', '-1'), - 'per_month_call_limit': call_limits.get('per_month_call_limit', '-1'), - 'current_state': call_limits.get('current_state', {}), - 'created_at': call_limits.get('created_at', ''), - 'updated_at': call_limits.get('updated_at', ''), - }) + # Handle different API response structures + import uuid + + # Handle case where API returns data directly instead of in 'limits' array + if ( + "limits" not in call_limits + and "per_second_call_limit" in call_limits + ): + # API returned single limit object, wrap it in limits array + if "rate_limiting_id" not in call_limits: + call_limits["rate_limiting_id"] = str(uuid.uuid4()) + call_limits = {"limits": [call_limits]} + elif "limits" not in call_limits: + # No limits data found + call_limits = {"limits": []} + else: + # Ensure each limit has a rate_limiting_id + for limit in call_limits.get("limits", []): + if ( + "rate_limiting_id" not in limit + or not limit["rate_limiting_id"] + ): + limit["rate_limiting_id"] = str(uuid.uuid4()) + + # For backwards compatibility, merge first limit into consumer if limits exist + if call_limits.get("limits") and len(call_limits["limits"]) > 0: + first_limit = call_limits["limits"][0] + consumer.update( + { + "from_date": first_limit.get("from_date", ""), + "to_date": first_limit.get("to_date", ""), + "per_second_call_limit": first_limit.get( + "per_second_call_limit", "-1" + ), + "per_minute_call_limit": first_limit.get( + "per_minute_call_limit", "-1" + ), + "per_hour_call_limit": first_limit.get( + "per_hour_call_limit", "-1" + ), + "per_day_call_limit": first_limit.get( + "per_day_call_limit", "-1" + ), + "per_week_call_limit": first_limit.get( + "per_week_call_limit", "-1" + ), + "per_month_call_limit": first_limit.get( + "per_month_call_limit", "-1" + ), + "current_state": call_limits.get("current_state", {}), + "created_at": first_limit.get("created_at", ""), + "updated_at": first_limit.get("updated_at", ""), + } + ) except APIError as err: messages.error(self.request, err) except Exception as err: messages.error(self.request, "{}".format(err)) finally: - context.update({ - 'consumer': consumer, - 'call_limits': call_limits - }) + context.update({"consumer": consumer, "call_limits": call_limits}) return context @@ -279,52 +542,61 @@ class UsageDataAjaxView(LoginRequiredMixin, TemplateView): """AJAX view to return usage data for real-time updates""" def get(self, request, *args, **kwargs): - api = API(self.request.session.get('obp')) + api = API(self.request.session.get("obp")) try: - call_limits_urlpath = '/management/consumers/{}/consumer/call-limits'.format(self.kwargs['consumer_id']) - call_limits = api.get(call_limits_urlpath) + call_limits_urlpath = ( + "/management/consumers/{}/consumer/call-limits".format( + self.kwargs["consumer_id"] + ) + ) + call_limits = api.get( + call_limits_urlpath, version=settings.API_VERSION["v510"] + ) - if 'code' in call_limits and call_limits['code'] >= 400: - return JsonResponse({'error': call_limits['message']}, status=400) + if "code" in call_limits and call_limits["code"] >= 400: + return JsonResponse({"error": call_limits["message"]}, status=400) return JsonResponse(call_limits) except APIError as err: - return JsonResponse({'error': str(err)}, status=500) + return JsonResponse({"error": str(err)}, status=500) except Exception as err: - return JsonResponse({'error': str(err)}, status=500) + return JsonResponse({"error": str(err)}, status=500) class EnableDisableView(LoginRequiredMixin, RedirectView): """View to enable or disable a consumer""" + enabled = False success = None def get_redirect_url(self, *args, **kwargs): - api = API(self.request.session.get('obp')) + api = API(self.request.session.get("obp")) try: - urlpath = '/management/consumers/{}'.format(kwargs['consumer_id']) - payload = {'enabled': self.enabled} + urlpath = "/management/consumers/{}".format(kwargs["consumer_id"]) + payload = {"enabled": self.enabled} response = api.put(urlpath, payload) - if 'code' in response and response['code'] >= 400: - messages.error(self.request, response['message']) + if "code" in response and response["code"] >= 400: + messages.error(self.request, response["message"]) else: messages.success(self.request, self.success) except APIError as err: messages.error(self.request, err) - urlpath = self.request.POST.get('next', reverse('consumers-index')) + urlpath = self.request.POST.get("next", reverse("consumers-index")) query = self.request.GET.urlencode() - redirect_url = '{}?{}'.format(urlpath, query) + redirect_url = "{}?{}".format(urlpath, query) return redirect_url class EnableView(EnableDisableView): """View to enable a consumer""" + enabled = True success = "Consumer has been enabled." class DisableView(EnableDisableView): """View to disable a consumer""" + enabled = False success = "Consumer has been disabled."