diff --git a/awx/api/filters.py b/awx/api/filters.py index 1a6e3eb90e..e574fce539 100644 --- a/awx/api/filters.py +++ b/awx/api/filters.py @@ -385,6 +385,10 @@ class FieldLookupBackend(BaseFilterBackend): raise ParseError(json.dumps(e.messages, ensure_ascii=False)) +class HostMetricSummaryMonthlyFieldLookupBackend(FieldLookupBackend): + RESERVED_NAMES = ('page', 'page_size', 'format', 'order', 'order_by', 'search', 'type', 'past_months', 'count_disabled', 'no_truncate', 'limit') + + class OrderByBackend(BaseFilterBackend): """ Filter to apply ordering based on query string parameters. diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 5989adb4db..1ff00854c2 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -57,6 +57,7 @@ from awx.main.models import ( Group, Host, HostMetric, + HostMetricSummaryMonthly, Instance, InstanceGroup, InstanceLink, @@ -5406,6 +5407,13 @@ class HostMetricSerializer(BaseSerializer): ) +class HostMetricSummaryMonthlySerializer(BaseSerializer): + class Meta: + model = HostMetricSummaryMonthly + read_only_fields = ("id", "date", "license_consumed", "license_capacity", "hosts_added", "hosts_deleted", "indirectly_managed_hosts") + fields = read_only_fields + + class InstanceGroupSerializer(BaseSerializer): show_capabilities = ['edit', 'delete'] capacity = serializers.SerializerMethodField() diff --git a/awx/api/urls/urls.py b/awx/api/urls/urls.py index b9dbafca33..c7d73165c3 100644 --- a/awx/api/urls/urls.py +++ b/awx/api/urls/urls.py @@ -30,6 +30,7 @@ from awx.api.views import ( OAuth2TokenList, ApplicationOAuth2TokenList, OAuth2ApplicationDetail, + HostMetricSummaryMonthlyList, ) from awx.api.views.bulk import ( @@ -120,6 +121,7 @@ v2_urls = [ re_path(r'^inventories/', include(inventory_urls)), re_path(r'^hosts/', include(host_urls)), re_path(r'^host_metrics/', include(host_metric_urls)), + re_path(r'^host_metric_summary_monthly/$', HostMetricSummaryMonthlyList.as_view(), name='host_metric_summary_monthly_list'), re_path(r'^groups/', include(group_urls)), re_path(r'^inventory_sources/', include(inventory_source_urls)), re_path(r'^inventory_updates/', include(inventory_update_urls)), diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 7dda3d4d44..0dd0f306cd 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -3,6 +3,7 @@ # Python import dateutil +import datetime import functools import html import itertools @@ -17,7 +18,6 @@ from collections import OrderedDict from urllib3.exceptions import ConnectTimeoutError - # Django from django.conf import settings from django.core.exceptions import FieldError, ObjectDoesNotExist @@ -122,6 +122,7 @@ from awx.api.views.mixin import ( UnifiedJobDeletionMixin, NoTruncateMixin, ) +from awx.api.filters import HostMetricSummaryMonthlyFieldLookupBackend from awx.api.pagination import UnifiedJobEventPagination from awx.main.utils import set_environ @@ -1568,6 +1569,37 @@ class HostMetricDetail(RetrieveDestroyAPIView): return Response(status=status.HTTP_204_NO_CONTENT) +class HostMetricSummaryMonthlyList(ListAPIView): + name = _("Host Metrics Summary Monthly") + model = models.HostMetricSummaryMonthly + permission_classes = (IsSystemAdminOrAuditor,) + serializer_class = serializers.HostMetricSummaryMonthlySerializer + search_fields = ('date',) + filter_backends = [HostMetricSummaryMonthlyFieldLookupBackend] + + def get_queryset(self): + queryset = super().get_queryset() + past_months = self.request.query_params.get('past_months', None) + date_from = self._get_date_from(past_months) + + queryset = queryset.filter(date__gte=date_from) + return queryset + + @staticmethod + def _get_date_from(past_months, default=12, maximum=36): + try: + months_ago = int(past_months or default) + except ValueError: + months_ago = default + months_ago = min(months_ago, maximum) + months_ago = max(months_ago, 1) + + date_from = datetime.date.today() - dateutil.relativedelta.relativedelta(months=months_ago) + # Set to beginning of the month + date_from = date_from.replace(day=1).isoformat() + return date_from + + class HostList(HostRelatedSearchMixin, ListCreateAPIView): always_allow_superuser = False model = models.Host diff --git a/awx/main/access.py b/awx/main/access.py index 185ab1e061..5cca1ff133 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -38,6 +38,7 @@ from awx.main.models import ( Group, Host, HostMetric, + HostMetricSummaryMonthly, Instance, InstanceGroup, Inventory, @@ -912,6 +913,33 @@ class HostMetricAccess(BaseAccess): return bool(self.user.is_superuser or (obj and obj.user == self.user)) +class HostMetricSummaryMonthlyAccess(BaseAccess): + """ + - I can see host metrics when I'm a super user or system auditor. + """ + + model = HostMetricSummaryMonthly + + def get_queryset(self): + if self.user.is_superuser or self.user.is_system_auditor: + qs = self.model.objects.all() + else: + qs = self.filtered_queryset() + return qs + + def can_read(self, obj): + return bool(self.user.is_superuser or self.user.is_system_auditor or (obj and obj.user == self.user)) + + def can_add(self, data): + return False # There is no API endpoint to POST new settings. + + def can_change(self, obj, data): + return False + + def can_delete(self, obj): + return False + + class InventoryAccess(BaseAccess): """ I can see inventory when: diff --git a/awx/main/migrations/0176_hostmetricsummarymonthly.py b/awx/main/migrations/0176_hostmetricsummarymonthly.py new file mode 100644 index 0000000000..735f46f0d6 --- /dev/null +++ b/awx/main/migrations/0176_hostmetricsummarymonthly.py @@ -0,0 +1,33 @@ +# Generated by Django 3.2.16 on 2023-02-10 12:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('main', '0175_add_hostmetric_fields'), + ] + + operations = [ + migrations.CreateModel( + name='HostMetricSummaryMonthly', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateField(unique=True)), + ('license_consumed', models.BigIntegerField(default=0, help_text='How much unique hosts are consumed from the license')), + ('license_capacity', models.BigIntegerField(default=0, help_text="'License capacity as max. number of unique hosts")), + ( + 'hosts_added', + models.BigIntegerField(default=0, help_text='How many hosts were added in the associated month, consuming more license capacity'), + ), + ( + 'hosts_deleted', + models.BigIntegerField(default=0, help_text='How many hosts were deleted in the associated month, freeing the license capacity'), + ), + ( + 'indirectly_managed_hosts', + models.BigIntegerField(default=0, help_text='Manually entered number indirectly managed hosts for a certain month'), + ), + ], + ), + ] diff --git a/awx/main/models/__init__.py b/awx/main/models/__init__.py index ed49b98083..8a608aeead 100644 --- a/awx/main/models/__init__.py +++ b/awx/main/models/__init__.py @@ -16,6 +16,7 @@ from awx.main.models.inventory import ( # noqa Group, Host, HostMetric, + HostMetricSummaryMonthly, Inventory, InventorySource, InventoryUpdate, diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index 41a85b0071..c937c4342d 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -53,7 +53,7 @@ from awx.main.utils.execution_environments import to_container_path from awx.main.utils.licensing import server_product_name -__all__ = ['Inventory', 'Host', 'Group', 'InventorySource', 'InventoryUpdate', 'SmartInventoryMembership'] +__all__ = ['Inventory', 'Host', 'Group', 'InventorySource', 'InventoryUpdate', 'SmartInventoryMembership', 'HostMetric', 'HostMetricSummaryMonthly'] logger = logging.getLogger('awx.main.models.inventory') @@ -850,6 +850,19 @@ class HostMetric(models.Model): self.save() +class HostMetricSummaryMonthly(models.Model): + """ + HostMetric summaries computed by scheduled task monthly + """ + + date = models.DateField(unique=True) + license_consumed = models.BigIntegerField(default=0, help_text=_("How much unique hosts are consumed from the license")) + license_capacity = models.BigIntegerField(default=0, help_text=_("'License capacity as max. number of unique hosts")) + hosts_added = models.BigIntegerField(default=0, help_text=_("How many hosts were added in the associated month, consuming more license capacity")) + hosts_deleted = models.BigIntegerField(default=0, help_text=_("How many hosts were deleted in the associated month, freeing the license capacity")) + indirectly_managed_hosts = models.BigIntegerField(default=0, help_text=("Manually entered number indirectly managed hosts for a certain month")) + + class InventorySourceOptions(BaseModel): """ Common fields for InventorySource and InventoryUpdate.